diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 8b030f3..e601945 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -9,7 +9,22 @@ permissions: contents: read jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - run: pip install -e ".[dev]" + - run: ruff check src/ tests/ + - run: ruff format --check src/ tests/ + - run: mypy src/specsmith --ignore-missing-imports + - run: pytest tests/ -x -q + dev-build: + needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4a29a3..0ab65b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,8 @@ jobs: python-version: "3.12" cache: pip - run: pip install -e ".[dev]" - - run: ruff check src/ + - run: ruff check src/ tests/ + - run: ruff format --check src/ tests/ - run: mypy src/specsmith --ignore-missing-imports - run: pytest tests/ -x -q @@ -54,7 +55,8 @@ jobs: run: | gh release create "${{ github.ref_name }}" dist/* \ --title "${{ github.ref_name }}" \ - --generate-notes + --generate-notes \ + --latest || echo "Release already exists — skipping" pypi-publish: needs: build diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b9859..60b8154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] - 2026-04-02 + +### Added +- **Process execution with PID tracking**: `specsmith exec`, `specsmith ps`, `specsmith abort` — cross-platform (Windows taskkill / POSIX SIGTERM+SIGKILL) process tracking and abort. PID files in `.specsmith/pids/`. +- **`specsmith upgrade --full`**: full sync of infrastructure files — regenerates exec shims, CI configs, agent integrations. Creates missing community/config files. Safe: never overwrites user docs. +- **Language-specific scaffold templates** (#41): Rust (Cargo.toml, main.rs), Go (go.mod, main.go), JS/TS (package.json for web-frontend, fullstack-js). +- **ReadTheDocs templates** (#38): `.readthedocs.yaml` and `mkdocs.yml` for Python/doc projects. +- **Release workflow templates** (#44): `.github/workflows/release.yml` with test gate, language-aware build, GitHub Release, PyPI OIDC publish. +- **PyPI integration** (#36): OIDC-based trusted publishing via release workflow template. + +### Changed +- **Template directory restructured** (#45): `pyproject.toml.j2` moved to `python/`. Templates organized into `python/`, `rust/`, `go/`, `js/`, `community/`, `governance/`, `docs/`, `scripts/`, `workflows/`. +- **CI-gated releases**: both dev-release and stable release workflows now run full test suite (ruff check+format, mypy, pytest) before PyPI publish. +- Exec shims (`exec.cmd`, `exec.sh`) now write PID files for `specsmith ps`/`specsmith abort`. + ## [0.2.0] - 2026-04-02 ### Added @@ -171,7 +186,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **G9**: Session start file list now marks services.md as conditional ("if it exists"). - **G10**: Open TODOs format specified as `- [ ]` / `- [x]` checkbox syntax. -[Unreleased]: https://github.com/BitConcepts/specsmith/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/BitConcepts/specsmith/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/BitConcepts/specsmith/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/BitConcepts/specsmith/compare/v0.1.3...v0.2.0 [0.1.3]: https://github.com/BitConcepts/specsmith/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/BitConcepts/specsmith/compare/v0.1.1...v0.1.2 diff --git a/pyproject.toml b/pyproject.toml index 21a4f00..395185f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "specsmith" -version = "0.2.0" +version = "0.2.1" description = "Forge governed project scaffolds from the Agentic AI Development Workflow Specification." readme = "README.md" license = {text = "MIT"} diff --git a/src/specsmith/__init__.py b/src/specsmith/__init__.py index ee39cf0..71804e1 100644 --- a/src/specsmith/__init__.py +++ b/src/specsmith/__init__.py @@ -8,4 +8,4 @@ try: __version__: str = _pkg_version("specsmith") except PackageNotFoundError: # running from source without install - __version__ = "0.2.0" + __version__ = "0.2.1" diff --git a/src/specsmith/cli.py b/src/specsmith/cli.py index 335d01e..f156d16 100644 --- a/src/specsmith/cli.py +++ b/src/specsmith/cli.py @@ -291,12 +291,23 @@ def compress(project_dir: str, threshold: int, keep_recent: int) -> None: default=".", help="Project root directory.", ) -def upgrade(spec_version: str | None, project_dir: str) -> None: - """Update governance files to match a newer spec version.""" +@click.option( + "--full", + is_flag=True, + default=False, + help="Full sync: also regenerate exec shims, CI, agent files, create missing community files.", +) +def upgrade(spec_version: str | None, project_dir: str, full: bool) -> None: + """Update governance files to match a newer spec version. + + With --full: also regenerates exec shims (PID tracking), CI configs, + agent integrations, and creates missing community files. Safe: never + overwrites AGENTS.md, LEDGER.md, or user documentation. + """ from specsmith.upgrader import run_upgrade root = Path(project_dir).resolve() - result = run_upgrade(root, target_version=spec_version) + result = run_upgrade(root, target_version=spec_version, full=full) console.print(result.message) if result.updated_files: @@ -1544,5 +1555,92 @@ def serve(port: int) -> None: ) +# --------------------------------------------------------------------------- +# Process execution and abort +# --------------------------------------------------------------------------- + + +@main.command(name="exec") +@click.argument("command") +@click.option("--timeout", default=120, help="Timeout in seconds (default: 120).") +@click.option("--project-dir", type=click.Path(exists=True), default=".") +def exec_cmd(command: str, timeout: int, project_dir: str) -> None: + """Execute a command with PID tracking and timeout enforcement. + + Tracks the process in .specsmith/pids/ so it can be listed (specsmith ps) + or aborted (specsmith abort). Logs stdout/stderr to .specsmith/logs/. + Works cross-platform: Windows, Linux, macOS. + """ + from specsmith.executor import run_tracked + + root = Path(project_dir).resolve() + console.print(f"[bold]exec[/bold] {command} (timeout={timeout}s)") + + result = run_tracked(root, command, timeout=timeout) + + if result.timed_out: + console.print(f"[red]TIMEOUT[/red] after {timeout}s (PID {result.pid})") + elif result.exit_code == 0: + console.print(f"[green]OK[/green] ({result.duration:.1f}s) — exit code 0") + else: + console.print(f"[red]FAILED[/red] ({result.duration:.1f}s) — exit code {result.exit_code}") + if result.stdout_file: + console.print(f" stdout: {result.stdout_file}") + if result.stderr_file: + console.print(f" stderr: {result.stderr_file}") + raise SystemExit(result.exit_code) + + +@main.command(name="ps") +@click.option("--project-dir", type=click.Path(exists=True), default=".") +def ps_cmd(project_dir: str) -> None: + """List tracked running processes.""" + from specsmith.executor import list_processes + + root = Path(project_dir).resolve() + procs = list_processes(root) + if not procs: + console.print("No tracked processes running.") + return + for p in procs: + elapsed = p.elapsed + remaining = max(0, p.timeout - elapsed) + status = "[red]EXPIRED[/red]" if p.is_expired else f"{remaining:.0f}s left" + console.print(f" PID {p.pid} {status} {p.command}") + console.print(f"\n {len(procs)} process(es)") + + +@main.command(name="abort") +@click.option("--pid", type=int, default=None, help="Abort a specific PID.") +@click.option("--all", "abort_all_flag", is_flag=True, default=False, help="Abort all tracked.") +@click.option("--project-dir", type=click.Path(exists=True), default=".") +def abort_cmd(pid: int | None, abort_all_flag: bool, project_dir: str) -> None: + """Abort tracked process(es). Sends SIGTERM then SIGKILL (POSIX) or taskkill (Windows).""" + from specsmith.executor import abort_all, abort_process, list_processes + + root = Path(project_dir).resolve() + + if abort_all_flag: + killed = abort_all(root) + if killed: + console.print(f"[green]Aborted {len(killed)} process(es): {killed}[/green]") + else: + console.print("No tracked processes to abort.") + elif pid: + if abort_process(root, pid): + console.print(f"[green]Aborted PID {pid}[/green]") + else: + console.print(f"[red]Could not abort PID {pid}[/red]") + else: + procs = list_processes(root) + if not procs: + console.print("No tracked processes. Use --pid or --all.") + return + console.print("Tracked processes:") + for p in procs: + console.print(f" PID {p.pid} {p.command}") + console.print("\nUse --pid or --all to abort.") + + if __name__ == "__main__": main() diff --git a/src/specsmith/executor.py b/src/specsmith/executor.py new file mode 100644 index 0000000..acb1fd2 --- /dev/null +++ b/src/specsmith/executor.py @@ -0,0 +1,267 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 BitConcepts, LLC. All rights reserved. +"""Executor — cross-platform process execution with PID tracking and abort. + +Provides governed command execution with: +- PID file tracking in .specsmith/pids/ +- Configurable timeout enforcement +- Cross-platform abort (Windows taskkill / POSIX SIGTERM+SIGKILL) +- Process listing for agent visibility +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + + +@dataclass +class TrackedProcess: + """Metadata for a tracked process.""" + + pid: int + command: str + started: str # ISO timestamp + timeout: int # seconds + pid_file: str = "" + + @property + def started_dt(self) -> datetime: + return datetime.fromisoformat(self.started) + + @property + def elapsed(self) -> float: + return (datetime.now(tz=timezone.utc) - self.started_dt).total_seconds() + + @property + def is_expired(self) -> bool: + return self.elapsed > self.timeout + + +@dataclass +class ExecResult: + """Result of a tracked execution.""" + + command: str + exit_code: int + pid: int + duration: float + timed_out: bool = False + aborted: bool = False + stdout_file: str = "" + stderr_file: str = "" + + +def _pids_dir(root: Path) -> Path: + d = root / ".specsmith" / "pids" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _logs_dir(root: Path) -> Path: + d = root / ".specsmith" / "logs" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _write_pid_file(root: Path, proc: TrackedProcess) -> Path: + """Write PID tracking file. Returns path to PID file.""" + pid_file = _pids_dir(root) / f"{proc.pid}.json" + proc.pid_file = str(pid_file) + pid_file.write_text(json.dumps(asdict(proc), indent=2), encoding="utf-8") + return pid_file + + +def _remove_pid_file(root: Path, pid: int) -> None: + """Remove PID tracking file.""" + pid_file = _pids_dir(root) / f"{pid}.json" + if pid_file.exists(): + pid_file.unlink() + + +def _is_process_alive(pid: int) -> bool: + """Check if a process is still running (cross-platform).""" + try: + if sys.platform == "win32": + # Windows: use tasklist to check + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}", "/NH"], + capture_output=True, + text=True, + timeout=5, + ) + return str(pid) in result.stdout + else: + # POSIX: signal 0 checks existence without killing + os.kill(pid, 0) + return True + except (OSError, subprocess.TimeoutExpired): + return False + + +def _kill_process(pid: int, *, graceful_timeout: float = 5.0) -> bool: + """Kill a process cross-platform. Returns True if killed. + + Strategy: + - POSIX: SIGTERM → wait → SIGKILL + - Windows: taskkill → taskkill /F + """ + if not _is_process_alive(pid): + return True # Already dead + + try: + if sys.platform == "win32": + # Graceful first + subprocess.run( + ["taskkill", "/PID", str(pid)], + capture_output=True, + timeout=graceful_timeout, + ) + time.sleep(min(graceful_timeout, 2.0)) + if not _is_process_alive(pid): + return True + # Force kill + subprocess.run( + ["taskkill", "/F", "/PID", str(pid), "/T"], + capture_output=True, + timeout=5, + ) + else: + # SIGTERM first + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + graceful_timeout + while time.monotonic() < deadline: + if not _is_process_alive(pid): + return True + time.sleep(0.2) + # SIGKILL + os.kill(pid, signal.SIGKILL) + except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + time.sleep(0.5) + return not _is_process_alive(pid) + + +def run_tracked( + root: Path, + command: str, + *, + timeout: int = 120, + capture: bool = True, +) -> ExecResult: + """Execute a command with PID tracking and timeout enforcement. + + - Writes PID file to .specsmith/pids/.json + - Enforces timeout via subprocess.Popen + polling + - Logs stdout/stderr to .specsmith/logs/ + - Cleans up PID file on completion + - Cross-platform: works on Windows, Linux, macOS + """ + started = datetime.now(tz=timezone.utc).isoformat() + ts = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S") + + stdout_path = _logs_dir(root) / f"exec_{ts}.stdout" + stderr_path = _logs_dir(root) / f"exec_{ts}.stderr" + + # Determine shell + if sys.platform == "win32": + shell_args: list[str] = ["cmd", "/c", command] + else: + shell_args = ["bash", "-c", command] + + stdout_fh = open(stdout_path, "w", encoding="utf-8") if capture else None # noqa: SIM115 + stderr_fh = open(stderr_path, "w", encoding="utf-8") if capture else None # noqa: SIM115 + + try: + proc = subprocess.Popen( # noqa: S603 + shell_args, + stdout=stdout_fh or subprocess.PIPE, + stderr=stderr_fh or subprocess.PIPE, + cwd=str(root), + ) + + tracked = TrackedProcess( + pid=proc.pid, + command=command, + started=started, + timeout=timeout, + ) + pid_file = _write_pid_file(root, tracked) + + start = time.monotonic() + timed_out = False + + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + _kill_process(proc.pid) + proc.wait(timeout=5) # Reap zombie + + duration = time.monotonic() - start + exit_code = proc.returncode if proc.returncode is not None else -1 + + # Clean up PID file + if pid_file.exists(): + pid_file.unlink() + + return ExecResult( + command=command, + exit_code=124 if timed_out else exit_code, + pid=proc.pid, + duration=duration, + timed_out=timed_out, + stdout_file=str(stdout_path) if capture else "", + stderr_file=str(stderr_path) if capture else "", + ) + + finally: + if stdout_fh: + stdout_fh.close() + if stderr_fh: + stderr_fh.close() + + +def list_processes(root: Path) -> list[TrackedProcess]: + """List all tracked processes. Prunes stale PID files for dead processes.""" + pids_dir = _pids_dir(root) + result: list[TrackedProcess] = [] + + for pid_file in pids_dir.glob("*.json"): + try: + data = json.loads(pid_file.read_text(encoding="utf-8")) + tp = TrackedProcess(**data) + if _is_process_alive(tp.pid): + result.append(tp) + else: + # Stale PID file — process already exited + pid_file.unlink() + except (json.JSONDecodeError, TypeError, OSError): + pid_file.unlink(missing_ok=True) + + return result + + +def abort_process(root: Path, pid: int) -> bool: + """Abort a specific tracked process by PID. Returns True if killed.""" + killed = _kill_process(pid) + _remove_pid_file(root, pid) + return killed + + +def abort_all(root: Path) -> list[int]: + """Abort all tracked processes. Returns list of killed PIDs.""" + killed: list[int] = [] + for tp in list_processes(root): + if _kill_process(tp.pid): + killed.append(tp.pid) + _remove_pid_file(root, tp.pid) + return killed diff --git a/src/specsmith/scaffolder.py b/src/specsmith/scaffolder.py index 0afb236..c7d413f 100644 --- a/src/specsmith/scaffolder.py +++ b/src/specsmith/scaffolder.py @@ -137,19 +137,47 @@ def _build_file_map(config: ProjectConfig) -> list[tuple[str, str]]: # Community / compliance files files.extend(_build_community_files(config)) - # Python project types get pyproject.toml and src layout + # Language-specific project files (#41) if config.type in ( ProjectType.CLI_PYTHON, ProjectType.LIBRARY_PYTHON, ProjectType.BACKEND_FRONTEND, ProjectType.BACKEND_FRONTEND_TRAY, ): - files.append(("pyproject.toml.j2", "pyproject.toml")) + files.append(("python/pyproject.toml.j2", "pyproject.toml")) files.append(("python/init.py.j2", f"src/{config.package_name}/__init__.py")) - if config.type == ProjectType.CLI_PYTHON: files.append(("python/cli.py.j2", f"src/{config.package_name}/cli.py")) + elif config.type in (ProjectType.CLI_RUST, ProjectType.LIBRARY_RUST): + files.append(("rust/Cargo.toml.j2", "Cargo.toml")) + if config.type == ProjectType.CLI_RUST: + files.append(("rust/main.rs.j2", "src/main.rs")) + + elif config.type == ProjectType.CLI_GO: + files.append(("go/go.mod.j2", "go.mod")) + files.append(("go/main.go.j2", "cmd/main.go")) + + elif config.type in ( + ProjectType.WEB_FRONTEND, + ProjectType.FULLSTACK_JS, + ): + files.append(("js/package.json.j2", "package.json")) + + # ReadTheDocs integration (#38) — Python and doc projects + if config.type in ( + ProjectType.CLI_PYTHON, + ProjectType.LIBRARY_PYTHON, + ProjectType.SPEC_DOCUMENT, + ProjectType.USER_MANUAL, + ): + files.append(("docs/readthedocs.yaml.j2", ".readthedocs.yaml")) + files.append(("docs/mkdocs.yml.j2", "mkdocs.yml")) + + # Release workflow template (#44) — gitflow + GitHub projects + if config.vcs_platform == "github" and config.branching_strategy == "gitflow": + files.append(("workflows/release.yml.j2", ".github/workflows/release.yml")) + return files diff --git a/src/specsmith/templates/docs/mkdocs.yml.j2 b/src/specsmith/templates/docs/mkdocs.yml.j2 new file mode 100644 index 0000000..3b4d9f3 --- /dev/null +++ b/src/specsmith/templates/docs/mkdocs.yml.j2 @@ -0,0 +1,34 @@ +site_name: {{ project.name }} +site_description: {{ project.description or project.name }} +repo_url: https://github.com/{{ project.name }} +edit_uri: edit/main/docs/ + +docs_dir: docs/site + +theme: + name: material + palette: + - scheme: default + primary: deep purple + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: deep purple + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.sections + - content.code.copy + +nav: + - Home: index.md + +markdown_extensions: + - tables + - admonition + - pymdownx.highlight + - pymdownx.superfences + - toc: + permalink: true diff --git a/src/specsmith/templates/docs/readthedocs.yaml.j2 b/src/specsmith/templates/docs/readthedocs.yaml.j2 new file mode 100644 index 0000000..ba53841 --- /dev/null +++ b/src/specsmith/templates/docs/readthedocs.yaml.j2 @@ -0,0 +1,22 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: +{% if project.language == 'python' %} + python: "3.12" +{% elif project.language in ('javascript', 'typescript') %} + nodejs: "20" +{% endif %} + +mkdocs: + configuration: mkdocs.yml + +{% if project.language == 'python' %} +python: + install: + - method: pip + path: . + extra_requirements: + - docs +{% endif %} diff --git a/src/specsmith/templates/go/go.mod.j2 b/src/specsmith/templates/go/go.mod.j2 new file mode 100644 index 0000000..42d7078 --- /dev/null +++ b/src/specsmith/templates/go/go.mod.j2 @@ -0,0 +1,3 @@ +module {{ package_name }} + +go 1.22 diff --git a/src/specsmith/templates/go/main.go.j2 b/src/specsmith/templates/go/main.go.j2 new file mode 100644 index 0000000..4dab349 --- /dev/null +++ b/src/specsmith/templates/go/main.go.j2 @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("{{ project.name }}") +} diff --git a/src/specsmith/templates/js/package.json.j2 b/src/specsmith/templates/js/package.json.j2 new file mode 100644 index 0000000..b37c60e --- /dev/null +++ b/src/specsmith/templates/js/package.json.j2 @@ -0,0 +1,48 @@ +{ + "name": "{{ package_name }}", + "version": "0.1.0", + "description": "{{ project.description or project.name }}", + "license": "{{ project.license }}", +{% if project.type.value == 'web-frontend' %} + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src/", + "test": "vitest" + }, + "dependencies": {}, + "devDependencies": { + "vite": "^6.0.0", + "eslint": "^9.0.0", + "vitest": "^3.0.0" + } +{% elif project.type.value == 'fullstack-js' %} + "scripts": { + "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", + "dev:server": "node server/src/index.js", + "dev:client": "vite", + "build": "vite build", + "lint": "eslint .", + "test": "vitest" + }, + "dependencies": { + "express": "^5.0.0" + }, + "devDependencies": { + "concurrently": "^9.0.0", + "eslint": "^9.0.0", + "vitest": "^3.0.0" + } +{% else %} + "scripts": { + "start": "node src/index.js", + "lint": "eslint src/", + "test": "vitest" + }, + "dependencies": {}, + "devDependencies": { + "eslint": "^9.0.0", + "vitest": "^3.0.0" + } +{% endif %} +} diff --git a/src/specsmith/templates/pyproject.toml.j2 b/src/specsmith/templates/python/pyproject.toml.j2 similarity index 100% rename from src/specsmith/templates/pyproject.toml.j2 rename to src/specsmith/templates/python/pyproject.toml.j2 diff --git a/src/specsmith/templates/rust/Cargo.toml.j2 b/src/specsmith/templates/rust/Cargo.toml.j2 new file mode 100644 index 0000000..fdef2f4 --- /dev/null +++ b/src/specsmith/templates/rust/Cargo.toml.j2 @@ -0,0 +1,19 @@ +[package] +name = "{{ package_name }}" +version = "0.1.0" +edition = "2021" +description = "{{ project.description or project.name }}" +license = "{{ project.license }}" + +[dependencies] +{% if project.type.value == 'cli-rust' %} +clap = { version = "4", features = ["derive"] } +{% endif %} + +[dev-dependencies] + +{% if project.type.value == 'cli-rust' %} +[[bin]] +name = "{{ package_name }}" +path = "src/main.rs" +{% endif %} diff --git a/src/specsmith/templates/rust/main.rs.j2 b/src/specsmith/templates/rust/main.rs.j2 new file mode 100644 index 0000000..b893b51 --- /dev/null +++ b/src/specsmith/templates/rust/main.rs.j2 @@ -0,0 +1,15 @@ +use clap::Parser; + +/// {{ project.description or project.name }} +#[derive(Parser, Debug)] +#[command(version, about)] +struct Args { + /// Name to greet + #[arg(short, long, default_value = "world")] + name: String, +} + +fn main() { + let args = Args::parse(); + println!("Hello, {}!", args.name); +} diff --git a/src/specsmith/templates/scripts/exec.cmd.j2 b/src/specsmith/templates/scripts/exec.cmd.j2 index 1d5e8bd..a7b46c6 100644 --- a/src/specsmith/templates/scripts/exec.cmd.j2 +++ b/src/specsmith/templates/scripts/exec.cmd.j2 @@ -1,7 +1,11 @@ @echo off REM {{ project.name }} — Command Execution Shim (Windows) -REM Wraps external commands with timeout enforcement, logging, and exit code capture. +REM Wraps external commands with PID tracking, timeout enforcement, and abort support. REM Usage: scripts\exec.cmd "" [timeout_seconds] +REM +REM PID files: .specsmith\pids\.json (for specsmith ps / specsmith abort) +REM Logs: .specsmith\logs\exec_.stdout/.stderr +REM Prefer: specsmith exec "" --timeout (Python-based, full tracking) setlocal enabledelayedexpansion @@ -10,24 +14,50 @@ set "TIMEOUT_SEC=%~2" if "%TIMEOUT_SEC%"=="" set "TIMEOUT_SEC=120" set "PROJECT_ROOT=%~dp0.." -set "LOG_DIR=%PROJECT_ROOT%\.work\logs" +set "PID_DIR=%PROJECT_ROOT%\.specsmith\pids" +set "LOG_DIR=%PROJECT_ROOT%\.specsmith\logs" +if not exist "%PID_DIR%" mkdir "%PID_DIR%" if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set "DT=%%I" set "TIMESTAMP=%DT:~0,4%-%DT:~4,2%-%DT:~6,2%_%DT:~8,2%-%DT:~10,2%-%DT:~12,2%" -set "LOG_FILE=%LOG_DIR%\exec_%TIMESTAMP%.log" +set "STDOUT_LOG=%LOG_DIR%\exec_%TIMESTAMP%.stdout" +set "STDERR_LOG=%LOG_DIR%\exec_%TIMESTAMP%.stderr" echo [exec] Command : %COMMAND% echo [exec] Timeout : %TIMEOUT_SEC%s -set "START_TIME=%TIME%" -start /b /wait cmd /c "%COMMAND%" > "%LOG_FILE%.stdout" 2> "%LOG_FILE%.stderr" -set "EXIT_CODE=%ERRORLEVEL%" +REM Launch command and capture PID +start /b cmd /c "%COMMAND%" > "%STDOUT_LOG%" 2> "%STDERR_LOG%" +for /f "tokens=2" %%P in ('tasklist /fi "imagename eq cmd.exe" /nh ^| findstr /i "cmd"') do ( + set "CMD_PID=%%P" +) +REM Write PID file for tracking +echo {"pid": %CMD_PID%, "command": "%COMMAND%", "timeout": %TIMEOUT_SEC%} > "%PID_DIR%\%CMD_PID%.json" + +REM Wait with timeout +set /a ELAPSED=0 +:wait_loop +tasklist /fi "PID eq %CMD_PID%" /nh 2>nul | findstr /i "%CMD_PID%" >nul +if errorlevel 1 goto :done +if %ELAPSED% geq %TIMEOUT_SEC% goto :timeout +timeout /t 1 /nobreak >nul +set /a ELAPSED+=1 +goto :wait_loop + +:timeout +echo [exec] TIMEOUT after %TIMEOUT_SEC%s — killing PID %CMD_PID% +taskkill /F /PID %CMD_PID% /T >nul 2>&1 +del "%PID_DIR%\%CMD_PID%.json" >nul 2>&1 +exit /b 124 + +:done +del "%PID_DIR%\%CMD_PID%.json" >nul 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" if %EXIT_CODE% equ 0 ( echo [exec] OK — exit code 0 ) else ( echo [exec] FAILED — exit code %EXIT_CODE% ) - exit /b %EXIT_CODE% diff --git a/src/specsmith/templates/scripts/exec.sh.j2 b/src/specsmith/templates/scripts/exec.sh.j2 index 02baf1f..2a3d7ab 100644 --- a/src/specsmith/templates/scripts/exec.sh.j2 +++ b/src/specsmith/templates/scripts/exec.sh.j2 @@ -1,6 +1,11 @@ #!/usr/bin/env bash # {{ project.name }} — Command Execution Shim (POSIX) +# Wraps external commands with PID tracking, timeout enforcement, and abort support. # Usage: ./scripts/exec.sh +# +# PID files: .specsmith/pids/.json (for specsmith ps / specsmith abort) +# Logs: .specsmith/logs/exec_.stdout/.stderr +# Prefer: specsmith exec "" --timeout (Python-based, full tracking) set -uo pipefail TIMEOUT_SECONDS="${1:?Usage: exec.sh }" @@ -8,30 +13,47 @@ shift COMMAND="$*" PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -LOG_DIR="$PROJECT_ROOT/.work/logs" -mkdir -p "$LOG_DIR" +PID_DIR="$PROJECT_ROOT/.specsmith/pids" +LOG_DIR="$PROJECT_ROOT/.specsmith/logs" +mkdir -p "$PID_DIR" "$LOG_DIR" + +TIMESTAMP=$(date -u +%Y%m%d_%H%M%S) +STDOUT_LOG="$LOG_DIR/exec_${TIMESTAMP}.stdout" +STDERR_LOG="$LOG_DIR/exec_${TIMESTAMP}.stderr" echo "[exec] Command : $COMMAND" echo "[exec] Timeout : ${TIMEOUT_SECONDS}s" -START_TIME=$(date +%s) -if command -v timeout &>/dev/null; then - timeout "$TIMEOUT_SECONDS" bash -c "$COMMAND" - EXIT_CODE=$? -else - bash -c "$COMMAND" & - PID=$! - ( sleep "$TIMEOUT_SECONDS" && kill -9 "$PID" 2>/dev/null ) & - WATCHDOG=$! - wait "$PID" 2>/dev/null - EXIT_CODE=$? - kill "$WATCHDOG" 2>/dev/null - wait "$WATCHDOG" 2>/dev/null -fi +# Launch in background, track PID +bash -c "$COMMAND" > "$STDOUT_LOG" 2> "$STDERR_LOG" & +CMD_PID=$! + +# Write PID file for specsmith ps/abort +cat > "$PID_DIR/${CMD_PID}.json" </dev/null && sleep 5 && kill -9 "$CMD_PID" 2>/dev/null ) & +WATCHDOG=$! + +START_TIME=$(date +%s) +wait "$CMD_PID" 2>/dev/null +EXIT_CODE=$? DURATION=$(( $(date +%s) - START_TIME )) -if [ "$EXIT_CODE" -eq 124 ] || [ "$EXIT_CODE" -eq 137 ]; then - echo "[exec] TIMEOUT after ${TIMEOUT_SECONDS}s" + +# Kill watchdog if command finished before timeout +kill "$WATCHDOG" 2>/dev/null +wait "$WATCHDOG" 2>/dev/null + +if [ "$EXIT_CODE" -eq 143 ] || [ "$EXIT_CODE" -eq 137 ]; then + echo "[exec] TIMEOUT after ${TIMEOUT_SECONDS}s (PID $CMD_PID killed)" exit 124 fi echo "[exec] $([ $EXIT_CODE -eq 0 ] && echo OK || echo FAILED) (${DURATION}s) — exit code $EXIT_CODE" diff --git a/src/specsmith/templates/workflows/release.yml.j2 b/src/specsmith/templates/workflows/release.yml.j2 new file mode 100644 index 0000000..47c3bd6 --- /dev/null +++ b/src/specsmith/templates/workflows/release.yml.j2 @@ -0,0 +1,101 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + test: + runs-on: ubuntu-latest + if: github.ref_type == 'tag' + steps: + - uses: actions/checkout@v6 +{% if project.language == 'python' %} + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - run: pip install -e ".[dev]" +{% for cmd in tools.lint %} + - run: {{ cmd }} +{% endfor %} +{% for cmd in tools.typecheck %} + - run: {{ cmd }} +{% endfor %} +{% for cmd in tools.test %} + - run: {{ cmd }} +{% endfor %} +{% elif project.language == 'rust' %} + - uses: dtolnay/rust-toolchain@stable + - run: cargo clippy -- -D warnings + - run: cargo test +{% elif project.language == 'go' %} + - uses: actions/setup-go@v5 + with: + go-version: stable + - run: golangci-lint run + - run: go test ./... +{% endif %} + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 +{% if project.language == 'python' %} + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - run: pip install build + - run: python -m build +{% elif project.language == 'rust' %} + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --release +{% elif project.language == 'go' %} + - uses: actions/setup-go@v5 + with: + go-version: stable + - run: go build -o dist/ ./... +{% endif %} + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + + github-release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - name: Create GitHub Release + env: + GH_TOKEN: {{ '${{ github.token }}' }} + run: | + gh release create "{{ '${{ github.ref_name }}' }}" dist/* \ + --title "{{ '${{ github.ref_name }}' }}" \ + --generate-notes +{% if project.language == 'python' %} + + pypi-publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 +{% endif %} diff --git a/src/specsmith/upgrader.py b/src/specsmith/upgrader.py index 7d78cc6..a8225d1 100644 --- a/src/specsmith/upgrader.py +++ b/src/specsmith/upgrader.py @@ -47,16 +47,42 @@ class UpgradeResult: ] +def _get_env_and_ctx( + config: ProjectConfig, +) -> tuple[Environment, dict[str, object]]: + """Create Jinja env and template context from config.""" + from specsmith.tools import get_tools + + env = Environment( + loader=PackageLoader("specsmith", "templates"), + autoescape=select_autoescape([]), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, + ) + ctx: dict[str, object] = { + "project": config, + "today": date.today().isoformat(), + "package_name": config.package_name, + "tools": get_tools(config), + } + return env, ctx + + def run_upgrade( root: Path, *, target_version: str | None = None, + full: bool = False, ) -> UpgradeResult: """Upgrade governance files to a newer spec version. Args: root: Project root directory. target_version: Target spec version. If None, uses the current specsmith version. + full: If True, also regenerate exec shims, agent integrations, CI configs, + and create missing community/RTD files. Safe: never overwrites + AGENTS.md, LEDGER.md, REQUIREMENTS.md, TEST_SPEC.md, or user docs. Returns: UpgradeResult with details of the operation. @@ -79,41 +105,22 @@ def run_upgrade( new_version = target_version or __version__ old_version = config.spec_version - if old_version == new_version: + # For --full, allow syncing even when version matches + if old_version == new_version and not full: return UpgradeResult(message=f"Already at spec version {new_version}. Nothing to upgrade.") - # Update config config.spec_version = new_version - - env = Environment( - loader=PackageLoader("specsmith", "templates"), - autoescape=select_autoescape([]), - keep_trailing_newline=True, - trim_blocks=True, - lstrip_blocks=True, - ) - - from specsmith.tools import get_tools - - ctx = { - "project": config, - "today": date.today().isoformat(), - "package_name": config.package_name, - "tools": get_tools(config), - } + env, ctx = _get_env_and_ctx(config) result = UpgradeResult() # Migrate legacy lowercase filenames to uppercase _migrate_legacy_filenames(root, result) + # Regenerate governance templates (always overwritten — they're spec-managed) for template_name, output_rel in _GOVERNANCE_TEMPLATES: output_path = root / output_rel - - if not output_path.exists(): - result.skipped_files.append(output_rel) - continue - + output_path.parent.mkdir(parents=True, exist_ok=True) tmpl = env.get_template(template_name) content = tmpl.render(**ctx) output_path.write_text(content, encoding="utf-8") @@ -133,6 +140,10 @@ def run_upgrade( save_budget(root, CreditBudget()) result.updated_files.append(".specsmith/credit-budget.json") + # Full sync: regenerate shims, CI, agent files, create missing community files + if full: + result.updated_files.extend(_sync_full(root, config, env, ctx)) + result.message = ( f"Upgraded from {old_version} to {new_version}. " f"{len(result.updated_files)} files updated, {len(result.skipped_files)} skipped." @@ -141,6 +152,103 @@ def run_upgrade( return result +# Files that are NEVER overwritten by --full sync (user-owned content) +_USER_OWNED: set[str] = { + "AGENTS.md", + "LEDGER.md", + "README.md", + "docs/REQUIREMENTS.md", + "docs/TEST_SPEC.md", + "docs/ARCHITECTURE.md", + "docs/WORKFLOW.md", +} + + +def _sync_full( + root: Path, + config: ProjectConfig, + env: Environment, + ctx: dict[str, object], +) -> list[str]: + """Full sync: regenerate infrastructure files, create missing community files. + + Safe rules: + - User-owned docs (AGENTS.md, LEDGER.md, etc.) are NEVER touched + - Exec shims are ALWAYS regenerated (they carry security/abort logic) + - CI configs are regenerated (tool-aware, reflects current specsmith version) + - Agent integrations are regenerated + - Community/RTD files are created only if missing + """ + synced: list[str] = [] + + from specsmith.scaffolder import _build_community_files + + # 1. Exec shims — always regenerate (carries PID tracking / abort fixes) + shim_templates = [ + ("scripts/exec.cmd.j2", "scripts/exec.cmd"), + ("scripts/exec.sh.j2", "scripts/exec.sh"), + ("scripts/setup.cmd.j2", "scripts/setup.cmd"), + ("scripts/setup.sh.j2", "scripts/setup.sh"), + ("scripts/run.cmd.j2", "scripts/run.cmd"), + ("scripts/run.sh.j2", "scripts/run.sh"), + ] + for tmpl_name, output_rel in shim_templates: + out = root / output_rel + out.parent.mkdir(parents=True, exist_ok=True) + tmpl = env.get_template(tmpl_name) + out.write_text(tmpl.render(**ctx), encoding="utf-8") + synced.append(output_rel) + + # 2. Agent integrations — regenerate + for integration_name in config.integrations: + if integration_name == "agents-md": + continue + try: + from specsmith.integrations import get_adapter + + adapter = get_adapter(integration_name) + files = adapter.generate(config, root) + for f in files: + synced.append(str(f.relative_to(root))) + except ValueError: + pass + + # 3. VCS CI configs — regenerate + if config.vcs_platform: + try: + from specsmith.vcs import get_platform + + platform = get_platform(config.vcs_platform) + files = platform.generate_all(config, root) + for f in files: + synced.append(str(f.relative_to(root))) + except ValueError: + pass + + # 4. Community files — create only if missing + for tmpl_name, output_rel in _build_community_files(config): + out = root / output_rel + if not out.exists(): + out.parent.mkdir(parents=True, exist_ok=True) + tmpl = env.get_template(tmpl_name) + out.write_text(tmpl.render(**ctx), encoding="utf-8") + synced.append(f"{output_rel} (created)") + + # 5. Config files — create only if missing (.editorconfig, .gitattributes) + config_templates = [ + ("editorconfig.j2", ".editorconfig"), + ("gitattributes.j2", ".gitattributes"), + ] + for tmpl_name, output_rel in config_templates: + out = root / output_rel + if not out.exists(): + tmpl = env.get_template(tmpl_name) + out.write_text(tmpl.render(**ctx), encoding="utf-8") + synced.append(f"{output_rel} (created)") + + return synced + + def _migrate_legacy_filenames(root: Path, result: UpgradeResult) -> None: """Rename legacy lowercase governance files to uppercase.