diff --git a/.coverage b/.coverage index 7447211..3a3a86c 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b0e8d00..93f41fa 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,16 +1,25 @@ name: ci-cd -on: ["create"] release/v* +on: + push: + tags: + - "v*" jobs: pypi-publish: name: Upload release to PyPI runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/ + url: https://pypi.org/p/seed-cli permissions: - id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write steps: - # retrieve your distributions here - + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + - run: python -m pip install -U pip hatch twine + - run: hatch build - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* diff --git a/.gitignore b/.gitignore index 0436bf5..2ac68ab 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,173 @@ vscode-extension/ *.tree err-*.txt +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class -__pycache__ +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json diff --git a/README.md b/README.md index 0b3d8c3..e32193b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Think **Terraform for directory trees**. - Partial plans (`--target scripts/`) - Spec inheritance (`@include`) - Variables (`{{project_name}}`) +- Template content from GitHub (`source.json`) - Plugins - Checksums & drift detection - CI & pre-commit hooks @@ -159,6 +160,29 @@ seed create spec.tree version_id=v3 --- +## Template Content Sources + +Templates can point to real file contents via `source.json` or the `--content-url` flag. When a content source is set, `seed` fetches actual files (from a local directory or a GitHub tree URL) and stores them alongside the spec. + +```bash +# Add a template with content from GitHub +seed templates add ./fastapi --name fastapi \ + --content-url https://github.com/tiangolo/full-stack-fastapi-template/tree/master/backend/app + +# Re-fetch content from the stored source +seed templates update fastapi + +# Update all templates with content sources +seed templates update --all + +# Change where content is fetched from +seed templates update fastapi --content-url https://github.com/other/repo/tree/main/src +``` + +Templates that include a `source.json` file (containing `{"content_url": "..."}`) will automatically fetch content when installed. The built-in `fastapi`, `python-package`, and `node-typescript` templates use this feature. + +--- + ## Match Command The `match` command modifies the filesystem to match your spec, creating missing items and deleting extras: diff --git a/dist/seed_cli-1.0.2-py3-none-any.whl b/dist/seed_cli-1.0.2-py3-none-any.whl deleted file mode 100644 index 777a8ac..0000000 Binary files a/dist/seed_cli-1.0.2-py3-none-any.whl and /dev/null differ diff --git a/dist/seed_cli-1.0.2.tar.gz b/dist/seed_cli-1.0.2.tar.gz deleted file mode 100644 index 68419dd..0000000 Binary files a/dist/seed_cli-1.0.2.tar.gz and /dev/null differ diff --git a/dist/seed_cli-1.0.3-py3-none-any.whl b/dist/seed_cli-1.0.3-py3-none-any.whl deleted file mode 100644 index 263bc23..0000000 Binary files a/dist/seed_cli-1.0.3-py3-none-any.whl and /dev/null differ diff --git a/dist/seed_cli-1.0.3.tar.gz b/dist/seed_cli-1.0.3.tar.gz deleted file mode 100644 index c2b02b6..0000000 Binary files a/dist/seed_cli-1.0.3.tar.gz and /dev/null differ diff --git a/dist/seed_cli-1.0.4.tar.gz b/dist/seed_cli-1.0.4.tar.gz deleted file mode 100644 index 1bc39cb..0000000 Binary files a/dist/seed_cli-1.0.4.tar.gz and /dev/null differ diff --git a/dist/seed_cli-1.0.4-py3-none-any.whl b/dist/seed_cli-1.0.5-py3-none-any.whl similarity index 56% rename from dist/seed_cli-1.0.4-py3-none-any.whl rename to dist/seed_cli-1.0.5-py3-none-any.whl index 198ac84..cab92f9 100644 Binary files a/dist/seed_cli-1.0.4-py3-none-any.whl and b/dist/seed_cli-1.0.5-py3-none-any.whl differ diff --git a/dist/seed_cli-1.0.5.tar.gz b/dist/seed_cli-1.0.5.tar.gz new file mode 100644 index 0000000..8aceac5 Binary files /dev/null and b/dist/seed_cli-1.0.5.tar.gz differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..2e460d0 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,1254 @@ + + + + + + seed-cli - Terraform for your filesystem + + + + + + +
+
+
+ seed-cli logo +

seed-cli

+
+

Declarative filesystem management for developers

+
+ + + + +
+
+ seed-cli +

Terraform for your filesystem

+

Define, version, and replicate directory structures declaratively

+

Browse Recipes →

+
+ +
+
+

Declarative

+

Define your filesystem structure in simple, readable spec files

+
+
+

Snapshots

+

Capture and restore directory states at any point in time

+
+
+

Versioning

+

Track changes, lock versions, and safely upgrade structures

+
+
+

Templates

+

Reusable specs with variables for consistent project scaffolding

+
+
+ +
+ +

Quick Start

+ +

1. Install

+
+ +
pip install seed-cli
+
+ +

2. Capture existing structure

+
+ +
seed capture --out project.tree
+
+ +

3. Preview changes

+
+ +
seed plan project.tree
+
+ +

4. Apply the spec

+
+ +
seed apply project.tree
+
+ +

Global Options

+

Available on all commands:

+
+ +
seed [command] --verbose          # Enable verbose logging
+seed [command] --debug            # Enable debug logging
+seed [command] --ignore PATTERN   # Extra ignore patterns (glob)
+seed [command] --targets PATTERN  # Extra targets (glob)
+seed [command] --target-mode {prefix,exact}  # Target matching mode
+
+
+ + +
+

Core Commands

+ +
+

seed plan <spec>

+

Parse spec and generate execution plan. Runs plugin parse + plan lifecycle and outputs the planned changes without modifying the filesystem.

+
+ --base BASE Base directory (default: current directory)
+ --vars VARS Template variables (key=value)
+ --out OUT Output plan to file (JSON format)
+ --dot Output plan as Graphviz DOT format
+ --no-skip Hide SKIP lines in output for cleaner view +
+
+
+ +
# Preview what would be created
+seed plan project.tree
+
+# With template variables
+seed plan project.tree --vars environment=prod
+
+# Export plan as JSON
+seed plan project.tree --out plan.json
+
+# Export as Graphviz DOT
+seed plan project.tree --dot > plan.dot
+
+ +
+

seed apply <spec>

+

Acquire state lock, run hooks (pre_apply, post_apply), execute plan, and run plugin build lifecycle. Creates files and directories as specified.

+
+ --base BASE Base directory (default: current directory)
+ --dangerous Allow dangerous operations
+ --dry-run Show what would be executed without making changes
+ --yes, -y Create all optional items (marked with ?) without prompting
+ --skip-optional Skip all optional items (marked with ?) without prompting +
+
+
+ +
# Apply spec to current directory
+seed apply project.tree
+
+# Apply to specific directory
+seed apply project.tree --base ./myproject
+
+# Preview without making changes
+seed apply project.tree --dry-run
+
+# Auto-create all optional items
+seed apply project.tree --yes
+
+# Skip all optional items
+seed apply project.tree --skip-optional
+
+ +
+

seed diff <spec>

+

Compare spec with filesystem and show missing, extra, and drifted paths. Useful for detecting drift from your intended structure.

+
+ --base BASE Base directory (default: current directory)
+ --ignore PATTERN Ignore paths matching pattern (can be repeated)
+ --no-sublevels Hide extras inside directories defined in the spec +
+
+
+ +
# Compare spec with current directory
+seed diff project.tree
+
+# Ignore certain patterns
+seed diff project.tree --ignore "*.pyc" --ignore "__pycache__"
+
+# Only show top-level extras
+seed diff project.tree --no-sublevels
+
+ +
+

seed capture

+

Capture current filesystem state as a spec file. Useful for creating initial specs from existing projects.

+
+ --base BASE Base directory (default: current directory)
+ --json Output in JSON format
+ --dot Output in Graphviz DOT format
+ --out OUT Output file path (prints to stdout if not specified) +
+
+
+ +
# Capture to file
+seed capture --out project.tree
+
+# Capture specific directory
+seed capture --base ./src --out src.tree
+
+# Output as JSON
+seed capture --json --out project.json
+
+# Output as Graphviz DOT
+seed capture --dot --out project.dot
+
+ +
+

seed create <spec> <values...>

+

Create a new instance of a template directory structure. Use with specs containing <varname>/ template patterns.

+
+ --base BASE Base directory (default: current directory)
+ --dry-run Preview what would be created +
+
+
+ +
# Create new version directory from template
+seed create releases.tree version_id=v3
+
+# Preview without creating
+seed create releases.tree version_id=v3 --dry-run
+
+# Multiple variables
+seed create project.tree name=myapp env=production
+
+
+ + +
+

Sync & Match

+

These commands modify the filesystem to match your spec exactly, including deleting extra files. Use with caution.

+ +
+

seed sync <spec> dangerous

+

Same as apply, but also deletes extraneous files. Plugins may veto deletions. Requires --dangerous flag.

+
+ --base BASE Base directory (default: current directory)
+ --dangerous Required flag to enable sync (not required with --dry-run)
+ --dry-run Show what would be executed without making changes
+ --yes, -y Create all optional items (marked with ?) without prompting
+ --skip-optional Skip all optional items (marked with ?) without prompting +
+
+
+ +
# Preview what would be synced
+seed sync project.tree --dry-run
+
+# Actually sync (deletes extra files!)
+seed sync project.tree --dangerous
+
+# Sync with auto-create optionals
+seed sync project.tree --dangerous --yes
+
+ +
+

seed match <spec> dangerous

+

Modify filesystem to match the spec. Creates missing items and deletes extras. Use ... in spec to mark directories where extra files are allowed.

+
+ --base BASE Base directory (default: current directory)
+ --dangerous Required flag (will create/delete files). Not required with --dry-run
+ --dry-run Preview changes without modifying filesystem
+ --yes, -y Create all optional items (marked with ?) without prompting
+ --skip-optional Skip all optional items (marked with ?) without prompting +
+
+
+ +
# Preview what would change
+seed match project.tree --dry-run
+
+# Match filesystem to spec (creates and deletes!)
+seed match project.tree --dangerous
+
+# Match specific directory
+seed match project.tree --base ./target --dangerous
+
+ +

Protecting Directories from Deletion

+

Use ... to mark directories where extra files should be preserved:

+
+ +
project/
+├── src/
+│   └── main.py
+├── data/       ...   # Extra files here won't be deleted
+└── config.yaml
+
+
+ + +
+

Spec Syntax

+

Spec files use an intuitive tree-like syntax. Supports .tree, .yaml, .json, .dot, and even images.

+ +

Basic Structure

+
+ +
project/
+├── src/
+│   ├── main.py
+│   └── utils/
+│       └── helpers.py
+├── tests/
+│   └── test_main.py
+├── config.yaml
+└── README.md
+
+ +

Annotations

+
    +
  • @generated - File is auto-generated, can be safely overwritten
  • +
  • @manual - File is manually maintained, never overwrite
  • +
  • ? - Optional file or directory (prompts during apply)
  • +
  • ... - Directory has contents but we don't manage them (preserves extras during sync/match)
  • +
+
+ +
scripts/
+├── build.py      @generated
+├── config.json   @manual
+├── cache/        ...
+└── docs/?
+
+ +

Template Variables

+

Create reusable specs with dynamic values:

+
    +
  • <varname>/ - Directory placeholder, replaced at runtime
  • +
  • {{var}} - String interpolation in file contents
  • +
+
+ +
<project_name>/
+├── src/
+│   └── <module_name>/
+│       └── __init__.py
+├── <environment>/
+│   └── config.yaml
+└── README.md
+
+ +

Using Variables

+
+ +
# With seed plan/apply
+seed apply spec.tree --vars project_name=myapp module_name=core
+
+# With seed create
+seed create spec.tree project_name=myapp environment=prod
+
+ +

Includes

+

Import other spec files to compose larger structures:

+
+ +
@include base.tree
+@include ./shared/common.tree
+
+project/
+├── custom/
+└── specific.py
+
+
+ + +
+

Template Registry

+

Manage reusable spec templates from GitHub or local sources.

+ +
+

seed templates add <source>

+

Add a template from GitHub URL or local file path. Supports fetching real file contents from a content source.

+
+ --name, -n NAME Name for the template (default: derived from URL/filename)
+ --version, -v VERSION Version name (default: auto-increment)
+ --content-url URL Fetch file contents from a local path or GitHub tree URL +
+
+
+ +
# From GitHub
+seed templates add https://github.com/user/template-repo
+
+# From local file
+seed templates add ./my-templates/python-project.tree
+
+# With custom name
+seed templates add ./template.tree --name my-template
+
+# With specific version
+seed templates add ./template.tree --name my-template --version v1.0
+
+# With content from GitHub
+seed templates add ./fastapi --name fastapi \
+  --content-url https://github.com/tiangolo/full-stack-fastapi-template/tree/master/backend/app
+
+# With content from a local directory
+seed templates add ./my-template --name myapp --content-url /path/to/local/files
+
+ +
+

seed templates list

+

List all stored templates with their versions and lock status.

+
+
+ +
seed templates list
+
+ +
+

seed templates use <name>

+

Apply a template to the filesystem. Creates the directory structure defined in the template.

+
+ --version, -v VERSION Version to use (default: current)
+ --base BASE Base directory (default: current directory)
+ --vars VARS Template variables (key=value)
+ --yes, -y Apply without prompting
+ --dry-run Show plan without applying +
+
+
+ +
# Use template in current directory
+seed templates use python-project
+
+# Use in specific directory
+seed templates use python-project --base ./myproject
+
+# With variables
+seed templates use python-project --vars name=myapp version=1.0
+
+# Preview without applying
+seed templates use python-project --dry-run
+
+# Use specific version
+seed templates use python-project --version v2
+
+ +
+

seed templates show <name>

+

Show the content of a template.

+
+ --version, -v VERSION Version to show (default: current) +
+
+
+ +
# Show current version
+seed templates show python-project
+
+# Show specific version
+seed templates show python-project --version v1
+
+ +
+

seed templates versions <name>

+

Manage template versions. List versions, add new versions, or set current version.

+
+ --add PATH Add a new version from file
+ --name VERSION Name for the new version (with --add)
+ --set-current VERSION Set version as current +
+
+
+ +
# List versions
+seed templates versions python-project
+
+# Add new version
+seed templates versions python-project --add ./updated.tree --name v2
+
+# Set current version
+seed templates versions python-project --set-current v2
+
+ +
+

seed templates lock <name>

+

Lock or unlock a template to prevent/allow updates.

+
+ --version, -v VERSION Version to set as current before locking
+ --unlock Unlock instead of lock +
+
+
+ +
# Lock template
+seed templates lock python-project
+
+# Lock at specific version
+seed templates lock python-project --version v1
+
+# Unlock template
+seed templates lock python-project --unlock
+
+ +
+

seed templates update <name>

+

Re-fetch content from a template's content source. Use --all to update every template that has a content URL. Use --content-url to change where content is fetched from.

+
+ --all Update all templates with content sources
+ --content-url URL Set a new content source (local path or GitHub tree URL) +
+
+
+ +
# Re-fetch content for a template
+seed templates update fastapi
+
+# Update all templates
+seed templates update --all
+
+# Change content source
+seed templates update myapp --content-url https://github.com/owner/repo/tree/develop/src
+
+# Switch from GitHub to local directory
+seed templates update myapp --content-url /path/to/local/files
+
+ +
+

seed templates remove <name>

+

Remove a template from the registry.

+
+
+ +
seed templates remove python-project
+
+ +
+ +

Content Sources (source.json)

+

Templates can include a source.json file that tells seed where to fetch real file contents from. When a template with a source.json is installed, seed automatically downloads the files.

+
+ +
{
+  "content_url": "https://github.com/owner/repo/tree/main/src"
+}
+
+

Content sources support both local directory paths and GitHub tree URLs. The built-in fastapi, python-package, and node-typescript templates ship with source.json and auto-fetch content on first use.

+ +

Changing a Template's Content Source

+

Use seed templates update with --content-url to point a template at a different source. This updates both meta.json and source.json in ~/.seed/templates/<name>/.

+
+ +
# Point to a different repo/branch
+seed templates update myapp \
+  --content-url https://github.com/owner/repo/tree/develop/src
+
+# Switch to a local directory
+seed templates update myapp --content-url /path/to/local/files
+
+
+ + +
+

Versioning & Locking

+

Lock filesystem structures to a spec. Supports versioning and watch mode to prevent drift.

+ +
+

seed lock set <spec>

+

Set the active structure spec. Creates a version if needed.

+
+ --base BASE Base directory
+ --version, -v VERSION Version name (default: auto-increment) +
+
+
+ +
# Set lock with auto-versioning
+seed lock set project.tree
+
+# Set lock with specific version name
+seed lock set project.tree --version v1.0
+
+# Set lock for specific directory
+seed lock set project.tree --base ./myproject
+
+ +
+

seed lock watch

+

Watch filesystem and enforce structure continuously. Runs as a daemon to detect and report drift.

+
+ --base BASE Base directory
+ --interval INTERVAL Check interval in seconds +
+
+
+ +
# Start watching with default interval
+seed lock watch
+
+# Watch with custom interval (every 5 seconds)
+seed lock watch --interval 5
+
+# Watch specific directory
+seed lock watch --base ./myproject
+
+ +
+

seed lock list

+

List available structure versions.

+
+ --base BASE Base directory +
+
+
+ +
seed lock list
+
+ +
+

seed lock status

+

Show current structure lock status including active version and any drift.

+
+ --base BASE Base directory +
+
+
+ +
seed lock status
+
+ +
+

seed lock upgrade <version> dangerous

+

Upgrade to a newer structure version. Applies changes to bring filesystem to the new version.

+
+ --base BASE Base directory
+ --dangerous Apply changes (required)
+ --dry-run Preview changes +
+
+
+ +
# Preview upgrade
+seed lock upgrade v2 --dry-run
+
+# Apply upgrade
+seed lock upgrade v2 --dangerous
+
+ +
+

seed lock downgrade <version> dangerous

+

Downgrade to an older structure version.

+
+ --base BASE Base directory
+ --dangerous Apply changes (required)
+ --dry-run Preview changes +
+
+
+ +
# Preview downgrade
+seed lock downgrade v1 --dry-run
+
+# Apply downgrade
+seed lock downgrade v1 --dangerous
+
+
+ + +
+

Snapshots & Specs History

+

Snapshots are created automatically before apply/match/sync operations. Use them to undo changes.

+ +

Revert Command

+ +
+

seed revert [snapshot_id]

+

Revert filesystem to a previous snapshot. Snapshots are created automatically before apply/match/sync operations.

+
+ --base BASE Base directory (default: current directory)
+ --list List available snapshots
+ --dry-run Preview what would be reverted
+ --delete ID Delete a specific snapshot +
+
+
+ +
# List all snapshots
+seed revert --list
+
+# Revert to latest snapshot
+seed revert
+
+# Revert to specific snapshot
+seed revert abc123
+
+# Preview revert
+seed revert abc123 --dry-run
+
+# Delete a snapshot
+seed revert --delete abc123
+
+ +
+ +

Specs History

+

View and manage automatically captured spec versions.

+ +
+

seed specs list

+

List all captured spec versions with timestamps and metadata.

+
+ --base BASE Base directory +
+
+
+ +
seed specs list
+
+ +
+

seed specs show [version]

+

Show content of a spec version.

+
+ --base BASE Base directory
+ version Version to show (e.g., 1 or v1). Default: latest +
+
+
+ +
# Show latest spec
+seed specs show
+
+# Show specific version
+seed specs show v1
+seed specs show 2
+
+ +
+

seed specs diff <v1> <v2>

+

Compare two spec versions to see what changed.

+
+ --base BASE Base directory +
+
+
+ +
# Compare two versions
+seed specs diff v1 v2
+seed specs diff 1 3
+
+
+ + +
+

Utilities

+ +

Doctor

+
+

seed doctor <spec>

+

Lint spec file and optionally auto-fix issues. Checks for syntax errors, invalid paths, and common mistakes.

+
+ --base BASE Base directory (default: current directory)
+ --fix Automatically fix issues when possible +
+
+
+ +
# Check spec for issues
+seed doctor project.tree
+
+# Auto-fix issues
+seed doctor project.tree --fix
+
+ +
+ +

Export

+
+

seed export <format>

+

Export filesystem state or plan in various formats: tree, json, plan, or dot.

+
+ --input INPUT Input spec or plan file (default: capture from filesystem)
+ --out OUT Output file path (required)
+ --base BASE Base directory (default: current directory) +
+
+
+ +
# Export current filesystem as tree
+seed export tree --out structure.tree
+
+# Export as JSON
+seed export json --out structure.json
+
+# Export as Graphviz DOT
+seed export dot --out structure.dot
+
+# Export existing spec to different format
+seed export json --input project.tree --out project.json
+
+ +
+ +

Hooks

+
+

seed hooks install

+

Install git hooks (e.g., pre-commit) to automatically validate filesystem structure on commits.

+
+ --hook HOOK Hook name to install (default: pre-commit) +
+
+
+ +
# Install default pre-commit hook
+seed hooks install
+
+# Install specific hook
+seed hooks install --hook pre-push
+
+ +
+ +

Utils

+ +
+

seed utils extract-tree <image>

+

Extract tree structure from an image using OCR. Useful for converting screenshots of directory structures into spec files.

+
+ --out OUT Output .tree file path (default: image path with .tree extension)
+ --vars VARS Template variables (key=value)
+ --raw Output raw OCR text without cleaning (for debugging) +
+
+
+ +
# Extract from screenshot
+seed utils extract-tree screenshot.png
+
+# Specify output path
+seed utils extract-tree screenshot.png --out project.tree
+
+# Debug OCR output
+seed utils extract-tree screenshot.png --raw
+
+ +
+

seed utils state-lock

+

Manage execution state locks for concurrent access control. Use if a process crashed and left a stale lock.

+
+ --base BASE Base directory
+ --renew Renew existing lock
+ --force-unlock Force unlock (use if process crashed) +
+
+
+ +
# Check lock status
+seed utils state-lock
+
+# Force unlock after crash
+seed utils state-lock --force-unlock
+
+# Renew existing lock
+seed utils state-lock --renew
+
+
+ + +
+ + + + diff --git a/docs/recipes.html b/docs/recipes.html new file mode 100644 index 0000000..94b52b3 --- /dev/null +++ b/docs/recipes.html @@ -0,0 +1,616 @@ + + + + + + Recipes - seed-cli + + + + + + +
+
+ +

Common Templates & Recipes

+
+ +
+
+

Ready-to-use Templates

+

Copy these templates directly or use them as starting points for your projects. Each recipe includes the spec file and usage instructions.

+
+ + +
+
+

Python Package

+
+ Python + Package +
+
+
+

Standard Python package structure with src layout, tests, and modern tooling (pyproject.toml). Includes source.json — content is auto-fetched from GitHub on install. Run seed templates update python-package to refresh.

+
+ + +
+
+
+ +
src/
+├── <package_name>/
+│   ├──__init__.py
+│   ├──main.py
+│   └──utils.py
+├──tests/
+│   ├──__init__.py
+│   └──test_main.py
+├──pyproject.toml
+├──README.md
+├──LICENSE
+└──.gitignore
+
+
+

Variables

+
    +
  • package_name - Python package name
  • +
+
+
+
+
+ +
# Add template from repo
+seed templates add https://github.com/Isaac12x/seed-cli/templates/python-package/spec.tree --name python-package
+
+# Create new project
+seed templates use python-package --vars package_name=myapp
+
+# Or use directly with create
+seed create spec.tree package_name=myapp
+
+
+
+
+ + +
+
+

FastAPI Application

+
+ Python + API +
+
+
+

FastAPI project with routers, models, schemas, and database setup. Includes source.json — content is auto-fetched from GitHub on install. Run seed templates update fastapi to refresh.

+
+ + +
+
+
+ +
.
+├──src/
+│   ├──app/
+│   │   ├──__init__.py
+│   │   ├──main.py
+│   │   ├──api/
+│   │   │  ├──__init__.py
+│   │   │  ├──routes/
+│   │   │  ├──health.py
+│   │   │  └──deps.py
+│   │   ├──core/
+│   │   │  ├──__init__.py
+│   │   │  └──config.py
+│   │   ├──models/
+│   │   │  └──__init__.py
+│   │   ├──schemas/
+│   │   │  └──__init__.py
+│   │   ├──services/
+│   │   │  └──__init__.py
+│   │   ├──db/
+│   │   │  ├──__init__.py
+│   │   │  └──session.py
+├──tests/
+│   ├──__init__.py
+│   ├──conftest.py
+│   └──api/
+│       └──test_health.py
+└──alembic/
+   ├──versions/
+   ├──env.py
+   └──alembic.ini
+
+
+
+
+ +
# Add template from repo
+seed templates add https://github.com/Isaac12x/seed-cli/templates/fastapi/spec.tree --name fastapi
+
+# Create FastAPI project
+seed templates use fastapi
+
+
+
+
+ + +
+
+

Node.js TypeScript

+
+ Node.js + TypeScript +
+
+
+

TypeScript Node.js project with ESLint, Prettier, and testing setup. Includes source.json — content is auto-fetched from GitHub on install. Run seed templates update node-typescript to refresh.

+
+ + +
+
+
+ +
.
+├──src/
+│   ├──index.ts
+│   ├──config/
+│   │   └──index.ts
+│   ├──utils/
+│   │   └──index.ts
+│   └──types/
+│       └──index.ts
+│──tests/
+│   └──index.test.ts
+├──package.json
+├──tsconfig.json
+├──.eslintrc.json
+├──.prettierrc
+├──README.md
+├──LICENSE
+├──.gitignore
+└──.nvmrc
+
+
+
+
+ +
# Add template from repo
+seed templates add https://github.com/Isaac12x/seed-cli/templates/node-typescript/spec.tree --name node-typescript
+
+# Create TypeScript project
+seed templates use node-typescript
+
+
+
+
+ + +
+
+

Ralph

+
+ Scripts + PRD +
+
+
+

Ralph script structure for PRD-driven development workflows.

+
+ + +
+
+
+ +
./
+├── scripts/
+    ├── ralph/
+        ├── ralph-once.sh
+        ├── ralph.sh
+        ├── progress.txt
+        ├── prd.json
+        └── PRD.md
+    ├──...
+├──...
+
+
+
+
+ +
# Add template from repo
+seed templates add https://github.com/Isaac12x/seed-cli/templates/ralph/spec.tree --name ralph
+
+# Add ralph scripts to your project
+seed templates use ralph
+
+
+
+
+ +
+ + +
+ + + + diff --git a/pyproject.toml b/pyproject.toml index 7516b11..2e6b991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "seed-cli" -version = "1.0.4" +version = "1.0.5" description = "The perfect pair for the `tree` program. Seed creates, updates and deletes file and folder structures in your codebase." requires-python = ">=3.10" readme = "README.md" @@ -35,6 +35,7 @@ classifiers = [ dependencies = [ "pyyaml", "argcomplete", + "requests", ] [project.urls] @@ -53,6 +54,7 @@ dev = [ "pre-commit", "hypothesis>=6.103.1", "hypothesis[numpy]>=6.103.1", + "rich", ] @@ -65,6 +67,12 @@ src = "src" [tool.hatch.build.targets.wheel] packages = ["src/seed_cli"] +[tool.hatch.build.targets.wheel.force-include] +"src/seed_cli/resources/default_templates" = "seed_cli/resources/default_templates" + +[tool.hatch.build.targets.sdist.force-include] +"src/seed_cli/resources/default_templates" = "src/seed_cli/resources/default_templates" + [tool.black] line-length = 88 diff --git a/src/seed_cli/__pycache__/__init__.cpython-312.pyc b/src/seed_cli/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a7ee592..0000000 Binary files a/src/seed_cli/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/apply.cpython-312.pyc b/src/seed_cli/__pycache__/apply.cpython-312.pyc deleted file mode 100644 index 25e820f..0000000 Binary files a/src/seed_cli/__pycache__/apply.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/capture.cpython-312.pyc b/src/seed_cli/__pycache__/capture.cpython-312.pyc deleted file mode 100644 index 24363cc..0000000 Binary files a/src/seed_cli/__pycache__/capture.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/checksums.cpython-312.pyc b/src/seed_cli/__pycache__/checksums.cpython-312.pyc deleted file mode 100644 index 40d6108..0000000 Binary files a/src/seed_cli/__pycache__/checksums.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/cli.cpython-312.pyc b/src/seed_cli/__pycache__/cli.cpython-312.pyc deleted file mode 100644 index c5ada6d..0000000 Binary files a/src/seed_cli/__pycache__/cli.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/diff.cpython-312.pyc b/src/seed_cli/__pycache__/diff.cpython-312.pyc deleted file mode 100644 index 1e32826..0000000 Binary files a/src/seed_cli/__pycache__/diff.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/doctor.cpython-312.pyc b/src/seed_cli/__pycache__/doctor.cpython-312.pyc deleted file mode 100644 index 8d4d664..0000000 Binary files a/src/seed_cli/__pycache__/doctor.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/executor.cpython-312.pyc b/src/seed_cli/__pycache__/executor.cpython-312.pyc deleted file mode 100644 index 2137e6a..0000000 Binary files a/src/seed_cli/__pycache__/executor.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/exporter.cpython-312.pyc b/src/seed_cli/__pycache__/exporter.cpython-312.pyc deleted file mode 100644 index 37ebdca..0000000 Binary files a/src/seed_cli/__pycache__/exporter.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/graphviz.cpython-312.pyc b/src/seed_cli/__pycache__/graphviz.cpython-312.pyc deleted file mode 100644 index 470d7a5..0000000 Binary files a/src/seed_cli/__pycache__/graphviz.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/hooks.cpython-312.pyc b/src/seed_cli/__pycache__/hooks.cpython-312.pyc deleted file mode 100644 index 27af0e4..0000000 Binary files a/src/seed_cli/__pycache__/hooks.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/image.cpython-312.pyc b/src/seed_cli/__pycache__/image.cpython-312.pyc deleted file mode 100644 index f63ead7..0000000 Binary files a/src/seed_cli/__pycache__/image.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/includes.cpython-312.pyc b/src/seed_cli/__pycache__/includes.cpython-312.pyc deleted file mode 100644 index d1d2763..0000000 Binary files a/src/seed_cli/__pycache__/includes.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/lock_heartbeat.cpython-312.pyc b/src/seed_cli/__pycache__/lock_heartbeat.cpython-312.pyc deleted file mode 100644 index cdca970..0000000 Binary files a/src/seed_cli/__pycache__/lock_heartbeat.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/logging.cpython-312.pyc b/src/seed_cli/__pycache__/logging.cpython-312.pyc deleted file mode 100644 index 2e18159..0000000 Binary files a/src/seed_cli/__pycache__/logging.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/match.cpython-312.pyc b/src/seed_cli/__pycache__/match.cpython-312.pyc deleted file mode 100644 index ce79f01..0000000 Binary files a/src/seed_cli/__pycache__/match.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/parsers.cpython-312.pyc b/src/seed_cli/__pycache__/parsers.cpython-312.pyc deleted file mode 100644 index cb25ffd..0000000 Binary files a/src/seed_cli/__pycache__/parsers.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/planning.cpython-312.pyc b/src/seed_cli/__pycache__/planning.cpython-312.pyc deleted file mode 100644 index aff5436..0000000 Binary files a/src/seed_cli/__pycache__/planning.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/schema.cpython-312.pyc b/src/seed_cli/__pycache__/schema.cpython-312.pyc deleted file mode 100644 index 032cca4..0000000 Binary files a/src/seed_cli/__pycache__/schema.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/snapshot.cpython-312.pyc b/src/seed_cli/__pycache__/snapshot.cpython-312.pyc deleted file mode 100644 index 725c39c..0000000 Binary files a/src/seed_cli/__pycache__/snapshot.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/structure_lock.cpython-312.pyc b/src/seed_cli/__pycache__/structure_lock.cpython-312.pyc deleted file mode 100644 index 7484545..0000000 Binary files a/src/seed_cli/__pycache__/structure_lock.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/sync.cpython-312.pyc b/src/seed_cli/__pycache__/sync.cpython-312.pyc deleted file mode 100644 index f46885d..0000000 Binary files a/src/seed_cli/__pycache__/sync.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/templates.cpython-312.pyc b/src/seed_cli/__pycache__/templates.cpython-312.pyc deleted file mode 100644 index 94c8c76..0000000 Binary files a/src/seed_cli/__pycache__/templates.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/templating.cpython-312.pyc b/src/seed_cli/__pycache__/templating.cpython-312.pyc deleted file mode 100644 index 1d9694b..0000000 Binary files a/src/seed_cli/__pycache__/templating.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/ui.cpython-312.pyc b/src/seed_cli/__pycache__/ui.cpython-312.pyc deleted file mode 100644 index 8fc06f6..0000000 Binary files a/src/seed_cli/__pycache__/ui.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/__pycache__/utils.cpython-312.pyc b/src/seed_cli/__pycache__/utils.cpython-312.pyc deleted file mode 100644 index b12bdb2..0000000 Binary files a/src/seed_cli/__pycache__/utils.cpython-312.pyc and /dev/null differ diff --git a/src/seed_cli/apply.py b/src/seed_cli/apply.py index fa9b278..e403e81 100644 --- a/src/seed_cli/apply.py +++ b/src/seed_cli/apply.py @@ -138,6 +138,7 @@ def apply( interactive=interactive, skip_optional=skip_optional, include_optional=include_optional, + vars=vars, ) finally: if heartbeat: diff --git a/src/seed_cli/cli.py b/src/seed_cli/cli.py index 8f256fa..c00c290 100644 --- a/src/seed_cli/cli.py +++ b/src/seed_cli/cli.py @@ -360,6 +360,96 @@ def build_parser() -> argparse.ArgumentParser: specs_diff.add_argument("v2", help="Second version (e.g., 2 or v2)") specs_diff.add_argument("--base", default=".", help="Base directory") + # templates - manage reusable specs from GitHub + stpl = sub.add_parser( + "templates", + description="Manage reusable spec templates from GitHub", + help="Manage spec templates", + ) + templates_sub = stpl.add_subparsers(dest="templates_action", help="Templates action") + + # templates list + tpl_list = templates_sub.add_parser( + "list", + description="List all stored templates", + help="List templates", + ) + + # templates add + tpl_add = templates_sub.add_parser( + "add", + description="Add a template from GitHub URL or local file", + help="Add a template", + ) + tpl_add.add_argument("source", help="GitHub URL or local file path") + tpl_add.add_argument("--name", "-n", help="Name for the template (default: derived from URL/filename)") + tpl_add.add_argument("--version", "-v", help="Version name (default: auto-increment)") + tpl_add.add_argument("--content-url", dest="content_url", + help="URL or local path to fetch file contents from (saved in source.json)") + + # templates remove + tpl_remove = templates_sub.add_parser( + "remove", + description="Remove a template", + help="Remove a template", + ) + tpl_remove.add_argument("name", help="Template name to remove") + + # templates use + tpl_use = templates_sub.add_parser( + "use", + description="Apply a template to the filesystem", + help="Use a template", + ) + tpl_use.add_argument("name", help="Template name to use") + tpl_use.add_argument("--version", "-v", help="Version to use (default: current)") + tpl_use.add_argument("--base", default=".", help="Base directory (default: current directory)") + tpl_use.add_argument("--vars", action="append", help="Template variables (key=value)") + tpl_use.add_argument("--yes", "-y", action="store_true", help="Apply without prompting") + tpl_use.add_argument("--dry-run", action="store_true", help="Show plan without applying") + + # templates show + tpl_show = templates_sub.add_parser( + "show", + description="Show template content", + help="Show template content", + ) + tpl_show.add_argument("name", help="Template name") + tpl_show.add_argument("--version", "-v", help="Version to show (default: current)") + + # templates lock + tpl_lock = templates_sub.add_parser( + "lock", + description="Lock or unlock a template", + help="Lock/unlock template", + ) + tpl_lock.add_argument("name", help="Template name") + tpl_lock.add_argument("--version", "-v", help="Version to set as current before locking") + tpl_lock.add_argument("--unlock", action="store_true", help="Unlock instead of lock") + + # templates update + tpl_update = templates_sub.add_parser( + "update", + description="Re-fetch content from a template's source URL", + help="Update template content from source", + ) + tpl_update.add_argument("name", nargs="?", help="Template name to update") + tpl_update.add_argument("--all", action="store_true", dest="update_all", + help="Update all templates that have a content_url") + tpl_update.add_argument("--content-url", dest="content_url", + help="Set a new content URL (local path or GitHub tree URL)") + + # templates versions + tpl_versions = templates_sub.add_parser( + "versions", + description="Manage template versions", + help="Manage versions", + ) + tpl_versions.add_argument("name", help="Template name") + tpl_versions.add_argument("--add", metavar="PATH", help="Add a new version from file") + tpl_versions.add_argument("--name", dest="version_name", metavar="VERSION", help="Name for the new version (with --add)") + tpl_versions.add_argument("--set-current", metavar="VERSION", help="Set version as current") + # utils sut = sub.add_parser( "utils", @@ -912,6 +1002,328 @@ def main(argv=None) -> int: print(f"Unknown lock action: {action}") return 1 + # ---------------- TEMPLATES ---------------- + if args.cmd == "templates": + from seed_cli.template_registry import ( + list_templates, + get_template, + get_template_spec_path, + get_template_content_dir, + add_template, + add_local_template, + remove_template, + update_template, + list_versions as list_template_versions, + add_version, + set_current_version, + lock_template, + unlock_template, + parse_github_url, + ) + import time as time_module + from datetime import datetime + + action = getattr(args, "templates_action", None) + + # Default: list if no action specified + if not action or action == "list": + templates = list_templates() + if not templates: + print("No templates stored.") + print("\nUse 'seed templates add ' to add a template.") + return 0 + + print("Stored templates:\n") + for tmpl in templates: + locked_str = " [LOCKED]" if tmpl.locked else "" + print(f" {tmpl.name}{locked_str}") + print(f" Version: {tmpl.current_version} ({len(tmpl.versions)} total)") + print(f" Source: {tmpl.source}") + created = datetime.fromtimestamp(tmpl.created_at).strftime("%Y-%m-%d %H:%M") + print(f" Created: {created}") + print() + return 0 + + if action == "add": + source = args.source + name = getattr(args, "name", None) + version = getattr(args, "version", None) + add_content_url = getattr(args, "content_url", None) + + try: + # Check if it's a GitHub URL or local file/directory + if parse_github_url(source): + meta = add_template(source, name=name, version=version, content_url=add_content_url) + print(f"Added template: {meta.name}") + print(f" Version: {meta.current_version}") + print(f" Source: {meta.source}") + else: + # Local file or directory + source_path = Path(source) + if not name: + name = source_path.stem if source_path.is_file() else source_path.name + meta = add_local_template(source, name, version=version, content_url=add_content_url) + print(f"Added template: {meta.name}") + print(f" Version: {meta.current_version}") + print(f" Source: {meta.source}") + # Report if content files were included + from seed_cli.template_registry import get_template_content_dir + if get_template_content_dir(meta.name, meta.current_version): + print(f" Content: files/ directory included") + if meta.content_url: + print(f" Content URL: {meta.content_url}") + return 0 + except ValueError as e: + print(f"Error: {e}") + return 1 + except FileNotFoundError as e: + print(f"Error: {e}") + return 1 + except RuntimeError as e: + print(f"Error: {e}") + return 1 + except Exception as e: + log.error(f"Error adding template: {e}") + if args.debug: + import traceback + traceback.print_exc() + return 1 + + if action == "remove": + name = args.name + try: + if remove_template(name): + print(f"Removed template: {name}") + else: + print(f"Template not found: {name}") + return 1 + return 0 + except ValueError as e: + print(f"Error: {e}") + return 1 + + if action == "update": + update_all = getattr(args, "update_all", False) + tpl_name = getattr(args, "name", None) + update_content_url = getattr(args, "content_url", None) + + if not update_all and not tpl_name: + print("Error: provide a template name or use --all") + return 1 + + if update_all: + if update_content_url: + print("Error: --content-url cannot be used with --all") + return 1 + templates = list_templates() + updated = 0 + for tmpl in templates: + if tmpl.content_url: + try: + update_template(tmpl.name) + print(f"Updated: {tmpl.name}") + updated += 1 + except Exception as e: + print(f"Failed to update {tmpl.name}: {e}") + if updated == 0: + print("No templates with content_url found to update.") + else: + print(f"\nUpdated {updated} template(s)") + return 0 + + try: + meta = update_template(tpl_name, content_url=update_content_url) + print(f"Updated template: {meta.name}") + print(f" Content URL: {meta.content_url}") + return 0 + except ValueError as e: + print(f"Error: {e}") + return 1 + except RuntimeError as e: + print(f"Error: {e}") + return 1 + + if action == "use": + name = args.name + version = getattr(args, "version", None) + use_base = Path(getattr(args, "base", ".")).resolve() + use_vars = parse_vars(getattr(args, "vars", [])) + yes = getattr(args, "yes", False) + dry_run = getattr(args, "dry_run", False) + + # Get template spec path + spec_path = get_template_spec_path(name, version) + if not spec_path: + print(f"Template not found: {name}") + if version: + print(f"Version: {version}") + return 1 + + meta = get_template(name) + used_version = version or meta.current_version + print(f"Using template: {name} ({used_version})") + print(f"Base directory: {use_base}") + print() + + try: + # Parse spec and build plan + _, nodes = parse_spec_file(str(spec_path), use_vars, use_base, plugins, context) + + for p in plugins: + p.after_parse(nodes, context) + + for p in plugins: + p.before_plan(nodes, context) + + plan = build_plan( + nodes, + use_base, + ignore=args.ignore, + allow_delete=False, + targets=args.targets, + target_mode=args.target_mode, + ) + + for p in plugins: + p.after_plan(plan, context) + + # Show plan + print(plan.to_text()) + + if dry_run: + print("\nDRY RUN - No changes applied") + return 0 + + # Prompt for confirmation unless --yes + if not yes: + response = input("\nApply? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + print("Aborted.") + return 0 + + # Resolve content directory for template files + content_dir = get_template_content_dir(name, version) + + # Apply the template + result = apply( + str(spec_path), + use_base, + plugins=plugins, + dry_run=False, + vars=use_vars, + template_dir=content_dir, + ignore=args.ignore, + targets=args.targets, + target_mode=args.target_mode, + ) + + # Extract extra fields before creating Summary + snapshot_id = result.pop("snapshot_id", None) + spec_version = result.pop("spec_version", None) + result_spec_path = result.pop("spec_path", None) + summary = Summary(**result) + print(render_summary(summary)) + if spec_version: + print(f"\nSpec captured: v{spec_version} ({result_spec_path})") + if snapshot_id: + print(f"Snapshot created: {snapshot_id}") + print("Use 'seed revert' to undo changes") + return 0 + except Exception as e: + log.error(f"Error using template: {e}") + if args.debug: + import traceback + traceback.print_exc() + return 1 + + if action == "show": + name = args.name + version = getattr(args, "version", None) + + spec_path = get_template_spec_path(name, version) + if not spec_path: + print(f"Template not found: {name}") + if version: + print(f"Version: {version}") + return 1 + + meta = get_template(name) + used_version = version or meta.current_version + print(f"# Template: {name} ({used_version})") + print(f"# Source: {meta.source}") + locked_str = " [LOCKED]" if meta.locked else "" + print(f"# Status:{locked_str}") + print() + print(spec_path.read_text()) + return 0 + + if action == "lock": + name = args.name + version = getattr(args, "version", None) + do_unlock = getattr(args, "unlock", False) + + try: + if do_unlock: + unlock_template(name) + print(f"Unlocked template: {name}") + else: + lock_template(name, version) + msg = f"Locked template: {name}" + if version: + msg += f" at version {version}" + print(msg) + return 0 + except ValueError as e: + print(f"Error: {e}") + return 1 + + if action == "versions": + name = args.name + add_path = getattr(args, "add", None) + version_name = getattr(args, "version_name", None) + set_current = getattr(args, "set_current", None) + + meta = get_template(name) + if not meta: + print(f"Template not found: {name}") + return 1 + + # Add a new version + if add_path: + try: + new_version = add_version(name, add_path, version_name) + print(f"Added version: {new_version}") + return 0 + except (ValueError, FileNotFoundError) as e: + print(f"Error: {e}") + return 1 + + # Set current version + if set_current: + try: + set_current_version(name, set_current) + print(f"Set current version: {set_current}") + return 0 + except ValueError as e: + print(f"Error: {e}") + return 1 + + # List versions + versions = list_template_versions(name) + if not versions: + print(f"No versions found for template: {name}") + return 1 + + print(f"Versions for template '{name}':\n") + for v, path in versions: + current = " (current)" if v == meta.current_version else "" + print(f" {v}{current}") + print(f"\nTotal: {len(versions)} versions") + return 0 + + print(f"Unknown templates action: {action}") + return 1 + # ---------------- HOOKS (git) ---------------- if args.cmd == "hooks": if args.action == "install": diff --git a/src/seed_cli/executor.py b/src/seed_cli/executor.py index a766ca9..2a0642d 100644 --- a/src/seed_cli/executor.py +++ b/src/seed_cli/executor.py @@ -112,6 +112,7 @@ def execute_plan( interactive: bool = True, skip_optional: bool = False, include_optional: bool = False, + vars: Optional[Dict[str, str]] = None, ) -> Dict[str, int]: """Execute a plan against the filesystem. @@ -157,6 +158,17 @@ def execute_plan( counters["backed_up"] += 1 if force or not target.exists(): shutil.copy2(item, target) + # Apply variable substitution to text files + if vars and target.exists(): + try: + content = target.read_bytes() + text = content.decode("utf-8") + from .templating import apply_vars + replaced = apply_vars(text, vars, mode="loose") + if replaced != text: + target.write_text(replaced) + except (UnicodeDecodeError, ValueError): + pass # Skip binary files checks = load_checksums(base) diff --git a/src/seed_cli/resources/default_templates/fastapi/source.json b/src/seed_cli/resources/default_templates/fastapi/source.json new file mode 100644 index 0000000..871a8d8 --- /dev/null +++ b/src/seed_cli/resources/default_templates/fastapi/source.json @@ -0,0 +1,3 @@ +{ + "content_url": "https://github.com/tiangolo/full-stack-fastapi-template/tree/master/backend/app" +} diff --git a/src/seed_cli/resources/default_templates/node-typescript/source.json b/src/seed_cli/resources/default_templates/node-typescript/source.json new file mode 100644 index 0000000..c230c91 --- /dev/null +++ b/src/seed_cli/resources/default_templates/node-typescript/source.json @@ -0,0 +1,3 @@ +{ + "content_url": "https://github.com/microsoft/TypeScript-Node-Starter/tree/master/src" +} diff --git a/src/seed_cli/resources/default_templates/python-package/source.json b/src/seed_cli/resources/default_templates/python-package/source.json new file mode 100644 index 0000000..da12964 --- /dev/null +++ b/src/seed_cli/resources/default_templates/python-package/source.json @@ -0,0 +1,3 @@ +{ + "content_url": "https://github.com/pypa/sampleproject/tree/main/src" +} diff --git a/src/seed_cli/template_registry.py b/src/seed_cli/template_registry.py new file mode 100644 index 0000000..c3eb8ba --- /dev/null +++ b/src/seed_cli/template_registry.py @@ -0,0 +1,1138 @@ +"""seed_cli.template_registry + +Template management - store and manage reusable specs from GitHub. + +Features: +- Fetch specs from GitHub URLs +- Version management per template +- Template locking +- Local storage in ~/.seed/templates/ + +Structure: + ~/.seed/ + └── templates/ + ├── registry.json # {name: TemplateMetadata} + └── / + ├── meta.json # name, source, current_version, locked, versions[] + ├── v1.tree + └── v2.tree + +Usage: + seed templates add https://github.com/user/repo/blob/main/spec.tree --name myspec + seed templates list + seed templates use myspec + seed templates lock myspec + seed templates versions myspec --add newspec.tree --name v2 +""" + +import importlib.resources +import json +import os +import re +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse + +from .logging import get_logger + +log = get_logger("template_registry") + +TEMPLATES_DIR_NAME = "templates" +REGISTRY_FILE = "registry.json" +META_FILE = "meta.json" + + +@dataclass +class TemplateMetadata: + """Metadata for a stored template.""" + name: str + source: str # GitHub URL + current_version: str # "v1", "main", etc. + locked: bool = False + created_at: float = field(default_factory=time.time) + versions: List[str] = field(default_factory=list) + content_url: Optional[str] = None # GitHub tree URL for fetching content + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict) -> "TemplateMetadata": + # Handle older meta.json files that don't have content_url + known_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered = {k: v for k, v in data.items() if k in known_fields} + return cls(**filtered) + + +def get_templates_dir() -> Path: + """Get the templates directory (~/.seed/templates/). + + Creates the directory if it doesn't exist. + """ + seed_dir = Path.home() / ".seed" + templates_dir = seed_dir / TEMPLATES_DIR_NAME + templates_dir.mkdir(parents=True, exist_ok=True) + return templates_dir + + +def _get_registry_path() -> Path: + """Get path to the registry.json file.""" + return get_templates_dir() / REGISTRY_FILE + + +_defaults_installed = False + + +def install_default_templates() -> None: + """Install bundled default templates into ~/.seed/templates/ if not already present. + + Templates are shipped inside the package under resources/default_templates/. + Each subdirectory contains a spec.tree (and optionally a files/ dir). + Only installs templates that don't already exist in the registry. + """ + global _defaults_installed + if _defaults_installed: + return + _defaults_installed = True + + registry = load_registry() + + # Locate bundled templates via importlib.resources + try: + resources_pkg = importlib.resources.files("seed_cli") / "resources" / "default_templates" + except (TypeError, ModuleNotFoundError): + return + + # Iterate over bundled template directories + try: + entries = list(resources_pkg.iterdir()) + except (FileNotFoundError, OSError): + return + + for entry in entries: + if not entry.is_dir(): + continue + tpl_name = entry.name + + # Skip if already registered + if tpl_name in registry: + continue + + spec_resource = entry / "spec.tree" + if not spec_resource.is_file(): + continue + + # Read spec content from package resources + try: + spec_content = spec_resource.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + + # Install into ~/.seed/templates// + template_dir = _get_template_dir(tpl_name) + template_dir.mkdir(parents=True, exist_ok=True) + + version = "v1" + dest_path = template_dir / f"{version}.tree" + dest_path.write_text(spec_content) + + # Copy files/ content dir if present + content_url = None + files_resource = entry / "files" + if files_resource.is_dir(): + content_dest = template_dir / version + if content_dest.exists(): + shutil.rmtree(content_dest) + # Use shutil.copytree for Path-like resources (works on filesystem paths) + try: + shutil.copytree(str(files_resource), str(content_dest)) + except (OSError, shutil.Error): + pass + + # Check for source.json and fetch content from GitHub + source_json_resource = entry / "source.json" + if source_json_resource.is_file(): + try: + source_data = json.loads(source_json_resource.read_text(encoding="utf-8")) + content_url = source_data.get("content_url") + if content_url: + content_dest = template_dir / version + try: + fetch_dir_from_github(content_url, content_dest) + log.info(f"Fetched content for '{tpl_name}' from {content_url}") + except Exception as e: + log.warning( + f"Failed to fetch content for '{tpl_name}' from {content_url}: {e}. " + "Installing structure-only." + ) + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: + log.warning(f"Error reading source.json for {tpl_name}: {e}") + + meta = TemplateMetadata( + name=tpl_name, + source="builtin", + current_version=version, + locked=False, + versions=[version], + content_url=content_url, + ) + _save_meta(meta) + registry[tpl_name] = meta + + save_registry(registry) + + +def load_registry() -> Dict[str, TemplateMetadata]: + """Load the template registry from disk. + + Returns: + Dict mapping template names to TemplateMetadata + """ + registry_path = _get_registry_path() + if not registry_path.exists(): + return {} + + try: + data = json.loads(registry_path.read_text()) + return { + name: TemplateMetadata.from_dict(meta) + for name, meta in data.items() + } + except (json.JSONDecodeError, KeyError) as e: + log.warning(f"Error loading registry: {e}") + return {} + + +def save_registry(registry: Dict[str, TemplateMetadata]) -> None: + """Save the template registry to disk.""" + registry_path = _get_registry_path() + registry_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + name: meta.to_dict() + for name, meta in registry.items() + } + registry_path.write_text(json.dumps(data, indent=2)) + + +def _get_template_dir(name: str) -> Path: + """Get the directory for a specific template.""" + return get_templates_dir() / name + + +def _load_meta(name: str) -> Optional[TemplateMetadata]: + """Load metadata for a template from its meta.json file.""" + meta_path = _get_template_dir(name) / META_FILE + if not meta_path.exists(): + return None + + try: + data = json.loads(meta_path.read_text()) + return TemplateMetadata.from_dict(data) + except (json.JSONDecodeError, KeyError) as e: + log.warning(f"Error loading meta for {name}: {e}") + return None + + +def _save_meta(meta: TemplateMetadata) -> None: + """Save metadata for a template to its meta.json file.""" + template_dir = _get_template_dir(meta.name) + template_dir.mkdir(parents=True, exist_ok=True) + + meta_path = template_dir / META_FILE + meta_path.write_text(json.dumps(meta.to_dict(), indent=2)) + + +def parse_github_url(url: str) -> Optional[Dict[str, str]]: + """Parse a GitHub URL to extract owner, repo, ref, and path. + + Supports formats: + - https://github.com/owner/repo/blob/ref/path/to/file.tree + - https://github.com/owner/repo/tree/ref/path/to/dir + - github.com/owner/repo/blob/ref/path/to/file.tree + + Returns: + Dict with keys: owner, repo, ref, path, type (blob/tree) + None if URL doesn't match expected format + """ + # Normalize URL + if not url.startswith(("http://", "https://")): + url = "https://" + url + + parsed = urlparse(url) + if parsed.netloc not in ("github.com", "www.github.com"): + return None + + # Match /owner/repo/blob|tree/ref/path... + pattern = r'^/([^/]+)/([^/]+)/(blob|tree)/([^/]+)(?:/(.*))?$' + match = re.match(pattern, parsed.path) + + if not match: + return None + + owner, repo, url_type, ref, path = match.groups() + return { + "owner": owner, + "repo": repo, + "ref": ref, + "path": path or "", + "type": url_type, # "blob" for file, "tree" for directory + } + + +def _has_gh_cli() -> bool: + """Check if gh CLI is available.""" + try: + result = subprocess.run( + ["gh", "--version"], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _fetch_with_gh(owner: str, repo: str, ref: str, path: str) -> str: + """Fetch file content using gh CLI (handles auth).""" + # Use gh api to fetch raw content + api_path = f"repos/{owner}/{repo}/contents/{path}?ref={ref}" + + result = subprocess.run( + ["gh", "api", api_path, "-q", ".content"], + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + raise RuntimeError(f"gh api failed: {result.stderr}") + + # Content is base64 encoded + import base64 + content = base64.b64decode(result.stdout.strip()).decode("utf-8") + return content + + +def _fetch_with_git(owner: str, repo: str, ref: str, path: str, dest_dir: Path) -> Path: + """Fetch file using git sparse-checkout (no auth needed for public repos).""" + repo_url = f"https://github.com/{owner}/{repo}.git" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + # Initialize sparse checkout + subprocess.run( + ["git", "clone", "--filter=blob:none", "--no-checkout", "--depth=1", + "--branch", ref, repo_url, str(tmpdir / "repo")], + capture_output=True, + timeout=60, + check=True, + ) + + repo_dir = tmpdir / "repo" + + # Set up sparse checkout + subprocess.run( + ["git", "sparse-checkout", "init", "--cone"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + # Add the specific path + parent_dir = str(Path(path).parent) if "/" in path else "." + subprocess.run( + ["git", "sparse-checkout", "set", parent_dir], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + # Checkout + subprocess.run( + ["git", "checkout"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + # Copy the file to destination + src_file = repo_dir / path + if not src_file.exists(): + raise FileNotFoundError(f"File not found in repo: {path}") + + dest_file = dest_dir / src_file.name + shutil.copy2(src_file, dest_file) + return dest_file + + +def fetch_from_github(url: str, dest_dir: Path, name: Optional[str] = None) -> Tuple[Path, str]: + """Fetch a spec file from GitHub. + + Args: + url: GitHub URL to the spec file + dest_dir: Directory to store the fetched file + name: Optional name override for the file + + Returns: + Tuple of (local_path, original_filename) + + Raises: + ValueError: If URL is invalid + RuntimeError: If fetching fails + """ + parsed = parse_github_url(url) + if not parsed: + raise ValueError(f"Invalid GitHub URL: {url}") + + if parsed["type"] != "blob": + raise ValueError("URL must point to a file (blob), not a directory (tree)") + + owner = parsed["owner"] + repo = parsed["repo"] + ref = parsed["ref"] + path = parsed["path"] + + if not path: + raise ValueError("URL must include a path to a file") + + original_name = Path(path).name + dest_dir.mkdir(parents=True, exist_ok=True) + + # Try gh CLI first (handles auth for private repos) + if _has_gh_cli(): + try: + content = _fetch_with_gh(owner, repo, ref, path) + dest_name = name or original_name + dest_file = dest_dir / dest_name + dest_file.write_text(content) + return dest_file, original_name + except Exception as e: + log.debug(f"gh CLI failed, falling back to git: {e}") + + # Fallback to git sparse-checkout + try: + fetched = _fetch_with_git(owner, repo, ref, path, dest_dir) + if name and name != fetched.name: + new_path = dest_dir / name + fetched.rename(new_path) + return new_path, original_name + return fetched, original_name + except Exception as e: + raise RuntimeError(f"Failed to fetch from GitHub: {e}") + + +def _fetch_dir_with_git(owner: str, repo: str, ref: str, path: str, dest_dir: Path) -> Path: + """Fetch a directory from GitHub using git sparse-checkout. + + Args: + owner: Repository owner + repo: Repository name + ref: Git ref (branch, tag, commit) + path: Path to directory within the repo + dest_dir: Local destination directory + + Returns: + Path to the fetched directory contents (dest_dir) + """ + repo_url = f"https://github.com/{owner}/{repo}.git" + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + subprocess.run( + ["git", "clone", "--filter=blob:none", "--no-checkout", "--depth=1", + "--branch", ref, repo_url, str(tmpdir / "repo")], + capture_output=True, + timeout=120, + check=True, + ) + + repo_dir = tmpdir / "repo" + + subprocess.run( + ["git", "sparse-checkout", "init", "--cone"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + subprocess.run( + ["git", "sparse-checkout", "set", path], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + subprocess.run( + ["git", "checkout"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + src_dir = repo_dir / path + if not src_dir.exists() or not src_dir.is_dir(): + raise FileNotFoundError(f"Directory not found in repo: {path}") + + dest_dir.mkdir(parents=True, exist_ok=True) + # Copy contents of the fetched directory into dest_dir + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.copytree(str(src_dir), str(dest_dir)) + return dest_dir + + +def fetch_dir_from_github(url: str, dest_dir: Path) -> Path: + """Fetch a directory from a GitHub tree URL. + + Args: + url: GitHub tree URL (e.g., https://github.com/owner/repo/tree/ref/path) + dest_dir: Local destination directory + + Returns: + Path to the local copy + + Raises: + ValueError: If URL is invalid or not a tree URL + RuntimeError: If fetching fails + """ + parsed = parse_github_url(url) + if not parsed: + raise ValueError(f"Invalid GitHub URL: {url}") + + if parsed["type"] != "tree": + raise ValueError("URL must point to a directory (tree), not a file (blob)") + + owner = parsed["owner"] + repo = parsed["repo"] + ref = parsed["ref"] + path = parsed["path"] + + if not path: + raise ValueError("URL must include a path to a directory") + + try: + return _fetch_dir_with_git(owner, repo, ref, path, dest_dir) + except Exception as e: + raise RuntimeError(f"Failed to fetch directory from GitHub: {e}") + + +def update_template(name: str, content_url: Optional[str] = None) -> TemplateMetadata: + """Re-fetch content from a template's content_url, or set a new one. + + Args: + name: Template name + content_url: Optional new content URL to set (local path or GitHub tree URL). + If provided, replaces the existing content_url. + + Returns: + Updated TemplateMetadata + + Raises: + ValueError: If template not found or has no content_url + RuntimeError: If fetching fails + """ + meta = _load_meta(name) + if not meta: + raise ValueError(f"Template not found: {name}") + + # Use new content_url if provided, otherwise use existing + url = content_url or meta.content_url + if not url: + raise ValueError(f"Template '{name}' has no content_url to update from") + + template_dir = _get_template_dir(name) + content_dest = template_dir / meta.current_version + + fetch_content_to_dir(url, content_dest) + + # If a new content_url was provided, persist it + if content_url: + meta.content_url = content_url + _save_meta(meta) + _save_source_json(name, content_url) + + registry = load_registry() + registry[name] = meta + save_registry(registry) + + return meta + + +def _save_source_json(name: str, content_url: str) -> None: + """Write source.json into a template's directory.""" + template_dir = _get_template_dir(name) + template_dir.mkdir(parents=True, exist_ok=True) + source_json_path = template_dir / "source.json" + source_json_path.write_text(json.dumps({"content_url": content_url}, indent=2)) + + +def fetch_content_to_dir(content_url: str, dest_dir: Path) -> Path: + """Fetch content from a local path or GitHub tree URL into dest_dir. + + Args: + content_url: Local directory path or GitHub tree URL + dest_dir: Destination directory + + Returns: + Path to dest_dir + """ + local_path = Path(content_url) + if local_path.exists() and local_path.is_dir(): + dest_dir.mkdir(parents=True, exist_ok=True) + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.copytree(str(local_path), str(dest_dir)) + return dest_dir + + # Otherwise treat as GitHub URL + return fetch_dir_from_github(content_url, dest_dir) + + +def _get_next_version(template_dir: Path) -> str: + """Get the next version number for a template.""" + if not template_dir.exists(): + return "v1" + + existing = [] + for f in template_dir.iterdir(): + if f.suffix == ".tree" and f.stem.startswith("v"): + match = re.match(r"v(\d+)", f.stem) + if match: + existing.append(int(match.group(1))) + + if not existing: + return "v1" + return f"v{max(existing) + 1}" + + +def add_template( + source: str, + name: Optional[str] = None, + version: Optional[str] = None, + content_url: Optional[str] = None, +) -> TemplateMetadata: + """Add a template from a GitHub URL. + + Args: + source: GitHub URL to the spec file + name: Optional name for the template (defaults to filename without extension) + version: Optional version name (defaults to auto-increment) + content_url: Optional content URL (local path or GitHub tree URL) to fetch content from + + Returns: + TemplateMetadata for the added template + + Raises: + ValueError: If URL is invalid or template already exists + RuntimeError: If fetching fails + """ + parsed = parse_github_url(source) + if not parsed: + raise ValueError(f"Invalid GitHub URL: {source}") + + # Determine template name + if not name: + path = parsed.get("path", "") + if path: + name = Path(path).stem # filename without extension + else: + name = parsed["repo"] + + # Sanitize name + name = re.sub(r'[^\w\-]', '_', name) + + # If URL points to a directory (tree), fetch it and look for spec.tree + source.json + if parsed["type"] == "tree": + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) / "fetched" + fetch_dir_from_github(source, tmpdir_path) + + # Look for spec.tree inside + spec_file = tmpdir_path / "spec.tree" + if not spec_file.exists(): + raise ValueError(f"No spec.tree found in directory at {source}") + + # Delegate to add_local_template with the fetched directory + return add_local_template(str(tmpdir_path), name, version=version, content_url=content_url) + + template_dir = _get_template_dir(name) + + # Check if template exists and is locked + existing_meta = _load_meta(name) + if existing_meta and existing_meta.locked: + raise ValueError(f"Template '{name}' is locked. Unlock it first.") + + # Determine version + if not version: + version = _get_next_version(template_dir) + elif not version.startswith("v"): + version = f"v{version}" + + # Fetch the spec file + version_filename = f"{version}.tree" + fetched_path, _ = fetch_from_github(source, template_dir, version_filename) + + # Fetch content from content_url if provided + if content_url: + content_dest = template_dir / version + try: + fetch_content_to_dir(content_url, content_dest) + log.info(f"Fetched content from {content_url}") + except Exception as e: + log.warning(f"Failed to fetch content from {content_url}: {e}") + log.warning("Installing structure-only (empty files)") + + # Create or update metadata + if existing_meta: + meta = existing_meta + if version not in meta.versions: + meta.versions.append(version) + meta.current_version = version + if content_url: + meta.content_url = content_url + else: + meta = TemplateMetadata( + name=name, + source=source, + current_version=version, + locked=False, + versions=[version], + content_url=content_url, + ) + + _save_meta(meta) + + # Persist source.json + if content_url: + _save_source_json(name, content_url) + + # Update registry + registry = load_registry() + registry[name] = meta + save_registry(registry) + + return meta + + +def add_local_template( + spec_path: str, + name: str, + version: Optional[str] = None, + content_dir: Optional[str] = None, + content_url: Optional[str] = None, +) -> TemplateMetadata: + """Add a template from a local spec file or directory. + + Args: + spec_path: Path to the local spec file (.tree) or directory containing spec.tree + name: Name for the template + version: Optional version name (defaults to auto-increment) + content_dir: Optional path to directory with file contents to copy + content_url: Optional content URL (local path or GitHub tree URL) to fetch content from + + Returns: + TemplateMetadata for the added template + """ + spec_file = Path(spec_path) + + # If spec_path is a directory, look for spec.tree and files/ inside + source_json_data = None + if spec_file.is_dir(): + source_dir = spec_file + spec_file = source_dir / "spec.tree" + if not spec_file.exists(): + raise FileNotFoundError( + f"No spec.tree found in directory: {spec_path}" + ) + # Auto-detect files/ subdirectory as content_dir + files_dir = source_dir / "files" + if content_dir is None and files_dir.exists() and files_dir.is_dir(): + content_dir = str(files_dir) + # Check for source.json + source_json_path = source_dir / "source.json" + if source_json_path.exists(): + try: + source_json_data = json.loads(source_json_path.read_text()) + except (json.JSONDecodeError, OSError) as e: + log.warning(f"Error reading source.json: {e}") + elif not spec_file.exists(): + raise FileNotFoundError(f"Spec file not found: {spec_path}") + + # Sanitize name + name = re.sub(r'[^\w\-]', '_', name) + + template_dir = _get_template_dir(name) + template_dir.mkdir(parents=True, exist_ok=True) + + # Check if template exists and is locked + existing_meta = _load_meta(name) + if existing_meta and existing_meta.locked: + raise ValueError(f"Template '{name}' is locked. Unlock it first.") + + # Determine version + if not version: + version = _get_next_version(template_dir) + elif not version.startswith("v"): + version = f"v{version}" + + # Copy the spec file + dest_path = template_dir / f"{version}.tree" + shutil.copy2(spec_file, dest_path) + + # Copy content directory if provided + if content_dir: + content_src = Path(content_dir) + if content_src.exists() and content_src.is_dir(): + content_dest = template_dir / version + if content_dest.exists(): + shutil.rmtree(content_dest) + shutil.copytree(content_src, content_dest) + + # Resolve content_url: explicit param > source.json > None + if not content_url and source_json_data and not content_dir: + content_url = source_json_data.get("content_url") + + # Fetch content from content_url if present and no local content_dir was copied + if content_url and not content_dir: + content_dest = template_dir / version + try: + fetch_content_to_dir(content_url, content_dest) + log.info(f"Fetched content from {content_url}") + except Exception as e: + log.warning(f"Failed to fetch content from {content_url}: {e}") + log.warning("Installing structure-only (empty files)") + + # Create or update metadata + if existing_meta: + meta = existing_meta + if version not in meta.versions: + meta.versions.append(version) + meta.current_version = version + if content_url: + meta.content_url = content_url + # Preserve original source if it was from GitHub + if not meta.source.startswith("local:"): + pass # Keep the GitHub source + else: + meta.source = f"local:{spec_path}" + else: + meta = TemplateMetadata( + name=name, + source=f"local:{spec_path}", + current_version=version, + locked=False, + versions=[version], + content_url=content_url, + ) + + _save_meta(meta) + + # Persist source.json so `templates update` knows where to fetch + if content_url: + _save_source_json(name, content_url) + + # Update registry + registry = load_registry() + registry[name] = meta + save_registry(registry) + + return meta + + +def remove_template(name: str) -> bool: + """Remove a template. + + Args: + name: Template name + + Returns: + True if removed, False if not found + + Raises: + ValueError: If template is locked + """ + registry = load_registry() + + if name not in registry: + return False + + meta = registry[name] + if meta.locked: + raise ValueError(f"Template '{name}' is locked. Unlock it first.") + + # Remove template directory + template_dir = _get_template_dir(name) + if template_dir.exists(): + shutil.rmtree(template_dir) + + # Remove from registry + del registry[name] + save_registry(registry) + + return True + + +def list_templates() -> List[TemplateMetadata]: + """List all templates. + + Returns: + List of TemplateMetadata for all templates + """ + install_default_templates() + registry = load_registry() + return list(registry.values()) + + +def get_template(name: str) -> Optional[TemplateMetadata]: + """Get metadata for a specific template. + + Args: + name: Template name + + Returns: + TemplateMetadata or None if not found + """ + install_default_templates() + registry = load_registry() + return registry.get(name) + + +def get_template_spec_path(name: str, version: Optional[str] = None) -> Optional[Path]: + """Get the path to a template's spec file. + + Args: + name: Template name + version: Optional version (defaults to current_version) + + Returns: + Path to the .tree file or None if not found + """ + meta = get_template(name) + if not meta: + return None + + if version is None: + version = meta.current_version + elif not version.startswith("v"): + version = f"v{version}" + + spec_path = _get_template_dir(name) / f"{version}.tree" + if spec_path.exists(): + return spec_path + return None + + +def get_template_content_dir(name: str, version: Optional[str] = None) -> Optional[Path]: + """Get the path to a template's content directory. + + Content directories contain actual file contents that mirror the tree structure. + Located at ~/.seed/templates///. + + Args: + name: Template name + version: Optional version (defaults to current_version) + + Returns: + Path to the content directory or None if not found + """ + meta = get_template(name) + if not meta: + return None + + if version is None: + version = meta.current_version + elif not version.startswith("v"): + version = f"v{version}" + + content_dir = _get_template_dir(name) / version + if content_dir.exists() and content_dir.is_dir(): + return content_dir + return None + + +def list_versions(name: str) -> List[Tuple[str, Path]]: + """List all versions of a template. + + Args: + name: Template name + + Returns: + List of (version, path) tuples sorted by version number + """ + meta = get_template(name) + if not meta: + return [] + + template_dir = _get_template_dir(name) + versions = [] + + for version in meta.versions: + path = template_dir / f"{version}.tree" + if path.exists(): + versions.append((version, path)) + + # Sort by version number + def version_key(item): + v = item[0] + match = re.match(r'v(\d+)', v) + if match: + return int(match.group(1)) + return 0 + + return sorted(versions, key=version_key) + + +def add_version( + name: str, + spec_path: str, + version_name: Optional[str] = None, + content_dir: Optional[str] = None, +) -> str: + """Add a new version to an existing template. + + Args: + name: Template name + spec_path: Path to the spec file to add + version_name: Optional version name + content_dir: Optional path to directory with file contents to copy + + Returns: + The version name that was added + + Raises: + ValueError: If template not found or is locked + FileNotFoundError: If spec_path doesn't exist + """ + meta = get_template(name) + if not meta: + raise ValueError(f"Template not found: {name}") + + if meta.locked: + raise ValueError(f"Template '{name}' is locked. Unlock it first.") + + spec_file = Path(spec_path) + if not spec_file.exists(): + raise FileNotFoundError(f"Spec file not found: {spec_path}") + + template_dir = _get_template_dir(name) + + # Determine version + if not version_name: + version_name = _get_next_version(template_dir) + elif not version_name.startswith("v"): + version_name = f"v{version_name}" + + # Copy the spec file + dest_path = template_dir / f"{version_name}.tree" + shutil.copy2(spec_file, dest_path) + + # Copy content directory if provided + if content_dir: + content_src = Path(content_dir) + if content_src.exists() and content_src.is_dir(): + content_dest = template_dir / version_name + if content_dest.exists(): + shutil.rmtree(content_dest) + shutil.copytree(content_src, content_dest) + + # Update metadata + if version_name not in meta.versions: + meta.versions.append(version_name) + + _save_meta(meta) + + # Update registry + registry = load_registry() + registry[name] = meta + save_registry(registry) + + return version_name + + +def set_current_version(name: str, version: str) -> None: + """Set the current version for a template. + + Args: + name: Template name + version: Version to set as current + + Raises: + ValueError: If template or version not found + """ + meta = get_template(name) + if not meta: + raise ValueError(f"Template not found: {name}") + + if not version.startswith("v"): + version = f"v{version}" + + if version not in meta.versions: + raise ValueError(f"Version not found: {version}") + + meta.current_version = version + _save_meta(meta) + + # Update registry + registry = load_registry() + registry[name] = meta + save_registry(registry) + + +def lock_template(name: str, version: Optional[str] = None) -> None: + """Lock a template to prevent modifications. + + Args: + name: Template name + version: Optional version to set as current before locking + + Raises: + ValueError: If template not found + """ + meta = get_template(name) + if not meta: + raise ValueError(f"Template not found: {name}") + + if version: + if not version.startswith("v"): + version = f"v{version}" + if version not in meta.versions: + raise ValueError(f"Version not found: {version}") + meta.current_version = version + + meta.locked = True + _save_meta(meta) + + # Update registry + registry = load_registry() + registry[name] = meta + save_registry(registry) + + +def unlock_template(name: str) -> None: + """Unlock a template to allow modifications. + + Args: + name: Template name + + Raises: + ValueError: If template not found + """ + meta = get_template(name) + if not meta: + raise ValueError(f"Template not found: {name}") + + meta.locked = False + _save_meta(meta) + + # Update registry + registry = load_registry() + registry[name] = meta + save_registry(registry) diff --git a/tests/test_template_registry.py b/tests/test_template_registry.py new file mode 100644 index 0000000..c8a6e3c --- /dev/null +++ b/tests/test_template_registry.py @@ -0,0 +1,1308 @@ +"""Tests for seed_cli.template_registry module. + +Comprehensive tests for template management functionality. +""" + +import json +import time +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from seed_cli.template_registry import ( + TEMPLATES_DIR_NAME, + REGISTRY_FILE, + META_FILE, + TemplateMetadata, + get_templates_dir, + load_registry, + save_registry, + add_template, + add_local_template, + remove_template, + list_templates, + get_template, + get_template_spec_path, + get_template_content_dir, + list_versions, + add_version, + set_current_version, + lock_template, + unlock_template, + parse_github_url, + fetch_dir_from_github, + fetch_content_to_dir, + update_template, + install_default_templates, + _get_template_dir, + _load_meta, + _save_meta, + _save_source_json, + _get_next_version, +) + + +# ----------------------------------------------------------------------------- +# Tests for TemplateMetadata +# ----------------------------------------------------------------------------- + +def test_template_metadata_defaults(): + """Should create metadata with default values.""" + meta = TemplateMetadata( + name="test", + source="https://github.com/user/repo", + current_version="v1", + ) + assert meta.name == "test" + assert meta.source == "https://github.com/user/repo" + assert meta.current_version == "v1" + assert meta.locked is False + assert isinstance(meta.created_at, float) + assert meta.versions == [] + + +def test_template_metadata_to_dict(): + """Should convert metadata to dict.""" + meta = TemplateMetadata( + name="test", + source="https://github.com/user/repo", + current_version="v1", + locked=True, + versions=["v1", "v2"], + ) + data = meta.to_dict() + assert data["name"] == "test" + assert data["locked"] is True + assert data["versions"] == ["v1", "v2"] + + +def test_template_metadata_from_dict(): + """Should create metadata from dict.""" + data = { + "name": "test", + "source": "https://github.com/user/repo", + "current_version": "v1", + "locked": False, + "created_at": 12345.0, + "versions": ["v1"], + } + meta = TemplateMetadata.from_dict(data) + assert meta.name == "test" + assert meta.created_at == 12345.0 + + +# ----------------------------------------------------------------------------- +# Tests for get_templates_dir +# ----------------------------------------------------------------------------- + +def test_get_templates_dir_creates_directory(tmp_path, monkeypatch): + """Should create templates directory.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = get_templates_dir() + + assert result.exists() + assert result.is_dir() + assert result == tmp_path / ".seed" / TEMPLATES_DIR_NAME + + +def test_get_templates_dir_returns_existing(tmp_path, monkeypatch): + """Should return existing directory.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + templates_dir = tmp_path / ".seed" / TEMPLATES_DIR_NAME + templates_dir.mkdir(parents=True) + + result = get_templates_dir() + assert result == templates_dir + + +# ----------------------------------------------------------------------------- +# Tests for load_registry / save_registry +# ----------------------------------------------------------------------------- + +def test_load_registry_empty_when_no_file(tmp_path, monkeypatch): + """Should return empty dict when registry doesn't exist.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = load_registry() + assert result == {} + + +def test_save_and_load_registry(tmp_path, monkeypatch): + """Should save and load registry correctly.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + meta = TemplateMetadata( + name="test", + source="https://github.com/user/repo", + current_version="v1", + versions=["v1"], + ) + registry = {"test": meta} + + save_registry(registry) + loaded = load_registry() + + assert "test" in loaded + assert loaded["test"].name == "test" + assert loaded["test"].versions == ["v1"] + + +def test_load_registry_handles_invalid_json(tmp_path, monkeypatch): + """Should handle corrupted registry file.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Create invalid JSON + templates_dir = tmp_path / ".seed" / TEMPLATES_DIR_NAME + templates_dir.mkdir(parents=True) + (templates_dir / REGISTRY_FILE).write_text("not valid json") + + result = load_registry() + assert result == {} + + +# ----------------------------------------------------------------------------- +# Tests for parse_github_url +# ----------------------------------------------------------------------------- + +def test_parse_github_url_blob(): + """Should parse GitHub blob URL.""" + url = "https://github.com/user/repo/blob/main/path/to/spec.tree" + result = parse_github_url(url) + + assert result is not None + assert result["owner"] == "user" + assert result["repo"] == "repo" + assert result["ref"] == "main" + assert result["path"] == "path/to/spec.tree" + assert result["type"] == "blob" + + +def test_parse_github_url_tree(): + """Should parse GitHub tree URL.""" + url = "https://github.com/user/repo/tree/v1.0/specs" + result = parse_github_url(url) + + assert result is not None + assert result["type"] == "tree" + assert result["path"] == "specs" + + +def test_parse_github_url_without_protocol(): + """Should handle URL without https://.""" + url = "github.com/user/repo/blob/main/spec.tree" + result = parse_github_url(url) + + assert result is not None + assert result["owner"] == "user" + + +def test_parse_github_url_invalid(): + """Should return None for invalid URLs.""" + assert parse_github_url("https://gitlab.com/user/repo") is None + assert parse_github_url("not a url") is None + assert parse_github_url("https://github.com/user") is None + + +# ----------------------------------------------------------------------------- +# Tests for _get_next_version +# ----------------------------------------------------------------------------- + +def test_get_next_version_first(tmp_path, monkeypatch): + """Should return v1 for empty directory.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + template_dir = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "test" + template_dir.mkdir(parents=True) + + result = _get_next_version(template_dir) + assert result == "v1" + + +def test_get_next_version_increments(tmp_path, monkeypatch): + """Should increment from highest version.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + template_dir = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "test" + template_dir.mkdir(parents=True) + + (template_dir / "v1.tree").write_text("content") + (template_dir / "v2.tree").write_text("content") + + result = _get_next_version(template_dir) + assert result == "v3" + + +def test_get_next_version_handles_gaps(tmp_path, monkeypatch): + """Should use highest version even with gaps.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + template_dir = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "test" + template_dir.mkdir(parents=True) + + (template_dir / "v1.tree").write_text("content") + (template_dir / "v5.tree").write_text("content") + + result = _get_next_version(template_dir) + assert result == "v6" + + +# ----------------------------------------------------------------------------- +# Tests for add_local_template +# ----------------------------------------------------------------------------- + +def test_add_local_template(tmp_path, monkeypatch): + """Should add a template from local file.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Create a local spec file + spec_file = tmp_path / "myspec.tree" + spec_file.write_text("src/\nfile.py") + + meta = add_local_template(str(spec_file), "mytemplate") + + assert meta.name == "mytemplate" + assert meta.current_version == "v1" + assert "v1" in meta.versions + assert meta.source == f"local:{spec_file}" + + # Check file was copied + template_dir = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "mytemplate" + assert (template_dir / "v1.tree").exists() + + +def test_add_local_template_auto_version(tmp_path, monkeypatch): + """Should auto-increment version.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + meta1 = add_local_template(str(spec_file), "test") + meta2 = add_local_template(str(spec_file), "test") + + assert meta1.current_version == "v1" + assert meta2.current_version == "v2" + assert "v1" in meta2.versions + assert "v2" in meta2.versions + + +def test_add_local_template_explicit_version(tmp_path, monkeypatch): + """Should use explicit version name.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + meta = add_local_template(str(spec_file), "test", version="v5") + + assert meta.current_version == "v5" + + +def test_add_local_template_file_not_found(tmp_path, monkeypatch): + """Should raise FileNotFoundError for missing file.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + with pytest.raises(FileNotFoundError): + add_local_template("/nonexistent/spec.tree", "test") + + +def test_add_local_template_locked_error(tmp_path, monkeypatch): + """Should raise ValueError for locked template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + # Add and lock template + add_local_template(str(spec_file), "test") + lock_template("test") + + with pytest.raises(ValueError, match="locked"): + add_local_template(str(spec_file), "test") + + +# ----------------------------------------------------------------------------- +# Tests for remove_template +# ----------------------------------------------------------------------------- + +def test_remove_template(tmp_path, monkeypatch): + """Should remove a template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + + result = remove_template("test") + + assert result is True + assert get_template("test") is None + + # Check directory was removed + template_dir = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "test" + assert not template_dir.exists() + + +def test_remove_template_not_found(tmp_path, monkeypatch): + """Should return False for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = remove_template("nonexistent") + assert result is False + + +def test_remove_template_locked_error(tmp_path, monkeypatch): + """Should raise ValueError for locked template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + lock_template("test") + + with pytest.raises(ValueError, match="locked"): + remove_template("test") + + +# ----------------------------------------------------------------------------- +# Tests for list_templates +# ----------------------------------------------------------------------------- + +def test_list_templates_empty(tmp_path, monkeypatch): + """Should return empty list when no templates.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = list_templates() + assert result == [] + + +def test_list_templates(tmp_path, monkeypatch): + """Should list all templates.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test1") + add_local_template(str(spec_file), "test2") + + result = list_templates() + + names = [t.name for t in result] + assert "test1" in names + assert "test2" in names + + +# ----------------------------------------------------------------------------- +# Tests for get_template +# ----------------------------------------------------------------------------- + +def test_get_template(tmp_path, monkeypatch): + """Should get template metadata.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + + result = get_template("test") + + assert result is not None + assert result.name == "test" + + +def test_get_template_not_found(tmp_path, monkeypatch): + """Should return None for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = get_template("nonexistent") + assert result is None + + +# ----------------------------------------------------------------------------- +# Tests for get_template_spec_path +# ----------------------------------------------------------------------------- + +def test_get_template_spec_path(tmp_path, monkeypatch): + """Should return path to spec file.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + + result = get_template_spec_path("test") + + assert result is not None + assert result.exists() + assert result.name == "v1.tree" + + +def test_get_template_spec_path_with_version(tmp_path, monkeypatch): + """Should return path for specific version.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + add_local_template(str(spec_file), "test") + + result = get_template_spec_path("test", "v1") + + assert result is not None + assert result.name == "v1.tree" + + +def test_get_template_spec_path_not_found(tmp_path, monkeypatch): + """Should return None for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = get_template_spec_path("nonexistent") + assert result is None + + +# ----------------------------------------------------------------------------- +# Tests for list_versions +# ----------------------------------------------------------------------------- + +def test_list_versions(tmp_path, monkeypatch): + """Should list all versions of a template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + add_local_template(str(spec_file), "test") + add_local_template(str(spec_file), "test") + + result = list_versions("test") + + versions = [v for v, _ in result] + assert versions == ["v1", "v2", "v3"] + + +def test_list_versions_not_found(tmp_path, monkeypatch): + """Should return empty list for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = list_versions("nonexistent") + assert result == [] + + +# ----------------------------------------------------------------------------- +# Tests for add_version +# ----------------------------------------------------------------------------- + +def test_add_version(tmp_path, monkeypatch): + """Should add a new version to existing template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + spec_file2 = tmp_path / "spec2.tree" + spec_file2.write_text("updated content") + + add_local_template(str(spec_file), "test") + version = add_version("test", str(spec_file2)) + + assert version == "v2" + + meta = get_template("test") + assert "v2" in meta.versions + + +def test_add_version_explicit_name(tmp_path, monkeypatch): + """Should use explicit version name.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + version = add_version("test", str(spec_file), "v10") + + assert version == "v10" + + +def test_add_version_template_not_found(tmp_path, monkeypatch): + """Should raise ValueError for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + with pytest.raises(ValueError, match="not found"): + add_version("nonexistent", str(spec_file)) + + +def test_add_version_locked_error(tmp_path, monkeypatch): + """Should raise ValueError for locked template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + lock_template("test") + + with pytest.raises(ValueError, match="locked"): + add_version("test", str(spec_file)) + + +# ----------------------------------------------------------------------------- +# Tests for set_current_version +# ----------------------------------------------------------------------------- + +def test_set_current_version(tmp_path, monkeypatch): + """Should set current version.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + add_local_template(str(spec_file), "test") + + set_current_version("test", "v1") + + meta = get_template("test") + assert meta.current_version == "v1" + + +def test_set_current_version_not_found(tmp_path, monkeypatch): + """Should raise ValueError for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + with pytest.raises(ValueError, match="Template not found"): + set_current_version("nonexistent", "v1") + + +def test_set_current_version_invalid(tmp_path, monkeypatch): + """Should raise ValueError for non-existent version.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + + with pytest.raises(ValueError, match="Version not found"): + set_current_version("test", "v99") + + +# ----------------------------------------------------------------------------- +# Tests for lock_template / unlock_template +# ----------------------------------------------------------------------------- + +def test_lock_template(tmp_path, monkeypatch): + """Should lock a template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + lock_template("test") + + meta = get_template("test") + assert meta.locked is True + + +def test_lock_template_with_version(tmp_path, monkeypatch): + """Should set version when locking.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + add_local_template(str(spec_file), "test") + + lock_template("test", version="v1") + + meta = get_template("test") + assert meta.locked is True + assert meta.current_version == "v1" + + +def test_unlock_template(tmp_path, monkeypatch): + """Should unlock a template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + add_local_template(str(spec_file), "test") + lock_template("test") + unlock_template("test") + + meta = get_template("test") + assert meta.locked is False + + +def test_lock_template_not_found(tmp_path, monkeypatch): + """Should raise ValueError for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + with pytest.raises(ValueError, match="not found"): + lock_template("nonexistent") + + +def test_unlock_template_not_found(tmp_path, monkeypatch): + """Should raise ValueError for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + with pytest.raises(ValueError, match="not found"): + unlock_template("nonexistent") + + +# ----------------------------------------------------------------------------- +# Tests for _load_meta / _save_meta +# ----------------------------------------------------------------------------- + +def test_save_and_load_meta(tmp_path, monkeypatch): + """Should save and load meta.json correctly.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + meta = TemplateMetadata( + name="test", + source="local:test.tree", + current_version="v1", + locked=True, + versions=["v1", "v2"], + ) + + _save_meta(meta) + loaded = _load_meta("test") + + assert loaded is not None + assert loaded.name == "test" + assert loaded.locked is True + assert loaded.versions == ["v1", "v2"] + + +def test_load_meta_not_found(tmp_path, monkeypatch): + """Should return None for non-existent meta.json.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + result = _load_meta("nonexistent") + assert result is None + + +# ----------------------------------------------------------------------------- +# Integration tests +# ----------------------------------------------------------------------------- + +def test_full_workflow(tmp_path, monkeypatch): + """Test complete template lifecycle.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Create spec files + spec1 = tmp_path / "spec1.tree" + spec1.write_text("src/") + + spec2 = tmp_path / "spec2.tree" + spec2.write_text("src/\nlib/") + + # Add template + meta = add_local_template(str(spec1), "myproject") + assert meta.name == "myproject" + assert meta.current_version == "v1" + + # List templates + templates = list_templates() + assert len(templates) == 1 + + # Get spec path + path = get_template_spec_path("myproject") + assert path.read_text() == "src/" + + # Add version + add_version("myproject", str(spec2)) + meta = get_template("myproject") + assert "v2" in meta.versions + + # List versions + versions = list_versions("myproject") + assert len(versions) == 2 + + # Set current + set_current_version("myproject", "v1") + meta = get_template("myproject") + assert meta.current_version == "v1" + + # Lock + lock_template("myproject", version="v2") + meta = get_template("myproject") + assert meta.locked is True + assert meta.current_version == "v2" + + # Unlock + unlock_template("myproject") + meta = get_template("myproject") + assert meta.locked is False + + # Remove + remove_template("myproject") + assert get_template("myproject") is None + + +def test_sanitize_name(tmp_path, monkeypatch): + """Should sanitize template names.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("content") + + meta = add_local_template(str(spec_file), "my-template!@#$%") + + # Name should be sanitized + assert "!" not in meta.name + assert "@" not in meta.name + + +# ----------------------------------------------------------------------------- +# Tests for GitHub fetching (mocked) +# ----------------------------------------------------------------------------- + +def test_add_template_from_github_mocked(tmp_path, monkeypatch): + """Should add template from GitHub URL (mocked).""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Mock the fetch function + def mock_fetch(url, dest_dir, name=None): + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / (name or "spec.tree") + dest_file.write_text("github content") + return dest_file, "spec.tree" + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_from_github", + mock_fetch + ) + + meta = add_template( + "https://github.com/user/repo/blob/main/spec.tree", + name="github-template" + ) + + assert meta.name == "github-template" + assert "github.com" in meta.source + + +# ----------------------------------------------------------------------------- +# Tests for TemplateMetadata.content_url +# ----------------------------------------------------------------------------- + +def test_template_metadata_content_url_default(): + """content_url should default to None.""" + meta = TemplateMetadata( + name="test", + source="https://github.com/user/repo", + current_version="v1", + ) + assert meta.content_url is None + + +def test_template_metadata_content_url_roundtrip(): + """to_dict/from_dict should preserve content_url.""" + meta = TemplateMetadata( + name="test", + source="https://github.com/user/repo", + current_version="v1", + content_url="https://github.com/user/repo/tree/main/src", + ) + data = meta.to_dict() + assert data["content_url"] == "https://github.com/user/repo/tree/main/src" + + restored = TemplateMetadata.from_dict(data) + assert restored.content_url == "https://github.com/user/repo/tree/main/src" + + +def test_template_metadata_from_dict_ignores_unknown_fields(): + """from_dict should not crash on unknown fields.""" + data = { + "name": "test", + "source": "local:test.tree", + "current_version": "v1", + "locked": False, + "created_at": 100.0, + "versions": ["v1"], + "content_url": None, + "some_future_field": "unexpected", + } + meta = TemplateMetadata.from_dict(data) + assert meta.name == "test" + assert not hasattr(meta, "some_future_field") + + +def test_template_metadata_from_dict_missing_content_url(): + """Old meta.json without content_url should default to None.""" + data = { + "name": "test", + "source": "local:test.tree", + "current_version": "v1", + "locked": False, + "created_at": 100.0, + "versions": ["v1"], + } + meta = TemplateMetadata.from_dict(data) + assert meta.content_url is None + + +# ----------------------------------------------------------------------------- +# Tests for _save_source_json +# ----------------------------------------------------------------------------- + +def test_save_source_json(tmp_path, monkeypatch): + """Should write correct JSON to ~/.seed/templates//source.json.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + _save_source_json("mytemplate", "https://github.com/user/repo/tree/main/src") + + source_json = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "mytemplate" / "source.json" + assert source_json.exists() + data = json.loads(source_json.read_text()) + assert data["content_url"] == "https://github.com/user/repo/tree/main/src" + + +# ----------------------------------------------------------------------------- +# Tests for fetch_content_to_dir +# ----------------------------------------------------------------------------- + +def test_fetch_content_to_dir_local(tmp_path): + """Local directory should be copied into dest.""" + src = tmp_path / "source_files" + src.mkdir() + (src / "main.py").write_text("print('hello')") + (src / "utils.py").write_text("# utils") + + dest = tmp_path / "dest" + result = fetch_content_to_dir(str(src), dest) + + assert result == dest + assert (dest / "main.py").read_text() == "print('hello')" + assert (dest / "utils.py").read_text() == "# utils" + + +def test_fetch_content_to_dir_github_mocked(tmp_path, monkeypatch): + """Non-local path should delegate to fetch_dir_from_github.""" + calls = [] + + def mock_fetch(url, dest_dir): + calls.append((url, dest_dir)) + dest_dir.mkdir(parents=True, exist_ok=True) + (dest_dir / "fetched.py").write_text("fetched") + return dest_dir + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_dir_from_github", + mock_fetch, + ) + + dest = tmp_path / "dest" + fetch_content_to_dir("https://github.com/user/repo/tree/main/src", dest) + + assert len(calls) == 1 + assert calls[0][0] == "https://github.com/user/repo/tree/main/src" + + +# ----------------------------------------------------------------------------- +# Tests for fetch_dir_from_github +# ----------------------------------------------------------------------------- + +def test_fetch_dir_from_github_invalid_url(): + """Should raise ValueError for non-GitHub URL.""" + with pytest.raises(ValueError, match="Invalid GitHub URL"): + fetch_dir_from_github("https://gitlab.com/user/repo/tree/main/src", Path("/tmp/dest")) + + +def test_fetch_dir_from_github_blob_url(): + """Should raise ValueError for blob URL (must be tree).""" + with pytest.raises(ValueError, match="directory.*tree"): + fetch_dir_from_github( + "https://github.com/user/repo/blob/main/file.py", + Path("/tmp/dest"), + ) + + +def test_fetch_dir_from_github_no_path(): + """Should raise ValueError when URL has no path.""" + with pytest.raises(ValueError, match="path to a directory"): + fetch_dir_from_github( + "https://github.com/user/repo/tree/main", + Path("/tmp/dest"), + ) + + +# ----------------------------------------------------------------------------- +# Tests for update_template +# ----------------------------------------------------------------------------- + +def test_update_template_refetches(tmp_path, monkeypatch): + """Should re-fetch content from existing content_url.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Create a template with content_url + spec_file = tmp_path / "spec.tree" + spec_file.write_text("src/\nfile.py") + + meta = add_local_template(str(spec_file), "test") + meta.content_url = "https://github.com/user/repo/tree/main/src" + _save_meta(meta) + + fetch_calls = [] + + def mock_fetch(url, dest): + fetch_calls.append(url) + dest.mkdir(parents=True, exist_ok=True) + (dest / "fetched.py").write_text("content") + return dest + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_content_to_dir", + mock_fetch, + ) + + result = update_template("test") + assert len(fetch_calls) == 1 + assert fetch_calls[0] == "https://github.com/user/repo/tree/main/src" + assert result.name == "test" + + +def test_update_template_with_new_url(tmp_path, monkeypatch): + """Should set new content_url and persist to meta + source.json.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("src/") + + meta = add_local_template(str(spec_file), "test") + meta.content_url = "https://github.com/user/repo/tree/main/old" + _save_meta(meta) + + def mock_fetch(url, dest): + dest.mkdir(parents=True, exist_ok=True) + return dest + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_content_to_dir", + mock_fetch, + ) + + new_url = "https://github.com/user/repo/tree/main/new" + result = update_template("test", content_url=new_url) + + assert result.content_url == new_url + + # Check meta persisted + loaded_meta = _load_meta("test") + assert loaded_meta.content_url == new_url + + # Check source.json persisted + source_json = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "test" / "source.json" + assert source_json.exists() + data = json.loads(source_json.read_text()) + assert data["content_url"] == new_url + + +def test_update_template_not_found(tmp_path, monkeypatch): + """Should raise ValueError for non-existent template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + with pytest.raises(ValueError, match="Template not found"): + update_template("nonexistent") + + +def test_update_template_no_content_url(tmp_path, monkeypatch): + """Should raise ValueError when template has no content_url.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("src/") + add_local_template(str(spec_file), "test") + + with pytest.raises(ValueError, match="no content_url"): + update_template("test") + + +# ----------------------------------------------------------------------------- +# Tests for add_local_template with content_url +# ----------------------------------------------------------------------------- + +def test_add_local_template_with_content_url(tmp_path, monkeypatch): + """Should fetch content + set meta.content_url + write source.json.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("src/\nfile.py") + + fetch_calls = [] + + def mock_fetch(url, dest): + fetch_calls.append(url) + dest.mkdir(parents=True, exist_ok=True) + (dest / "main.py").write_text("fetched") + return dest + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_content_to_dir", + mock_fetch, + ) + + url = "https://github.com/user/repo/tree/main/src" + meta = add_local_template(str(spec_file), "test", content_url=url) + + assert meta.content_url == url + assert len(fetch_calls) == 1 + + # source.json written + source_json = tmp_path / ".seed" / TEMPLATES_DIR_NAME / "test" / "source.json" + assert source_json.exists() + + +def test_add_local_template_with_source_json(tmp_path, monkeypatch): + """Directory with source.json should trigger fetch.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + tpl_dir = tmp_path / "mytemplate" + tpl_dir.mkdir() + (tpl_dir / "spec.tree").write_text("src/") + (tpl_dir / "source.json").write_text(json.dumps({ + "content_url": "https://github.com/user/repo/tree/main/src" + })) + + fetch_calls = [] + + def mock_fetch(url, dest): + fetch_calls.append(url) + dest.mkdir(parents=True, exist_ok=True) + return dest + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_content_to_dir", + mock_fetch, + ) + + meta = add_local_template(str(tpl_dir), "test") + assert meta.content_url == "https://github.com/user/repo/tree/main/src" + assert len(fetch_calls) == 1 + + +def test_add_local_template_content_url_overrides_source_json(tmp_path, monkeypatch): + """Explicit content_url param should win over source.json.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + tpl_dir = tmp_path / "mytemplate" + tpl_dir.mkdir() + (tpl_dir / "spec.tree").write_text("src/") + (tpl_dir / "source.json").write_text(json.dumps({ + "content_url": "https://github.com/user/old-repo/tree/main/src" + })) + + fetch_calls = [] + + def mock_fetch(url, dest): + fetch_calls.append(url) + dest.mkdir(parents=True, exist_ok=True) + return dest + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_content_to_dir", + mock_fetch, + ) + + explicit_url = "https://github.com/user/new-repo/tree/main/src" + meta = add_local_template(str(tpl_dir), "test", content_url=explicit_url) + + assert meta.content_url == explicit_url + assert len(fetch_calls) == 1 + assert fetch_calls[0] == explicit_url + + +def test_add_local_template_content_url_fetch_failure(tmp_path, monkeypatch): + """Fetch failure should still create the template (graceful).""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + spec_file = tmp_path / "spec.tree" + spec_file.write_text("src/") + + def mock_fetch(url, dest): + raise RuntimeError("network error") + + monkeypatch.setattr( + "seed_cli.template_registry.fetch_content_to_dir", + mock_fetch, + ) + + url = "https://github.com/user/repo/tree/main/src" + meta = add_local_template(str(spec_file), "test", content_url=url) + + # Template should still be created + assert meta.name == "test" + assert meta.content_url == url + assert (tmp_path / ".seed" / TEMPLATES_DIR_NAME / "test" / "v1.tree").exists() + + +# ----------------------------------------------------------------------------- +# Tests for add_template with content_url +# ----------------------------------------------------------------------------- + +def test_add_template_blob_with_content_url(tmp_path, monkeypatch): + """Blob URL + content_url should call both fetch_from_github and fetch_content_to_dir.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + fetch_github_calls = [] + fetch_content_calls = [] + + def mock_fetch_github(url, dest_dir, name=None): + fetch_github_calls.append(url) + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / (name or "spec.tree") + dest_file.write_text("src/\nfile.py") + return dest_file, "spec.tree" + + def mock_fetch_content(url, dest): + fetch_content_calls.append(url) + dest.mkdir(parents=True, exist_ok=True) + return dest + + monkeypatch.setattr("seed_cli.template_registry.fetch_from_github", mock_fetch_github) + monkeypatch.setattr("seed_cli.template_registry.fetch_content_to_dir", mock_fetch_content) + + content_url = "https://github.com/user/repo/tree/main/src" + meta = add_template( + "https://github.com/user/repo/blob/main/spec.tree", + name="test", + content_url=content_url, + ) + + assert len(fetch_github_calls) == 1 + assert len(fetch_content_calls) == 1 + assert meta.content_url == content_url + + +def test_add_template_tree_url(tmp_path, monkeypatch): + """Tree URL should fetch dir and delegate to add_local_template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + def mock_fetch_dir(url, dest_dir): + dest_dir.mkdir(parents=True, exist_ok=True) + (dest_dir / "spec.tree").write_text("src/\nfile.py") + return dest_dir + + monkeypatch.setattr("seed_cli.template_registry.fetch_dir_from_github", mock_fetch_dir) + + meta = add_template( + "https://github.com/user/repo/tree/main/templates/mytemplate", + name="test", + ) + + assert meta.name == "test" + assert meta.current_version == "v1" + + +# ----------------------------------------------------------------------------- +# Tests for install_default_templates with source.json +# ----------------------------------------------------------------------------- + +def test_install_default_templates_reads_source_json(tmp_path, monkeypatch): + """Should read source.json and persist content_url in meta.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Reset the global flag + import seed_cli.template_registry as tr + monkeypatch.setattr(tr, "_defaults_installed", False) + + # Create a fake bundled template package + pkg_dir = tmp_path / "fake_pkg" / "default_templates" / "mytemplate" + pkg_dir.mkdir(parents=True) + (pkg_dir / "spec.tree").write_text("src/\nfile.py") + (pkg_dir / "source.json").write_text(json.dumps({ + "content_url": "https://github.com/user/repo/tree/main/src" + })) + + fetch_calls = [] + + def mock_fetch_dir(url, dest_dir): + fetch_calls.append(url) + dest_dir.mkdir(parents=True, exist_ok=True) + (dest_dir / "main.py").write_text("content") + return dest_dir + + monkeypatch.setattr("seed_cli.template_registry.fetch_dir_from_github", mock_fetch_dir) + + # Mock importlib.resources to return our fake package + mock_resources = pkg_dir.parent + monkeypatch.setattr( + "seed_cli.template_registry.importlib.resources.files", + lambda pkg: mock_resources.parent, + ) + # The code does: files("seed_cli") / "resources" / "default_templates" + # So we need: fake_pkg / "resources" / "default_templates" + # Let me restructure + real_pkg = tmp_path / "pkg_root" + resources_dir = real_pkg / "resources" / "default_templates" / "mytemplate" + resources_dir.mkdir(parents=True) + (resources_dir / "spec.tree").write_text("src/\nfile.py") + (resources_dir / "source.json").write_text(json.dumps({ + "content_url": "https://github.com/user/repo/tree/main/src" + })) + + monkeypatch.setattr( + "seed_cli.template_registry.importlib.resources.files", + lambda pkg: real_pkg, + ) + + install_default_templates() + + assert len(fetch_calls) == 1 + assert fetch_calls[0] == "https://github.com/user/repo/tree/main/src" + + # Verify meta was saved with content_url + meta = _load_meta("mytemplate") + assert meta is not None + assert meta.content_url == "https://github.com/user/repo/tree/main/src" + + +def test_install_default_templates_fetch_failure_graceful(tmp_path, monkeypatch): + """Fetch failure should still install the template.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + import seed_cli.template_registry as tr + monkeypatch.setattr(tr, "_defaults_installed", False) + + real_pkg = tmp_path / "pkg_root" + resources_dir = real_pkg / "resources" / "default_templates" / "mytemplate" + resources_dir.mkdir(parents=True) + (resources_dir / "spec.tree").write_text("src/\nfile.py") + (resources_dir / "source.json").write_text(json.dumps({ + "content_url": "https://github.com/user/repo/tree/main/src" + })) + + def mock_fetch_dir(url, dest_dir): + raise RuntimeError("network error") + + monkeypatch.setattr("seed_cli.template_registry.fetch_dir_from_github", mock_fetch_dir) + monkeypatch.setattr( + "seed_cli.template_registry.importlib.resources.files", + lambda pkg: real_pkg, + ) + + install_default_templates() + + # Template should still be installed + meta = _load_meta("mytemplate") + assert meta is not None + assert meta.name == "mytemplate" + assert meta.content_url == "https://github.com/user/repo/tree/main/src" + assert (tmp_path / ".seed" / TEMPLATES_DIR_NAME / "mytemplate" / "v1.tree").exists()