diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3547046 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Pytest + env: + PYTHONPATH: . + run: pytest -q + + extension: + runs-on: ubuntu-latest + defaults: + run: + working-directory: extension + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Install and compile + run: | + npm ci + npm run compile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..af42c6e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Install, compile, package + working-directory: extension + run: | + npm ci + npm run compile + npx --yes @vscode/vsce package + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: creer-vsix + path: extension/*.vsix + if-no-files-found: error + + github-release: + needs: [build] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Download VSIX artifact + uses: actions/download-artifact@v4 + with: + name: creer-vsix + path: dist + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/*.vsix + generate_release_notes: true + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish: + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Download VSIX artifact + uses: actions/download-artifact@v4 + with: + name: creer-vsix + path: dist + + - name: Publish (Marketplace / Open VSX when tokens present) + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + set -euo pipefail + cd dist + shopt -s nullglob + vsix=(*.vsix) + if [ ${#vsix[@]} -eq 0 ]; then + echo "No .vsix artifact found" + exit 1 + fi + echo "Packaged: ${vsix[*]}" + + published=false + if [ -n "${VSCE_PAT:-}" ]; then + echo "Publishing to VS Marketplace…" + npx --yes @vscode/vsce publish --packagePath "${vsix[0]}" -p "$VSCE_PAT" + published=true + fi + if [ -n "${OVSX_PAT:-}" ]; then + echo "Publishing to Open VSX…" + npx --yes ovsx publish "${vsix[0]}" -p "$OVSX_PAT" + published=true + fi + if [ "$published" = false ]; then + echo "No publish tokens configured — artifact only" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25ec95c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +*.log +.env +**/venv/ +**/__pycache__/ +**/node_modules/ +**/out/ +*.pyc +.pytest_cache/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..db6c97a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,26 @@ +# Creer — Final Plan + +## Done + +- **v0.1** — FastAPI planner/generator + VS Code write-to-workspace (+ optional git) +- **v0.2** — Preview, GitHub create/push, templates, overwrite protection, `/creer` chat +- **v0.3** — Streaming, offline/local models, bake-ins, SecretStorage + GIT_ASKPASS +- **v0.4** — Stream cancel, selectable license/CI bake-ins, quality gates +- **v0.5** — Content diff preview before write, multi-root workspace targeting, installable JSON/YAML template packs +- **v0.6** — Pack marketplace + remote URL install/delete, side-by-side conflict diffs, publish packaging +- **v0.7** — Self-hosted pack registry (`/registry` + download), extension icon, Browse Pack Registry, release changelog +- **v0.8** — Federated registry (`/registry/federated` + peers), Browse Federated Registry UI, GitHub Actions release + CI (artifact-first; signed publish when secrets exist) +- **v0.9** — Peer status/probe UX (`creer.registryPeers`, Manage Registry Peers), federated browse enrichment, GitHub Release on tag + `RELEASE.md` + +## Optional next + +- Human: configure `VSCE_PAT` / `OVSX_PAT` repository secrets; tag `v0.9.0` (see [`RELEASE.md`](RELEASE.md)) +- More peer discovery / registry auth + +## Non-goals + +- Multi-agent orchestration +- Memory graphs +- Overengineered plugin frameworks + +Stay power-focused: idea → plan → files → workspace. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0237c04 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Creer + +AI-powered repo scaffolding inside your workspace. + +**Current version: 0.9.0** + +## Architecture + +``` +creer/ +├── backend/ # Python FastAPI AI engine (+ packs/ + registry) +├── extension/ # VS Code extension (icon in media/) +└── .github/ # CI + release workflows +``` + +## Quick start + +```bash +# Backend +cd backend && python -m venv venv && source venv/bin/activate +pip install -r requirements.txt && cp .env.example .env +uvicorn main:app --reload --port 8000 + +# Extension +cd extension && npm install && npm run compile +# F5 → Creer: Create New Repo +``` + +## Registry & federation (v0.7–v0.9) + +Self-hosted pack catalog plus optional multi-host federation: + +| Method | Path | Description | +|---|---|---| +| `GET` | `/registry?q=&source=` | Searchable pack list | +| `GET` | `/registry/federated?q=&source=&peers=` | Local + peer merge (extra peers CSV) | +| `GET` | `/registry/peers` | Peer health + configured URLs | +| `POST` | `/registry/peers/probe` | Probe one peer `{ url }` | +| `GET` | `/registry/packs/{id}` | Pack metadata | +| `GET` | `/registry/packs/{id}/download` | Portable JSON pack (installable URL) | +| `GET` | `/marketplace` | Curated featured view | + +Extension settings: `creer.registryPeers` (comma-separated peer base URLs), `creer.showPeerStatus` (peer health in federated browse). + +Commands: **Creer: Browse Federated Registry**, **Creer: Manage Registry Peers**. + +Install from another Creer host: + +```bash +curl -X POST http://localhost:8000/packs/install \ + -H 'Content-Type: application/json' \ + -d '{"url":"http://other-host:8000/registry/packs/fastapi-crud/download"}' +``` + +Set `CREER_PUBLIC_BASE_URL` for absolute download links in registry responses. +Set `CREER_REGISTRY_PEERS` (comma-separated base URLs) for backend-configured federated discovery. +Use `creer.registryPeers` in the extension for client-side extra peers when browsing. + +## Extension commands + +| Command | Title | +|---|---| +| `creer.createRepo` | Create New Repo | +| `creer.createRepoFromChat` | Create from Chat Prompt | +| `creer.browseMarketplace` | Browse Pack Marketplace | +| `creer.browseRegistry` | Browse Pack Registry | +| `creer.browseFederatedRegistry` | Browse Federated Registry | +| `creer.manageRegistryPeers` | Manage Registry Peers | +| `creer.installPackFromUrl` | Install Pack from URL | +| `creer.setGitHubToken` / `clearGitHubToken` | SecretStorage token | + +## CI & publishing + +- **CI** (`.github/workflows/ci.yml`): pytest + extension compile on push/PR +- **Release** (`.github/workflows/release.yml`): tag `v*` → package `.vsix`, create GitHub Release with attachment; publish to Marketplace / Open VSX only when `VSCE_PAT` / `OVSX_PAT` secrets are set + +See [`RELEASE.md`](RELEASE.md) and [`extension/PUBLISH.md`](extension/PUBLISH.md). Package locally: + +```bash +cd extension && npm run compile && npm run package +# → creer-0.9.0.vsix (includes media/icon.png) +``` + +Signed Marketplace / Open VSX publish requires your own `VSCE_PAT` / `OVSX_PAT` (never commit tokens). + +## License + +MIT diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..fce98f3 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,56 @@ +# Releasing Creer v0.9.0 + +Exact steps for a human maintainer to cut a tagged release with GitHub Release + optional Marketplace / Open VSX publish. + +## 1. Set repository secrets (once) + +In GitHub → **Settings → Secrets and variables → Actions**, add: + +| Secret | Purpose | +|---|---| +| `VSCE_PAT` | Azure DevOps PAT with Marketplace **Acquire** + **Publish** (publisher must match `extension/package.json` → `publisher`) | +| `OVSX_PAT` | Open VSX access token from [open-vsx.org](https://open-vsx.org/) | + +Both are optional. If neither is set, the release workflow still builds the `.vsix`, uploads it as an artifact, and (on tag pushes) creates a **GitHub Release** with the `.vsix` attached. Marketplace / Open VSX publish is skipped with `No publish tokens configured — artifact only`. + +Never commit PATs. Prefer repo secrets over exporting tokens in shared shells. + +## 2. Bump & verify locally + +```bash +# Confirm extension version is 0.9.0 +grep '"version"' extension/package.json + +cd extension +npm ci +npm run compile +npm run package +# → creer-0.9.0.vsix +``` + +Smoke-test: `code --install-extension creer-0.9.0.vsix` (or Cursor equivalent) against a running backend. + +## 3. Tag v0.9.0 and push + +From a clean `main` (or the release commit): + +```bash +git tag -a v0.9.0 -m "Creer v0.9.0" +git push origin v0.9.0 +``` + +Tag pattern `v*` triggers [`.github/workflows/release.yml`](.github/workflows/release.yml). + +## 4. What the workflow does + +1. **build** — `npm ci` → `compile` → `vsce package` → upload `creer-vsix` artifact +2. **github-release** (tag pushes only) — create a GitHub Release and attach the `.vsix` (`contents: write`) +3. **publish** — if `VSCE_PAT` / `OVSX_PAT` secrets exist, publish to Marketplace / Open VSX; otherwise artifact-only + +You can also run the workflow via **Actions → Release → Run workflow** (`workflow_dispatch`) for a package/artifact without a tag (no GitHub Release job in that case). + +## 5. After release + +- Confirm the GitHub Release page lists `creer-0.9.0.vsix` +- If secrets were set, confirm Marketplace / Open VSX listing updated to 0.9.0 +- See [`extension/PUBLISH.md`](extension/PUBLISH.md) for manual `vsce` / `ovsx` publish from a laptop diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..999649a --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,27 @@ +# OpenAI API key (required unless CREER_OFFLINE=1 or using a local server that ignores it) +OPENAI_API_KEY=sk-your-key-here + +# Optional OpenAI-compatible base URL for local/offline backends (Ollama, LM Studio, vLLM, etc.) +# Example: http://127.0.0.1:11434/v1 +OPENAI_BASE_URL= + +# Model name (OpenAI or local-compatible) +CREER_MODEL=gpt-4o-mini + +# Template-only / stub generation — never calls the LLM +# Set to 1, true, or yes to enable +CREER_OFFLINE= + +# Optional extra packs directory (JSON/YAML). Merged with backend/packs; +# user packs override built-in packs on the same id. +# When set, remote installs (POST /packs/install) write here. +# When unset, installs go to backend/packs/installed/. +# CREER_PACKS_DIR=/path/to/my-packs + +# Optional public base URL for absolute registry download links +# Example: http://localhost:8000 or https://creer.example.com +# CREER_PUBLIC_BASE_URL=http://localhost:8000 + +# Comma-separated peer Creer registry base URLs (federated discovery, v0.8) +# Example: http://127.0.0.1:8001,https://creer-packs.example.com +# CREER_REGISTRY_PEERS= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..491ffd0 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,8 @@ +.env +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +packs/installed/* +!packs/installed/.gitkeep diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..1c9c4d8 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Creer backend package.""" diff --git a/backend/app/bakeins.py b/backend/app/bakeins.py new file mode 100644 index 0000000..c4e1ae9 --- /dev/null +++ b/backend/app/bakeins.py @@ -0,0 +1,245 @@ +"""Open-source bake-ins merged into scaffolds (LICENSE, README, CI).""" + +from __future__ import annotations + +from typing import Any, Literal + +LicenseId = Literal["mit", "apache-2.0", "none"] +CiId = Literal["auto", "python", "node", "none"] + + +def list_bakein_options() -> dict[str, list[dict[str, str]]]: + return { + "licenses": [ + {"id": "mit", "name": "MIT"}, + {"id": "apache-2.0", "name": "Apache 2.0"}, + {"id": "none", "name": "No license"}, + ], + "ci": [ + {"id": "auto", "name": "Auto-detect"}, + {"id": "python", "name": "Python"}, + {"id": "node", "name": "Node"}, + {"id": "none", "name": "No CI"}, + ], + } + + +def _normalize_options(options: dict[str, Any] | None) -> dict[str, Any]: + opts = options or {} + license_id = opts.get("license") or "mit" + ci_id = opts.get("ci") or "auto" + include_readme = opts.get("include_readme") + if include_readme is None: + include_readme = True + if license_id not in ("mit", "apache-2.0", "none"): + license_id = "mit" + if ci_id not in ("auto", "python", "node", "none"): + ci_id = "auto" + return { + "license": license_id, + "ci": ci_id, + "include_readme": bool(include_readme), + } + + +def _mit_license(project_name: str) -> str: + holder = project_name.strip() if project_name and project_name.strip() else "Creer Scaffold" + return f"""MIT License + +Copyright (c) 2026 {holder} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + + +def _apache_license(project_name: str) -> str: + holder = project_name.strip() if project_name and project_name.strip() else "Creer Scaffold" + return f"""Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Copyright 2026 {holder} + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +def _default_readme(plan: dict) -> str: + name = plan.get("project_name") or "project" + stack = plan.get("stack") or "" + desc = plan.get("description") or f"Scaffolded by Creer for {name}." + lines = [ + f"# {name}", + "", + desc, + "", + ] + if stack: + lines.extend([f"**Stack:** {stack}", ""]) + lines.extend( + [ + "## Getting started", + "", + "This project was generated with Creer.", + "Install dependencies and follow stack-specific docs in this repo.", + "", + ] + ) + return "\n".join(lines) + + +def _ci_python() -> str: + return """name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f pyproject.toml ]; then pip install -e ".[dev]" || pip install -e .; fi + pip install pytest + - name: Run tests + run: pytest -q || echo "No tests yet — scaffold CI ok" +""" + + +def _ci_node() -> str: + return """name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - name: Install + run: npm ci || npm install + - name: Test + run: npm test --if-present +""" + + +def _ci_generic() -> str: + return """name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + scaffold: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Scaffold CI + run: echo "Creer scaffold CI — add stack-specific checks as the project grows" +""" + + +def _ci_workflow(plan: dict, ci_id: str) -> str | None: + if ci_id == "none": + return None + if ci_id == "python": + return _ci_python() + if ci_id == "node": + return _ci_node() + + # auto + stack = (plan.get("stack") or "").lower() + paths = " ".join(plan.get("files") or []).lower() + blob = f"{stack} {paths}" + + is_python = any( + tok in blob + for tok in ("python", "fastapi", "uvicorn", "pytest", "requirements.txt", "pyproject.toml") + ) + is_node = any( + tok in blob for tok in ("node", "express", "next", "npm", "package.json", "react") + ) + + if is_python: + return _ci_python() + if is_node: + return _ci_node() + return _ci_generic() + + +def apply_bakeins( + plan: dict, + files: dict[str, str], + options: dict[str, Any] | None = None, +) -> dict[str, str]: + """ + Merge open-source bake-ins into generated files. + + - LICENSE per options.license (mit | apache-2.0 | none); never overwrite existing + - README.md created only if missing and include_readme + - .github/workflows/ci.yml per options.ci when missing + """ + opts = _normalize_options(options) + out = dict(files) + project_name = plan.get("project_name") or "Creer Scaffold" + + if opts["license"] != "none" and "LICENSE" not in out: + if opts["license"] == "apache-2.0": + out["LICENSE"] = _apache_license(project_name) + else: + out["LICENSE"] = _mit_license(project_name) + + if opts["include_readme"] and "README.md" not in out: + out["README.md"] = _default_readme(plan) + + ci_path = ".github/workflows/ci.yml" + if ci_path not in out: + ci_body = _ci_workflow(plan, opts["ci"]) + if ci_body is not None: + out[ci_path] = ci_body + + return out diff --git a/backend/app/federation.py b/backend/app/federation.py new file mode 100644 index 0000000..2ae88ef --- /dev/null +++ b/backend/app/federation.py @@ -0,0 +1,325 @@ +"""Federated multi-host registry discovery — query peer Creer registries and merge.""" + +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any +from urllib.parse import urljoin, urlparse + +import httpx + +from config import CREER_REGISTRY_PEERS +from app.registry import list_registry + +FEDERATION_VERSION = "0.9.0" +_MAX_PEERS = 8 + + +def parse_peers(raw: str | None = None) -> list[str]: + """ + Normalize peer base URLs from CREER_REGISTRY_PEERS (or raw override). + + Strips whitespace/trailing slash, dedupes (order preserved), keeps http/https only. + """ + text = CREER_REGISTRY_PEERS if raw is None else raw + if not text: + return [] + + seen: set[str] = set() + out: list[str] = [] + for part in text.split(","): + url = part.strip().rstrip("/") + if not url: + continue + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + continue + if not parsed.netloc: + continue + if url in seen: + continue + seen.add(url) + out.append(url) + return out + + +def resolve_peers(extra_peers: list[str] | None = None) -> list[str]: + """ + Merge configured peers with optional ad-hoc extras. + + Dedupes (configured first), http/https only, capped at _MAX_PEERS. + """ + configured = parse_peers() + extras: list[str] = [] + if extra_peers: + # Normalize each entry (allow raw URLs or comma-joined strings) + for raw in extra_peers: + if not raw: + continue + extras.extend(parse_peers(raw)) + + seen: set[str] = set() + out: list[str] = [] + for url in configured + extras: + if url in seen: + continue + seen.add(url) + out.append(url) + if len(out) >= _MAX_PEERS: + break + return out + + +def _absolutize_url(base_url: str, value: Any) -> Any: + if not isinstance(value, str) or not value: + return value + if value.startswith("http://") or value.startswith("https://"): + return value + # Relative path → absolute against peer base + return urljoin(base_url.rstrip("/") + "/", value.lstrip("/")) + + +def _tag_peer_items(base_url: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]: + tagged: list[dict[str, Any]] = [] + for raw in items: + if not isinstance(raw, dict): + continue + item = dict(raw) + item["peer"] = base_url + if "download_url" in item: + item["download_url"] = _absolutize_url(base_url, item.get("download_url")) + if "install_url" in item: + item["install_url"] = _absolutize_url(base_url, item.get("install_url")) + if "url" in item and isinstance(item.get("url"), str): + item["url"] = _absolutize_url(base_url, item["url"]) + tagged.append(item) + return tagged + + +def fetch_peer_registry( + base_url: str, + *, + q: str | None = None, + source: str | None = None, + timeout: float = 8.0, +) -> list[dict[str, Any]]: + """ + GET {base}/registry and return tagged items. + + On any failure returns [] (never raises for federation callers). + """ + items, _err = _fetch_peer_registry(base_url, q=q, source=source, timeout=timeout) + return items + + +def _fetch_peer_registry( + base_url: str, + *, + q: str | None = None, + source: str | None = None, + timeout: float = 8.0, +) -> tuple[list[dict[str, Any]], str | None]: + base = (base_url or "").strip().rstrip("/") + if not base: + return [], "empty base_url" + + params: dict[str, str] = {} + if q: + params["q"] = q + if source: + params["source"] = source + + url = f"{base}/registry" + try: + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + resp = client.get(url, params=params or None) + resp.raise_for_status() + data = resp.json() + except Exception as exc: # noqa: BLE001 — federation must never crash + return [], str(exc) + + if isinstance(data, dict): + raw_items = data.get("items") or [] + elif isinstance(data, list): + raw_items = data + else: + return [], "unexpected registry response shape" + + if not isinstance(raw_items, list): + return [], "unexpected registry items shape" + + return _tag_peer_items(base, raw_items), None + + +def probe_peer(base_url: str, timeout: float = 5.0) -> dict[str, Any]: + """ + Probe a peer host via /health (preferred) and/or /registry for pack count. + + Never raises — returns a status dict with ok/latency/count/version/error. + """ + base = (base_url or "").strip().rstrip("/") + result: dict[str, Any] = { + "base_url": base, + "ok": False, + "latency_ms": None, + "count": None, + "version": None, + "error": None, + } + if not base: + result["error"] = "empty base_url" + return result + + parsed = urlparse(base) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + result["error"] = "url must use http or https with a host" + return result + + started = time.perf_counter() + version: str | None = None + count: int | None = None + health_ok = False + last_error: str | None = None + + try: + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + # Prefer /health for liveness + version (+ registry_count when present) + try: + hresp = client.get(f"{base}/health") + hresp.raise_for_status() + hdata = hresp.json() + health_ok = True + if isinstance(hdata, dict): + ver = hdata.get("version") + if isinstance(ver, str): + version = ver + if "registry_count" in hdata and hdata["registry_count"] is not None: + try: + count = int(hdata["registry_count"]) + except (TypeError, ValueError): + pass + except Exception as exc: # noqa: BLE001 + last_error = str(exc) + + # Use /registry for count (and version fallback) when needed + if count is None or version is None: + try: + rresp = client.get(f"{base}/registry") + rresp.raise_for_status() + rdata = rresp.json() + if isinstance(rdata, dict): + if version is None: + ver = rdata.get("version") + if isinstance(ver, str): + version = ver + if count is None: + raw_items = rdata.get("items") or [] + count = len(raw_items) if isinstance(raw_items, list) else 0 + elif isinstance(rdata, list) and count is None: + count = len(rdata) + except Exception as exc: # noqa: BLE001 + if not health_ok: + last_error = str(exc) + elif last_error is None: + last_error = str(exc) + + latency_ms = round((time.perf_counter() - started) * 1000, 1) + result["latency_ms"] = latency_ms + + if health_ok or count is not None: + result["ok"] = True + result["count"] = count if count is not None else 0 + result["version"] = version + result["error"] = None + return result + + result["error"] = last_error or "unreachable" + return result + except Exception as exc: # noqa: BLE001 + result["latency_ms"] = round((time.perf_counter() - started) * 1000, 1) + result["error"] = str(exc) + return result + + +def list_peer_status() -> list[dict[str, Any]]: + """Probe all configured peers concurrently; preserve configured order.""" + peers = parse_peers()[:_MAX_PEERS] + if not peers: + return [] + + by_url: dict[str, dict[str, Any]] = {} + workers = min(8, len(peers)) + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = {pool.submit(probe_peer, p): p for p in peers} + for fut in as_completed(futures): + peer = futures[fut] + by_url[peer] = fut.result() + return [by_url[p] for p in peers] + + +def list_federated( + *, + q: str | None = None, + source: str | None = None, + include_local: bool = True, + extra_peers: list[str] | None = None, +) -> dict[str, Any]: + """Merge local registry with peer registries (local ids win on collision).""" + local = list_registry(q=q, source=source or "all") if include_local else { + "version": FEDERATION_VERSION, + "base_url": None, + "items": [], + } + + peers = resolve_peers(extra_peers) + peer_meta: list[dict[str, Any]] = [] + peer_items_by_url: dict[str, list[dict[str, Any]]] = {} + + def _one(peer: str) -> tuple[str, list[dict[str, Any]], str | None]: + items, err = _fetch_peer_registry(peer, q=q, source=source) + return peer, items, err + + if peers: + workers = min(8, len(peers)) + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = {pool.submit(_one, p): p for p in peers} + for fut in as_completed(futures): + peer, items, err = fut.result() + peer_items_by_url[peer] = items + peer_meta.append( + { + "base_url": peer, + "ok": err is None, + "count": len(items) if err is None else 0, + "error": err, + } + ) + # Stable peer order matching resolve_peers() + order = {p: i for i, p in enumerate(peers)} + peer_meta.sort(key=lambda m: order.get(m["base_url"], 0)) + + merged: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + + for item in local.get("items") or []: + pid = item.get("id") + if isinstance(pid, str) and pid: + seen_ids.add(pid) + merged.append(item) + + for peer in peers: + for item in peer_items_by_url.get(peer, []): + pid = item.get("id") + if isinstance(pid, str) and pid in seen_ids: + continue + if isinstance(pid, str) and pid: + seen_ids.add(pid) + merged.append(item) + + return { + "version": FEDERATION_VERSION, + "local": local, + "peers": peer_meta, + "items": merged, + } diff --git a/backend/app/generator.py b/backend/app/generator.py new file mode 100644 index 0000000..7dc8c59 --- /dev/null +++ b/backend/app/generator.py @@ -0,0 +1,285 @@ +"""File content generation — LLM-driven or offline stubs.""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Iterator +from typing import Any + +from config import CREER_OFFLINE, MODEL, OPENAI_API_KEY, OPENAI_BASE_URL +from app.llm import _get_client + + +def _use_offline(plan: dict) -> bool: + """True when CREER_OFFLINE, or when no LLM endpoint is configured and a plan exists.""" + if CREER_OFFLINE: + return True + # Local OpenAI-compatible servers (Ollama, etc.) may omit a real API key. + if OPENAI_BASE_URL: + return False + if not OPENAI_API_KEY and plan.get("files"): + return True + return False + + +def _stub_content(file_path: str, plan: dict) -> str: + """Deterministic stub content for offline / template-only generation.""" + name = plan.get("project_name") or "project" + stack = plan.get("stack") or "" + desc = plan.get("description") or f"Scaffolded project: {name}." + base = file_path.rsplit("/", 1)[-1] + base_lower = base.lower() + ext = base_lower.rsplit(".", 1)[-1] if "." in base_lower else "" + + if base_lower == "readme.md": + lines = [f"# {name}", "", desc, ""] + if stack: + lines.extend([f"**Stack:** {stack}", ""]) + lines.extend( + [ + "## Status", + "", + "Generated in offline / template-only mode. Replace stubs with real implementation.", + "", + ] + ) + return "\n".join(lines) + + if base_lower == "requirements.txt": + if "fastapi" in stack.lower() or "uvicorn" in stack.lower(): + return "fastapi>=0.115.0\nuvicorn[standard]>=0.32.0\npython-dotenv>=1.0.0\n" + return "# TODO: add dependencies\n" + + if base_lower == "package.json": + pkg = { + "name": name, + "version": "0.1.0", + "private": True, + "scripts": { + "start": "node src/index.js", + "test": "echo \"No tests yet\" && exit 0", + }, + "dependencies": {}, + } + if "express" in stack.lower(): + pkg["dependencies"]["express"] = "^4.21.0" + pkg["scripts"]["start"] = "node src/index.js" + if "next" in stack.lower(): + pkg["dependencies"]["next"] = "^14.2.0" + pkg["dependencies"]["react"] = "^18.3.0" + pkg["dependencies"]["react-dom"] = "^18.3.0" + pkg["scripts"] = { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "echo \"No tests yet\" && exit 0", + } + return json.dumps(pkg, indent=2) + "\n" + + if base_lower == "pyproject.toml": + return f"""[project] +name = "{name}" +version = "0.1.0" +description = "{desc.replace(chr(34), "'")}" +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] +{name} = "src.cli:main" +""" + + if base_lower in (".gitignore",): + return ( + "__pycache__/\n*.py[cod]\n.venv/\nvenv/\n.env\n" + "node_modules/\ndist/\nbuild/\n.DS_Store\n" + ) + + if base_lower in (".env.example",): + return "# Example environment variables\n# KEY=value\n" + + if base_lower == "tsconfig.json": + return json.dumps( + { + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": True, + "skipLibCheck": True, + "strict": True, + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "noEmit": True, + "incremental": True, + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + }, + indent=2, + ) + "\n" + + if base_lower == "next.config.js": + return "/** @type {import('next').NextConfig} */\nconst nextConfig = {};\nmodule.exports = nextConfig;\n" + + if ext in ("py",): + if base_lower == "main.py" and "fastapi" in stack.lower(): + return ( + '"""Application entrypoint."""\n\n' + "from fastapi import FastAPI\n\n" + f'app = FastAPI(title="{name}")\n\n\n' + '@app.get("/health")\n' + "def health():\n" + ' return {"status": "ok"}\n\n\n' + "# TODO: implement\n" + ) + return f'"""{file_path}"""\n\n# TODO: implement\n' + + if ext in ("js", "mjs", "cjs"): + return f"// {file_path}\n// TODO: implement\n" + + if ext in ("ts", "tsx"): + return f"// {file_path}\n// TODO: implement\n" + + if ext == "css": + return f"/* {file_path} */\n/* TODO: implement */\n" + + if ext in ("html", "htm"): + return ( + "\n" + '\n' + "\n" + ' \n' + f" {name}\n" + ' \n' + "\n" + "\n" + f"

{name}

\n" + " \n" + ' \n' + "\n" + "\n" + ) + + if ext in ("yml", "yaml"): + return f"# {file_path}\n# TODO: implement\n" + + if ext == "json": + return "{}\n" + + if ext == "toml": + return f"# {file_path}\n# TODO: implement\n" + + # Default: comment-style stub + return f"# TODO: implement ({file_path})\n" + + +def _strip_fences(content: str) -> str: + if content.startswith("```"): + lines = content.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + content = "\n".join(lines) + return content + + +def _generate_one_llm(file_path: str, plan: dict) -> str: + prompt = f""" +Generate the full content for file: {file_path} + +Project Stack: {plan.get("stack", "")} +Project Name: {plan.get("project_name", "")} + +Follow best practices. +Return ONLY the file content — no markdown fences, no explanation. +""" + response = _get_client().chat.completions.create( + model=MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0.3, + ) + content = response.choices[0].message.content or "" + return _strip_fences(content) + + +def generate_files_iter( + plan: dict, + should_cancel: Callable[[], bool] | None = None, +) -> Iterator[tuple[dict[str, Any], dict[str, str]]]: + """ + Yield (event_dict, partial_files) progress while generating. + + Events: + - file / generating + - file / done (with bytes) + - cancelled (when should_cancel returns True) + Does not yield start/done/error — caller owns those. + """ + files_output: dict[str, str] = {} + file_list = list(plan["files"]) + total = len(file_list) + offline = _use_offline(plan) + + for index, file_path in enumerate(file_list, start=1): + if should_cancel and should_cancel(): + yield ( + { + "event": "cancelled", + "detail": "Cancelled by user", + }, + dict(files_output), + ) + return + + yield ( + { + "event": "file", + "index": index, + "total": total, + "path": file_path, + "status": "generating", + }, + dict(files_output), + ) + + if should_cancel and should_cancel(): + yield ( + { + "event": "cancelled", + "detail": "Cancelled by user", + }, + dict(files_output), + ) + return + + if offline: + content = _stub_content(file_path, plan) + else: + content = _generate_one_llm(file_path, plan) + + files_output[file_path] = content + yield ( + { + "event": "file", + "index": index, + "total": total, + "path": file_path, + "status": "done", + "bytes": len(content.encode("utf-8")), + }, + dict(files_output), + ) + + +def generate_files( + plan: dict, + should_cancel: Callable[[], bool] | None = None, +) -> dict[str, str]: + """Generate file contents one-by-one from a project plan.""" + files_output: dict[str, str] = {} + for event, partial in generate_files_iter(plan, should_cancel=should_cancel): + files_output = partial + if event.get("event") == "cancelled": + raise ValueError(event.get("detail") or "Cancelled by user") + return files_output diff --git a/backend/app/github.py b/backend/app/github.py new file mode 100644 index 0000000..0dee3b2 --- /dev/null +++ b/backend/app/github.py @@ -0,0 +1,63 @@ +"""Optional GitHub REST helper for creating repositories.""" + +from __future__ import annotations + +import httpx + + +def create_github_repo( + token: str, + name: str, + private: bool = True, + description: str = "", +) -> dict: + """ + Create a GitHub repository for the authenticated user. + + Returns dict with html_url, clone_url, and full_name. + Raises ValueError on auth/API failures. + """ + if not token or not isinstance(token, str): + raise ValueError("GitHub token is required") + if not name or not isinstance(name, str): + raise ValueError("Repository name is required") + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "Creer/0.2", + } + payload = { + "name": name, + "private": private, + "description": description or "", + "auto_init": False, + } + + try: + with httpx.Client(timeout=30.0) as client: + resp = client.post( + "https://api.github.com/user/repos", + headers=headers, + json=payload, + ) + except httpx.HTTPError as exc: + raise ValueError(f"GitHub request failed: {exc}") from exc + + if resp.status_code == 401: + raise ValueError("GitHub authentication failed — check your token") + if resp.status_code == 403: + raise ValueError("GitHub forbidden — token may lack repo scope") + if resp.status_code == 422: + detail = resp.json().get("message", resp.text) if resp.headers.get("content-type", "").startswith("application/json") else resp.text + raise ValueError(f"GitHub rejected repo creation: {detail}") + if resp.status_code >= 400: + raise ValueError(f"GitHub API error ({resp.status_code}): {resp.text[:300]}") + + data = resp.json() + return { + "html_url": data.get("html_url", ""), + "clone_url": data.get("clone_url", ""), + "full_name": data.get("full_name", ""), + } diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..07c569c --- /dev/null +++ b/backend/app/jobs.py @@ -0,0 +1,76 @@ +"""In-memory generation job registry for stream cancellation.""" + +from __future__ import annotations + +import threading +import time +import uuid +from dataclasses import dataclass, field + +# Jobs older than this are eligible for cleanup. +_TTL_SECONDS = 30 * 60 + + +@dataclass +class _Job: + cancelled: bool = False + created_at: float = field(default_factory=time.time) + + +_lock = threading.Lock() +_jobs: dict[str, _Job] = {} + + +def _purge_expired(now: float | None = None) -> None: + now = now if now is not None else time.time() + expired = [jid for jid, job in _jobs.items() if now - job.created_at > _TTL_SECONDS] + for jid in expired: + del _jobs[jid] + + +def create_job(job_id: str | None = None) -> str: + """Register a new job (or ensure an existing id) and return its id. + + If the id was already cancelled (cancel-before-start), keep it cancelled. + """ + jid = (job_id or "").strip() or uuid.uuid4().hex + with _lock: + _purge_expired() + existing = _jobs.get(jid) + if existing is not None: + # Refresh TTL but preserve cancelled flag. + existing.created_at = time.time() + return jid + _jobs[jid] = _Job() + return jid + + +def is_cancelled(job_id: str | None) -> bool: + if not job_id: + return False + with _lock: + job = _jobs.get(job_id) + return bool(job and job.cancelled) + + +def cancel_job(job_id: str) -> bool: + """Mark a job cancelled. Returns True if the job existed (or was created as cancelled).""" + jid = (job_id or "").strip() + if not jid: + return False + with _lock: + _purge_expired() + job = _jobs.get(jid) + if job is None: + # Allow cancel-before-start races: register already-cancelled. + _jobs[jid] = _Job(cancelled=True) + return True + job.cancelled = True + return True + + +def finish_job(job_id: str | None) -> None: + if not job_id: + return + with _lock: + _jobs.pop(job_id, None) diff --git a/backend/app/llm.py b/backend/app/llm.py new file mode 100644 index 0000000..e12a328 --- /dev/null +++ b/backend/app/llm.py @@ -0,0 +1,30 @@ +"""Shared OpenAI-compatible client for Creer.""" + +from __future__ import annotations + +from openai import OpenAI + +from config import OPENAI_API_KEY, OPENAI_BASE_URL + +_client: OpenAI | None = None + + +def _get_client() -> OpenAI: + """Lazy OpenAI client. Passes base_url when OPENAI_BASE_URL is set (Ollama, etc.).""" + global _client + if _client is None: + if not OPENAI_API_KEY and not OPENAI_BASE_URL: + raise ValueError( + "OPENAI_API_KEY is not set (or set OPENAI_BASE_URL for a local OpenAI-compatible server)" + ) + kwargs: dict = {"api_key": OPENAI_API_KEY or "not-needed"} + if OPENAI_BASE_URL: + kwargs["base_url"] = OPENAI_BASE_URL + _client = OpenAI(**kwargs) + return _client + + +def reset_client() -> None: + """Clear the cached client (useful in tests).""" + global _client + _client = None diff --git a/backend/app/packs.py b/backend/app/packs.py new file mode 100644 index 0000000..0285492 --- /dev/null +++ b/backend/app/packs.py @@ -0,0 +1,491 @@ +"""Installable template packs (JSON/YAML) for Creer v0.6 — including remote install.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from urllib.parse import urlparse + +import httpx +import yaml + +from app.templates import slugify +from app.validator import _reject_unsafe_path + +# backend/packs — resolved relative to backend root (parent of app/) +_BACKEND_ROOT = Path(__file__).resolve().parent.parent +PACKS_DIR = _BACKEND_ROOT / "packs" +INSTALLED_DIR = PACKS_DIR / "installed" + +_PACK_FILE_SUFFIXES = (".json", ".yaml", ".yml") +_SAFE_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$") + +MAX_DOWNLOAD_BYTES = 1_000_000 +FETCH_TIMEOUT_SECONDS = 30.0 + + +class PackConflictError(Exception): + """Raised when installing a pack id that already exists and overwrite is false.""" + + +class PackNotInstalledError(Exception): + """Raised when uninstall targets a pack that is not in the writable install dir.""" + + +def _extra_packs_dir() -> Path | None: + """Optional user packs directory via CREER_PACKS_DIR.""" + raw = os.getenv("CREER_PACKS_DIR", "").strip() + if not raw: + return None + return Path(raw).expanduser().resolve() + + +def writable_packs_dir() -> Path: + """ + Directory where remotely installed packs are written. + + Prefer CREER_PACKS_DIR when set; otherwise backend/packs/installed/. + """ + extra = _extra_packs_dir() + if extra is not None: + return extra + return INSTALLED_DIR + + +def validate_pack_dict(data: dict, *, source: str = "") -> dict: + """ + Validate a pack mapping and return a normalized dict. + + Required: id, name, non-empty files list with safe relative paths. + Optional: description, stack, version. + """ + if not isinstance(data, dict): + raise ValueError(f"Pack must be a mapping/object: {source}") + + pack_id = data.get("id") + name = data.get("name") + files = data.get("files") + + if not isinstance(pack_id, str) or not pack_id.strip(): + raise ValueError(f"Pack missing valid id: {source}") + pack_id = pack_id.strip() + if not _SAFE_ID.match(pack_id): + raise ValueError(f"Invalid pack id {pack_id!r} in {source}") + + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"Pack missing valid name: {source}") + + if not isinstance(files, list) or not files: + raise ValueError(f"Pack must include a non-empty files list: {source}") + + validated_files: list[str] = [] + seen: set[str] = set() + for entry in files: + if not isinstance(entry, str) or not entry.strip(): + raise ValueError(f"Invalid file path in pack {source}: {entry!r}") + fpath = entry.strip().replace("\\", "/") + _reject_unsafe_path(fpath, label="pack file path") + if fpath in seen: + raise ValueError(f"Duplicate file path in pack {source}: {fpath!r}") + seen.add(fpath) + validated_files.append(fpath) + + version = data.get("version", "1.0.0") + if version is not None and not isinstance(version, str): + raise ValueError(f"Pack version must be a string: {source}") + + stack = data.get("stack", "") + if stack is not None and not isinstance(stack, str): + raise ValueError(f"Pack stack must be a string: {source}") + + description = data.get("description", "") + if description is not None and not isinstance(description, str): + raise ValueError(f"Pack description must be a string: {source}") + + return { + "id": pack_id, + "name": name.strip(), + "description": (description or "").strip(), + "stack": (stack or "").strip(), + "version": (version or "1.0.0").strip(), + "files": validated_files, + } + + +def _detect_pack_format( + hint_filename: str | None = None, + content_type: str | None = None, + text: str | None = None, +) -> str: + """Return 'json' or 'yaml' based on filename, Content-Type, and/or body sniff.""" + if hint_filename: + lower = hint_filename.lower().split("?", 1)[0] + if lower.endswith(".json"): + return "json" + if lower.endswith(".yaml") or lower.endswith(".yml"): + return "yaml" + + if content_type: + ct = content_type.split(";", 1)[0].strip().lower() + if ct in ("application/json", "text/json"): + return "json" + if ct in ( + "application/yaml", + "application/x-yaml", + "text/yaml", + "text/x-yaml", + ): + return "yaml" + + if text is not None: + stripped = text.lstrip() + if stripped.startswith("{") or stripped.startswith("["): + return "json" + + # Default: try JSON first via caller; prefer yaml when ambiguous after sniff + return "yaml" if text is not None else "json" + + +def parse_pack_content( + text_or_bytes: str | bytes, + hint_filename: str | None = None, + *, + content_type: str | None = None, +) -> dict: + """Parse and validate pack content from text or bytes.""" + if isinstance(text_or_bytes, bytes): + try: + text = text_or_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"Pack content is not valid UTF-8: {exc}") from exc + else: + text = text_or_bytes + + source = hint_filename or "" + fmt = _detect_pack_format(hint_filename, content_type, text) + + data: object + if fmt == "json": + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + # Fallback: maybe mis-detected YAML + if hint_filename and hint_filename.lower().endswith(".json"): + raise ValueError(f"Invalid JSON in pack {source}: {exc}") from exc + try: + data = yaml.safe_load(text) + except yaml.YAMLError as yexc: + raise ValueError(f"Invalid pack content {source}: {exc}") from yexc + else: + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise ValueError(f"Invalid YAML in pack {source}: {exc}") from exc + + if not isinstance(data, dict): + raise ValueError(f"Pack must be a mapping/object: {source}") + + return validate_pack_dict(data, source=source) + + +def parse_pack_bytes( + data: bytes, + hint_filename: str | None = None, + *, + content_type: str | None = None, +) -> dict: + """Parse pack bytes (alias of parse_pack_content for bytes).""" + return parse_pack_content(data, hint_filename, content_type=content_type) + + +def load_pack_file(path: Path | str) -> dict: + """ + Load and validate a pack from a JSON or YAML file. + + Required: id, name, non-empty files list with safe relative paths. + Optional: description, stack, version. + """ + path = Path(path) + if not path.is_file(): + raise ValueError(f"Pack file not found: {path}") + + suffix = path.suffix.lower() + if suffix not in _PACK_FILE_SUFFIXES: + raise ValueError(f"Unsupported pack file type: {path.suffix!r}") + + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"Cannot read pack file {path}: {exc}") from exc + + return parse_pack_content(text, hint_filename=path.name) + + +def _load_packs_from_dir(directory: Path) -> dict[str, dict]: + """Load all valid pack files from a directory (non-recursive).""" + result: dict[str, dict] = {} + if not directory.is_dir(): + return result + + for path in sorted(directory.iterdir()): + if not path.is_file(): + continue + if path.suffix.lower() not in _PACK_FILE_SUFFIXES: + continue + try: + pack = load_pack_file(path) + except ValueError: + # Skip invalid packs rather than failing the whole listing + continue + result[pack["id"]] = pack + return result + + +def _all_packs() -> dict[str, dict]: + """ + Merge built-in packs, installed packs, and optional CREER_PACKS_DIR. + + Later sources override earlier ones on id collision: + built-in → installed/ → CREER_PACKS_DIR. + """ + packs = _load_packs_from_dir(PACKS_DIR) + packs.update(_load_packs_from_dir(INSTALLED_DIR)) + extra = _extra_packs_dir() + if extra is not None and extra.resolve() != INSTALLED_DIR.resolve(): + packs.update(_load_packs_from_dir(extra)) + return packs + + +def list_packs() -> list[dict]: + """Return all available packs (built-in + installed + user), sorted by id.""" + packs = _all_packs() + return [dict(packs[k]) for k in sorted(packs.keys())] + + +def get_pack(pack_id: str) -> dict | None: + """Look up a pack by id.""" + if not pack_id: + return None + pack = _all_packs().get(pack_id) + return dict(pack) if pack else None + + +def install_pack(pack: dict, *, overwrite: bool = False) -> dict: + """ + Write a validated pack dict as ``{id}.json`` into the writable packs dir. + + Raises PackConflictError if the id already exists and overwrite is False. + """ + validated = validate_pack_dict(pack, source="") + pack_id = validated["id"] + + if get_pack(pack_id) is not None and not overwrite: + raise PackConflictError(f"Pack already exists: {pack_id!r}") + + target_dir = writable_packs_dir() + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / f"{pack_id}.json" + target.write_text( + json.dumps(validated, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return dict(validated) + + +def install_pack_from_bytes( + data: bytes, + *, + hint_filename: str = "pack.json", + content_type: str | None = None, + overwrite: bool = False, +) -> dict: + """Parse pack bytes and install into the writable packs dir.""" + pack = parse_pack_bytes(data, hint_filename, content_type=content_type) + return install_pack(pack, overwrite=overwrite) + + +def uninstall_pack(pack_id: str) -> bool: + """ + Remove an installed pack from the writable dir only. + + Returns True if a file was deleted. + Raises PackNotInstalledError if the pack is not present under the writable dir + (built-in shipped packs cannot be deleted this way). + """ + if not pack_id or not _SAFE_ID.match(pack_id): + raise ValueError(f"Invalid pack id: {pack_id!r}") + + target_dir = writable_packs_dir() + if not target_dir.is_dir(): + raise PackNotInstalledError(f"Pack not installed (writable): {pack_id!r}") + + candidates = [ + target_dir / f"{pack_id}.json", + target_dir / f"{pack_id}.yaml", + target_dir / f"{pack_id}.yml", + ] + deleted = False + for path in candidates: + if path.is_file(): + path.unlink() + deleted = True + + if not deleted: + raise PackNotInstalledError(f"Pack not installed (writable): {pack_id!r}") + return True + + +def validate_remote_pack_url(url: str) -> str: + """Allow only http/https URLs with a host. Returns the stripped URL.""" + if not isinstance(url, str) or not url.strip(): + raise ValueError("url is required") + url = url.strip() + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"Only http/https URLs are allowed, got scheme {parsed.scheme!r}" + ) + if not parsed.hostname: + raise ValueError("URL must include a host") + return url + + +def _hint_filename_from_url(url: str) -> str: + path = urlparse(url).path or "" + name = Path(path).name + if name and any(name.lower().endswith(s) for s in _PACK_FILE_SUFFIXES): + return name + return "pack.json" + + +def fetch_pack_bytes(url: str) -> tuple[bytes, str | None, str]: + """ + Download pack content from a remote URL. + + Returns (body, content_type, hint_filename). + Enforces http(s), ~30s timeout, and 1MB max size. + """ + url = validate_remote_pack_url(url) + hint = _hint_filename_from_url(url) + + try: + with httpx.Client( + timeout=FETCH_TIMEOUT_SECONDS, + follow_redirects=True, + ) as client: + with client.stream("GET", url) as resp: + if resp.status_code >= 400: + raise ValueError( + f"Failed to fetch pack ({resp.status_code}): {url}" + ) + content_type = resp.headers.get("content-type") + chunks: list[bytes] = [] + total = 0 + for chunk in resp.iter_bytes(): + if not chunk: + continue + total += len(chunk) + if total > MAX_DOWNLOAD_BYTES: + raise ValueError( + f"Pack download exceeds {MAX_DOWNLOAD_BYTES} byte limit" + ) + chunks.append(chunk) + body = b"".join(chunks) + except httpx.HTTPError as exc: + raise ValueError(f"Failed to fetch pack URL: {exc}") from exc + + if not body: + raise ValueError("Pack download was empty") + + return body, content_type, hint + + +def install_pack_from_url(url: str, *, overwrite: bool = False) -> dict: + """Fetch a remote pack URL, validate, and install.""" + body, content_type, hint = fetch_pack_bytes(url) + return install_pack_from_bytes( + body, + hint_filename=hint, + content_type=content_type, + overwrite=overwrite, + ) + + +def marketplace_catalog() -> list[dict]: + """ + Static curated marketplace catalog. + + Bundled entries mirror the three shipped packs (offline/demo). + Remote entries are illustrative placeholders — do not require network in tests. + """ + return [ + { + "id": "fastapi-crud", + "name": "FastAPI CRUD", + "description": "FastAPI starter with models, CRUD routes, and uvicorn entrypoint.", + "source": "bundled", + "url": None, + }, + { + "id": "python-lib", + "name": "Python Library", + "description": "Publishable Python library with pyproject, package layout, and tests.", + "source": "bundled", + "url": None, + }, + { + "id": "express-ts", + "name": "Express TypeScript", + "description": "Express API in TypeScript with tsconfig and basic router.", + "source": "bundled", + "url": None, + }, + { + "id": "demo-remote", + "name": "Demo Remote Pack", + "description": ( + "Example remote pack URL (placeholder). Install via POST /packs/install " + "with this url — for demos/docs only; tests should mock the fetch." + ), + "url": ( + "https://raw.githubusercontent.com/example/creer-packs/main/" + "demo-remote.json" + ), + "source": "remote", + }, + { + "id": "hello-cli", + "name": "Hello CLI Pack", + "description": ( + "Example remote CLI starter pack URL (placeholder for marketplace demos)." + ), + "url": ( + "https://raw.githubusercontent.com/example/creer-packs/main/" + "hello-cli.yaml" + ), + "source": "remote", + }, + ] + + +def apply_pack(pack_id: str, idea: str) -> dict: + """ + Build a plan from an installable pack. + + Uses the pack's files and stack. Derives project_name by slugifying + the idea (deterministic — no API key required). + """ + pack = get_pack(pack_id) + if pack is None: + raise ValueError(f"Unknown pack_id: {pack_id!r}") + + project_name = slugify(idea) + return { + "project_name": project_name, + "stack": pack["stack"], + "files": list(pack["files"]), + "pack_id": pack["id"], + "description": pack.get("description", ""), + } diff --git a/backend/app/planner.py b/backend/app/planner.py new file mode 100644 index 0000000..8e225b6 --- /dev/null +++ b/backend/app/planner.py @@ -0,0 +1,154 @@ +"""Project planner — AI-driven or template-based.""" + +from __future__ import annotations + +import json +import re + +from config import CREER_OFFLINE, MODEL, OPENAI_API_KEY, OPENAI_BASE_URL +from app.llm import _get_client +from app.packs import apply_pack, get_pack +from app.templates import apply_template, get_template, slugify + + +def _llm_configured() -> bool: + """True when an OpenAI key or OpenAI-compatible base URL is available.""" + return bool(OPENAI_API_KEY or OPENAI_BASE_URL) + + +def _parse_json(content: str) -> dict: + """Parse model JSON, tolerating optional markdown fences.""" + text = content.strip() + fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) + if fence: + text = fence.group(1).strip() + return json.loads(text) + + +def _ai_plan(idea: str) -> dict: + prompt = f""" +You are a senior software architect. + +Convert the following idea into a clean project structure. + +Return ONLY valid JSON (no markdown): +{{ + "project_name": "...", + "stack": "...", + "files": ["path/file.py", ...] +}} + +Rules: +- project_name must be a valid folder name (lowercase, hyphens ok, no spaces) +- files should be a focused, production-ready starter set (typically 5–15 files, max 40) +- include README.md and a dependency manifest appropriate for the stack + +Idea: {idea} +""" + + response = _get_client().chat.completions.create( + model=MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0.2, + response_format={"type": "json_object"}, + ) + + plan = _parse_json(response.choices[0].message.content) + if not isinstance(plan.get("files"), list) or not plan.get("project_name"): + raise ValueError("Planner returned an invalid plan structure") + return plan + + +def _ai_name_project(idea: str, template: dict) -> str: + """Optionally ask the model for a short project name; fall back to slugify.""" + try: + prompt = f""" +Given this project idea and stack, return ONLY valid JSON: +{{"project_name": "short-kebab-case-name"}} + +Rules: +- lowercase, hyphens ok, no spaces +- 2–40 characters preferred +- must be a valid folder name + +Idea: {idea} +Stack: {template.get("stack", "")} +Template: {template.get("name", "")} +""" + response = _get_client().chat.completions.create( + model=MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0.2, + response_format={"type": "json_object"}, + ) + data = _parse_json(response.choices[0].message.content) + name = data.get("project_name") + if isinstance(name, str) and name.strip(): + return slugify(name) + except Exception: + pass + return slugify(idea) + + +def plan_project( + idea: str, + template_id: str | None = None, + pack_id: str | None = None, +) -> dict: + """ + Build a project plan from an idea, optionally anchored to a pack or template. + + pack_id and template_id are mutually exclusive — both set raises ValueError. + + When pack_id or template_id is set: + - files and stack come from the pack/template + - project_name is derived deterministically via slugify (no API key required) + - if an LLM is configured and not offline, AI may refine project_name only + + Without either: full AI planning (requires OPENAI_API_KEY or OPENAI_BASE_URL), + unless offline — offline without pack_id or template_id raises ValueError. + """ + if pack_id and template_id: + raise ValueError("Provide pack_id or template_id, not both") + + if CREER_OFFLINE and not pack_id and not template_id: + raise ValueError( + "Offline mode requires pack_id or template_id. Pass a pack_id " + "(see GET /packs) or template_id (see GET /templates) — AI planning " + "is disabled when CREER_OFFLINE is set." + ) + + if pack_id: + pack = get_pack(pack_id) + if pack is None: + raise ValueError(f"Unknown pack_id: {pack_id!r}") + + plan = apply_pack(pack_id, idea) + + if _llm_configured() and not CREER_OFFLINE: + plan["project_name"] = _ai_name_project(idea, pack) + + return plan + + if template_id: + tmpl = get_template(template_id) + if tmpl is None: + raise ValueError(f"Unknown template_id: {template_id!r}") + + plan = apply_template(template_id, idea) + + # Optionally refine name with AI when an LLM endpoint is available and not offline + if _llm_configured() and not CREER_OFFLINE: + plan["project_name"] = _ai_name_project(idea, tmpl) + + return plan + + if not _llm_configured() and not CREER_OFFLINE: + # No LLM endpoint and no pack/template — cannot plan with AI + raise ValueError( + "OPENAI_API_KEY (or OPENAI_BASE_URL) is not set. Provide a pack_id " + "or template_id for pack/template-only planning, or set " + "CREER_OFFLINE=1 with a pack_id or template_id." + ) + + return _ai_plan(idea) diff --git a/backend/app/quality.py b/backend/app/quality.py new file mode 100644 index 0000000..be6f534 --- /dev/null +++ b/backend/app/quality.py @@ -0,0 +1,102 @@ +"""Telemetry-free quality gates for generated project trees.""" + +from __future__ import annotations + +from typing import Any + +MANIFEST_NAMES = { + "requirements.txt", + "pyproject.toml", + "package.json", + "Pipfile", + "go.mod", + "Cargo.toml", + "composer.json", +} + + +def run_quality_gates( + plan: dict, + files: dict[str, str], + *, + expect_license: bool = True, +) -> list[dict[str, Any]]: + """ + Return light static issues for a generated tree. + + Severities: error | warning | info + """ + issues: list[dict[str, Any]] = [] + + paths = list(files.keys()) + seen: set[str] = set() + for path in paths: + if path in seen: + issues.append( + { + "code": "path_duplicate", + "severity": "error", + "path": path, + "message": f"Duplicate path in generated tree: {path}", + } + ) + seen.add(path) + + for path, content in files.items(): + if not isinstance(content, str) or not content.strip(): + issues.append( + { + "code": "empty_file", + "severity": "warning", + "path": path, + "message": f"File is empty or whitespace-only: {path}", + } + ) + + if "README.md" not in files and "README" not in files: + issues.append( + { + "code": "missing_readme", + "severity": "warning", + "message": "No README.md in generated tree", + } + ) + + if expect_license and "LICENSE" not in files: + issues.append( + { + "code": "missing_license", + "severity": "warning", + "message": "LICENSE missing after bake-ins", + } + ) + + lower_names = {p.split("/")[-1] for p in files} + if not (lower_names & MANIFEST_NAMES): + issues.append( + { + "code": "no_manifest", + "severity": "info", + "message": "No dependency manifest (requirements.txt / package.json / …)", + } + ) + + # Plan vs files: warn if plan listed paths that weren't generated + planned = plan.get("files") or [] + if isinstance(planned, list): + missing = [p for p in planned if isinstance(p, str) and p not in files] + for path in missing[:20]: + issues.append( + { + "code": "missing_planned_file", + "severity": "warning", + "path": path, + "message": f"Planned file was not generated: {path}", + } + ) + + return issues + + +def has_errors(issues: list[dict[str, Any]]) -> bool: + return any(i.get("severity") == "error" for i in issues) diff --git a/backend/app/registry.py b/backend/app/registry.py new file mode 100644 index 0000000..a09901b --- /dev/null +++ b/backend/app/registry.py @@ -0,0 +1,166 @@ +"""Self-hosted pack registry — searchable catalog + downloadable pack JSON.""" + +from __future__ import annotations + +import json +from typing import Any + +from config import CREER_PUBLIC_BASE_URL +from app.packs import ( + INSTALLED_DIR, + PACKS_DIR, + _extra_packs_dir, + _load_packs_from_dir, + get_pack, + list_packs, + marketplace_catalog, +) + + +def _download_path(pack_id: str) -> str: + return f"/registry/packs/{pack_id}/download" + + +def _absolute_or_relative(path: str) -> str: + base = (CREER_PUBLIC_BASE_URL or "").rstrip("/") + if base: + return f"{base}{path}" + return path + + +def _packs_with_source() -> list[dict[str, Any]]: + """ + Build pack list with source tags. + + Precedence matches list_packs merge: built-in → installed → CREER_PACKS_DIR. + Final source reflects the winning file location. + """ + sources: dict[str, str] = {} + packs: dict[str, dict] = {} + + for pack_id, pack in _load_packs_from_dir(PACKS_DIR).items(): + packs[pack_id] = pack + sources[pack_id] = "bundled" + + for pack_id, pack in _load_packs_from_dir(INSTALLED_DIR).items(): + packs[pack_id] = pack + sources[pack_id] = "installed" + + extra = _extra_packs_dir() + if extra is not None and extra.resolve() != INSTALLED_DIR.resolve(): + for pack_id, pack in _load_packs_from_dir(extra).items(): + packs[pack_id] = pack + sources[pack_id] = "installed" + + items: list[dict[str, Any]] = [] + for pack_id in sorted(packs.keys()): + pack = dict(packs[pack_id]) + download = _absolute_or_relative(_download_path(pack_id)) + items.append( + { + "id": pack["id"], + "name": pack.get("name") or pack["id"], + "description": pack.get("description") or "", + "stack": pack.get("stack") or "", + "version": pack.get("version") or "1.0.0", + "source": sources.get(pack_id, "bundled"), + "files": list(pack.get("files") or []), + "download_url": download, + "install_url": download, + } + ) + return items + + +def list_registry( + *, + q: str | None = None, + source: str = "all", +) -> dict[str, Any]: + """Searchable registry listing.""" + source_filter = (source or "all").lower().strip() + if source_filter not in ("all", "bundled", "installed"): + source_filter = "all" + + items = _packs_with_source() + if source_filter != "all": + items = [i for i in items if i["source"] == source_filter] + + query = (q or "").strip().lower() + if query: + def matches(item: dict[str, Any]) -> bool: + blob = " ".join( + [ + str(item.get("id") or ""), + str(item.get("name") or ""), + str(item.get("description") or ""), + str(item.get("stack") or ""), + ] + ).lower() + return query in blob + + items = [i for i in items if matches(i)] + + return { + "version": "0.9.0", + "base_url": CREER_PUBLIC_BASE_URL or None, + "items": items, + } + + +def get_registry_pack(pack_id: str) -> dict[str, Any] | None: + for item in _packs_with_source(): + if item["id"] == pack_id: + # Include full pack body fields + pack = get_pack(pack_id) + if pack is None: + return None + out = dict(item) + out["files"] = list(pack.get("files") or []) + out["stack"] = pack.get("stack") or out.get("stack") or "" + out["description"] = pack.get("description") or out.get("description") or "" + return out + return None + + +def pack_download_bytes(pack_id: str) -> tuple[bytes, str] | None: + """Return (json_bytes, filename) for a pack, or None.""" + pack = get_pack(pack_id) + if pack is None: + return None + # Portable JSON export + payload = { + "id": pack["id"], + "name": pack.get("name") or pack["id"], + "description": pack.get("description") or "", + "stack": pack.get("stack") or "", + "version": pack.get("version") or "1.0.0", + "files": list(pack.get("files") or []), + } + data = (json.dumps(payload, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + return data, f"{pack['id']}.json" + + +def featured_marketplace() -> list[dict[str, Any]]: + """ + Backward-compatible marketplace view. + + Bundled catalog entries plus locally available packs as installable via registry download. + Remote placeholder URLs from the static catalog are preserved. + """ + featured = marketplace_catalog() + # Enrich bundled items that exist locally with download/install URLs + local = {i["id"]: i for i in _packs_with_source()} + out: list[dict[str, Any]] = [] + for item in featured: + entry = dict(item) + local_item = local.get(item["id"]) + if local_item and not entry.get("url"): + entry["url"] = local_item["download_url"] + entry["download_url"] = local_item["download_url"] + out.append(entry) + return out + + +def registry_count() -> int: + return len(list_packs()) diff --git a/backend/app/templates.py b/backend/app/templates.py new file mode 100644 index 0000000..0475b3c --- /dev/null +++ b/backend/app/templates.py @@ -0,0 +1,120 @@ +"""Curated starter templates for Creer v0.2.""" + +from __future__ import annotations + +import re + +TEMPLATES: dict[str, dict] = { + "fastapi-minimal": { + "id": "fastapi-minimal", + "name": "FastAPI Minimal", + "description": "Minimal FastAPI API with uvicorn, health check, and env config.", + "stack": "FastAPI + Uvicorn", + "files": [ + "main.py", + "requirements.txt", + "README.md", + ".env.example", + ".gitignore", + ], + }, + "express-api": { + "id": "express-api", + "name": "Express API", + "description": "Node.js Express REST API with a basic router and scripts.", + "stack": "Node.js + Express", + "files": [ + "package.json", + "src/index.js", + "src/routes/health.js", + "README.md", + ".gitignore", + ".env.example", + ], + }, + "nextjs-app": { + "id": "nextjs-app", + "name": "Next.js App", + "description": "Next.js App Router starter with a home page and package manifest.", + "stack": "Next.js + React", + "files": [ + "package.json", + "next.config.js", + "tsconfig.json", + "app/layout.tsx", + "app/page.tsx", + "app/globals.css", + "README.md", + ".gitignore", + ], + }, + "python-cli": { + "id": "python-cli", + "name": "Python CLI", + "description": "Python command-line tool with argparse entrypoint and packaging basics.", + "stack": "Python CLI", + "files": [ + "pyproject.toml", + "src/__init__.py", + "src/cli.py", + "src/__main__.py", + "README.md", + ".gitignore", + ], + }, + "static-site": { + "id": "static-site", + "name": "Static Site", + "description": "Simple static HTML/CSS/JS site ready to open in a browser.", + "stack": "HTML + CSS + JavaScript", + "files": [ + "index.html", + "styles.css", + "script.js", + "README.md", + ], + }, +} + + +def slugify(text: str) -> str: + """Derive a filesystem-safe project name from free text.""" + slug = text.strip().lower() + slug = re.sub(r"[^a-z0-9]+", "-", slug) + slug = slug.strip("-") + slug = re.sub(r"-{2,}", "-", slug) + if not slug: + slug = "project" + return slug[:64] + + +def list_templates() -> list[dict]: + """Return all curated templates as a list of dicts.""" + return [dict(t) for t in TEMPLATES.values()] + + +def get_template(template_id: str) -> dict | None: + """Look up a template by id.""" + tmpl = TEMPLATES.get(template_id) + return dict(tmpl) if tmpl else None + + +def apply_template(template_id: str, idea: str) -> dict: + """ + Build a plan from a curated template. + + Uses the template's files and stack. Derives project_name by slugifying + the idea (deterministic — no API key required). + """ + tmpl = get_template(template_id) + if tmpl is None: + raise ValueError(f"Unknown template_id: {template_id!r}") + + project_name = slugify(idea) + return { + "project_name": project_name, + "stack": tmpl["stack"], + "files": list(tmpl["files"]), + "template_id": tmpl["id"], + "description": tmpl.get("description", ""), + } diff --git a/backend/app/validator.py b/backend/app/validator.py new file mode 100644 index 0000000..f5fcdae --- /dev/null +++ b/backend/app/validator.py @@ -0,0 +1,109 @@ +"""Production-grade plan/file validation for Creer v0.2.""" + +from __future__ import annotations + +import re + +# project_name: lowercase start, alphanumerics/hyphen/underscore/dot, no spaces +SAFE_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$") + +# Relative paths only: no leading slash, no .. segments, safe chars +SAFE_PATH = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))[a-zA-Z0-9._/-]+$") + +# Windows drive / UNC-ish prefixes +WINDOWS_DRIVE = re.compile(r"^[a-zA-Z]:") + +# Plan file cap; generated set may also include bake-ins (LICENSE, CI, optional README). +MAX_FILES = 45 +MAX_CONTENT_BYTES_PER_FILE = 200_000 +MAX_TOTAL_CONTENT_BYTES = 2_000_000 + + +def _reject_unsafe_path(path: str, *, label: str = "file path") -> None: + if not isinstance(path, str): + raise ValueError(f"Invalid {label}: must be a string, got {type(path).__name__}") + + if "\x00" in path: + raise ValueError(f"Unsafe {label}: contains null byte: {path!r}") + + if path.startswith("/") or path.startswith("\\"): + raise ValueError(f"Unsafe {label}: absolute paths are not allowed: {path!r}") + + if WINDOWS_DRIVE.match(path) or path.startswith("\\\\"): + raise ValueError(f"Unsafe {label}: Windows drive/UNC paths are not allowed: {path!r}") + + # Normalize separators for .. checks + normalized = path.replace("\\", "/") + parts = normalized.split("/") + if ".." in parts or any(p == ".." for p in parts): + raise ValueError(f"Unsafe {label}: path traversal ('..') is not allowed: {path!r}") + + if not SAFE_PATH.match(path): + raise ValueError(f"Unsafe or invalid {label}: {path!r}") + + +def validate_plan(plan: dict) -> None: + if not isinstance(plan, dict): + raise ValueError("Plan must be a dictionary") + + name = plan.get("project_name") + files = plan.get("files") + + if not isinstance(name, str) or not name: + raise ValueError(f"Invalid project_name: {name!r}") + + if " " in name: + raise ValueError(f"Invalid project_name: spaces are not allowed: {name!r}") + + if "\x00" in name: + raise ValueError(f"Invalid project_name: contains null byte: {name!r}") + + if not SAFE_NAME.match(name): + raise ValueError( + f"Invalid project_name: must be 1–64 chars, start with alphanumeric, " + f"and contain only [a-zA-Z0-9._-]: {name!r}" + ) + + if not isinstance(files, list) or not files: + raise ValueError("Plan must include a non-empty files list") + + if len(files) > MAX_FILES: + raise ValueError(f"Plan exceeds max file count ({MAX_FILES}): got {len(files)}") + + seen: set[str] = set() + for path in files: + _reject_unsafe_path(path) + if path in seen: + raise ValueError(f"Duplicate file path in plan: {path!r}") + seen.add(path) + + +def validate_files(files: dict) -> None: + if not isinstance(files, dict) or not files: + raise ValueError("Generated files must be a non-empty mapping") + + if len(files) > MAX_FILES: + raise ValueError(f"Generated files exceed max file count ({MAX_FILES}): got {len(files)}") + + total_bytes = 0 + for path, content in files.items(): + _reject_unsafe_path(path) + + if not isinstance(content, str): + raise ValueError(f"File content for {path!r} must be a string") + + if "\x00" in content: + raise ValueError(f"File content for {path!r} contains null bytes") + + size = len(content.encode("utf-8")) + if size > MAX_CONTENT_BYTES_PER_FILE: + raise ValueError( + f"File {path!r} exceeds max content size " + f"({MAX_CONTENT_BYTES_PER_FILE} bytes): {size} bytes" + ) + total_bytes += size + + if total_bytes > MAX_TOTAL_CONTENT_BYTES: + raise ValueError( + f"Total content exceeds max ({MAX_TOTAL_CONTENT_BYTES} bytes): {total_bytes} bytes" + ) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0e5fc9f --- /dev/null +++ b/backend/config.py @@ -0,0 +1,15 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL") # e.g. http://127.0.0.1:11434/v1 for Ollama +MODEL = os.getenv("CREER_MODEL", "gpt-4o-mini") +CREER_OFFLINE = os.getenv("CREER_OFFLINE", "").lower() in ("1", "true", "yes") +# Optional extra packs directory (merged with backend/packs; user overrides on id collision) +CREER_PACKS_DIR = os.getenv("CREER_PACKS_DIR", "").strip() or None +# Optional public base URL for absolute registry download links +CREER_PUBLIC_BASE_URL = os.getenv("CREER_PUBLIC_BASE_URL", "").strip() or None +# Comma-separated peer Creer registry base URLs for federation (v0.8+) +CREER_REGISTRY_PEERS = os.getenv("CREER_REGISTRY_PEERS", "").strip() or None diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..54609c7 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,512 @@ +"""Creer FastAPI application — plan, generate, stream, cancel, GitHub helpers.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from typing import Any, Literal +from urllib.parse import urlparse + +from fastapi import FastAPI, Header, HTTPException, Query +from fastapi.responses import Response, StreamingResponse +from pydantic import BaseModel, Field + +from config import CREER_OFFLINE, CREER_PUBLIC_BASE_URL, MODEL, OPENAI_BASE_URL +from app.bakeins import apply_bakeins, list_bakein_options +from app.planner import plan_project +from app.generator import generate_files, generate_files_iter +from app.validator import validate_plan, validate_files +from app.templates import list_templates, get_template +from app.packs import ( + PackConflictError, + PackNotInstalledError, + get_pack, + install_pack_from_url, + list_packs, + uninstall_pack, +) +from app.registry import ( + featured_marketplace, + get_registry_pack, + list_registry, + pack_download_bytes, + registry_count, +) +from app.federation import list_federated, list_peer_status, parse_peers, probe_peer +from app.github import create_github_repo +from app.jobs import cancel_job, create_job, finish_job, is_cancelled +from app.quality import has_errors, run_quality_gates + +VERSION = "0.9.0" + +app = FastAPI(title="Creer", version=VERSION) + + +class PlanRequest(BaseModel): + idea: str = Field(..., min_length=3, max_length=4000) + template_id: str | None = None + pack_id: str | None = None + + +class PlanBody(BaseModel): + project_name: str + stack: str | None = None + files: list[str] + template_id: str | None = None + pack_id: str | None = None + description: str | None = None + + +class BakeinOptions(BaseModel): + license: Literal["mit", "apache-2.0", "none"] = "mit" + ci: Literal["auto", "python", "node", "none"] = "auto" + include_readme: bool = True + + +class GenerateRequest(BaseModel): + idea: str = Field(..., min_length=3, max_length=4000) + template_id: str | None = None + pack_id: str | None = None + plan: PlanBody | None = None + job_id: str | None = None + bakeins: BakeinOptions | None = None + + +class CancelRequest(BaseModel): + job_id: str = Field(..., min_length=1) + + +class QualityRequest(BaseModel): + plan: PlanBody | None = None + files: dict[str, str] + bakeins: BakeinOptions | None = None + + +class GitHubCreateRepoRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + private: bool = True + description: str = "" + token: str | None = None + + +class PackInstallRequest(BaseModel): + url: str = Field(..., min_length=1, max_length=2000) + overwrite: bool = False + + +class PeerProbeRequest(BaseModel): + url: str = Field(..., min_length=1, max_length=2000) + + +def _check_pack_template_exclusive( + pack_id: str | None, template_id: str | None +) -> None: + if pack_id and template_id: + raise ValueError("Provide pack_id or template_id, not both") + + +def _resolve_plan(request: GenerateRequest) -> dict: + """Resolve a validated plan from GenerateRequest (shared by sync + stream).""" + _check_pack_template_exclusive(request.pack_id, request.template_id) + + if request.plan is not None: + plan = request.plan.model_dump() + plan = {k: v for k, v in plan.items() if v is not None} + plan.setdefault("stack", "") + _check_pack_template_exclusive(plan.get("pack_id"), plan.get("template_id")) + if request.pack_id and "pack_id" not in plan: + plan["pack_id"] = request.pack_id + if request.template_id and "template_id" not in plan: + plan["template_id"] = request.template_id + # Re-check after merging top-level ids into plan + _check_pack_template_exclusive(plan.get("pack_id"), plan.get("template_id")) + else: + if request.pack_id and get_pack(request.pack_id) is None: + raise ValueError(f"Unknown pack_id: {request.pack_id!r}") + if request.template_id and get_template(request.template_id) is None: + raise ValueError(f"Unknown template_id: {request.template_id!r}") + plan = plan_project( + request.idea, + template_id=request.template_id, + pack_id=request.pack_id, + ) + + validate_plan(plan) + return plan + + +def _bakein_dict(bakeins: BakeinOptions | None) -> dict[str, Any] | None: + return bakeins.model_dump() if bakeins is not None else None + + +def _expect_license(bakeins: BakeinOptions | None) -> bool: + if bakeins is None: + return True + return bakeins.license != "none" + + +def _sse(data: dict) -> str: + return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + + +@app.get("/health") +def health(): + return { + "status": "ok", + "version": VERSION, + "offline": CREER_OFFLINE, + "base_url_set": bool(OPENAI_BASE_URL), + "model": MODEL, + "packs_count": len(list_packs()), + "registry_count": registry_count(), + "public_base_url_set": bool(CREER_PUBLIC_BASE_URL), + "peers_configured": len(parse_peers()), + } + + +@app.get("/templates") +def templates(): + return {"templates": list_templates()} + + +@app.get("/packs") +def packs(): + return {"packs": list_packs()} + + +@app.get("/registry") +def registry( + q: str | None = Query(default=None, description="Search name/description/id/stack"), + source: str = Query(default="all", description="bundled | installed | all"), +): + """Self-hosted searchable pack registry (local only).""" + return list_registry(q=q, source=source) + + +@app.get("/registry/federated") +def registry_federated( + q: str | None = Query(default=None, description="Search name/description/id/stack"), + source: str = Query(default="all", description="bundled | installed | all"), + peers: str | None = Query( + default=None, + description="Comma-separated extra peer base URLs for this request only", + ), +): + """Federated registry: local packs plus peer Creer registries.""" + extra = parse_peers(peers) if peers else None + return list_federated( + q=q, source=source, include_local=True, extra_peers=extra + ) + + +@app.get("/registry/peers") +def registry_peers(): + """Configured peer list plus live probe status for each peer.""" + return {"peers": list_peer_status(), "configured": parse_peers()} + + +@app.post("/registry/peers/probe") +def registry_peers_probe(request: PeerProbeRequest): + """Ad-hoc probe of a single peer base URL.""" + url = (request.url or "").strip().rstrip("/") + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise HTTPException( + status_code=400, + detail="url must use http or https with a host", + ) + return probe_peer(url) + + +@app.get("/registry/packs/{pack_id}") +def registry_pack_detail(pack_id: str): + item = get_registry_pack(pack_id) + if item is None: + raise HTTPException(status_code=404, detail=f"Unknown pack_id: {pack_id!r}") + return item + + +@app.get("/registry/packs/{pack_id}/download") +def registry_pack_download(pack_id: str): + """Download pack as portable JSON (usable as POST /packs/install url).""" + result = pack_download_bytes(pack_id) + if result is None: + raise HTTPException(status_code=404, detail=f"Unknown pack_id: {pack_id!r}") + data, filename = result + return Response( + content=data, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@app.get("/marketplace") +def marketplace(): + """Curated marketplace view (featured + local download URLs when available).""" + return {"items": featured_marketplace()} + + +@app.post("/packs/install") +def packs_install(request: PackInstallRequest): + """Fetch a remote pack URL (http/https), validate, and install locally.""" + try: + pack = install_pack_from_url(request.url, overwrite=request.overwrite) + except PackConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status_code=500, detail=f"Pack install failed: {exc}" + ) from exc + return {"installed": True, "pack": pack} + + +@app.get("/packs/{pack_id}") +def pack_detail(pack_id: str): + pack = get_pack(pack_id) + if pack is None: + raise HTTPException(status_code=404, detail=f"Unknown pack_id: {pack_id!r}") + return pack + + +@app.delete("/packs/{pack_id}") +def packs_delete(pack_id: str): + """Delete a pack from the writable installed dir only (not shipped examples).""" + try: + uninstall_pack(pack_id) + except PackNotInstalledError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"deleted": True, "pack_id": pack_id} + + +@app.get("/bakeins") +def bakeins(): + return list_bakein_options() + + +@app.post("/plan") +def plan_only(request: PlanRequest): + """Return a project plan without generating file contents.""" + try: + _check_pack_template_exclusive(request.pack_id, request.template_id) + if request.pack_id and get_pack(request.pack_id) is None: + raise ValueError(f"Unknown pack_id: {request.pack_id!r}") + if request.template_id and get_template(request.template_id) is None: + raise ValueError(f"Unknown template_id: {request.template_id!r}") + plan = plan_project( + request.idea, + template_id=request.template_id, + pack_id=request.pack_id, + ) + validate_plan(plan) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Planning failed: {exc}") from exc + + result = { + "project_name": plan["project_name"], + "stack": plan.get("stack"), + "files": plan["files"], + } + if plan.get("pack_id"): + result["pack_id"] = plan["pack_id"] + if plan.get("template_id"): + result["template_id"] = plan["template_id"] + if plan.get("description"): + result["description"] = plan["description"] + return result + + +@app.post("/generate") +def generate_project(request: GenerateRequest): + job_id = create_job(request.job_id) + try: + if is_cancelled(job_id): + raise ValueError("Cancelled by user") + plan = _resolve_plan(request) + files = generate_files(plan, should_cancel=lambda: is_cancelled(job_id)) + files = apply_bakeins(plan, files, _bakein_dict(request.bakeins)) + validate_files(files) + quality = run_quality_gates( + plan, files, expect_license=_expect_license(request.bakeins) + ) + if has_errors(quality): + detail = "; ".join( + f"{i.get('code')}: {i.get('message')}" for i in quality if i.get("severity") == "error" + ) + raise ValueError(f"Quality gate failed: {detail}") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Generation failed: {exc}") from exc + finally: + finish_job(job_id) + + result = { + "project_name": plan["project_name"], + "stack": plan.get("stack"), + "files": files, + "quality": quality, + "job_id": job_id, + } + if plan.get("pack_id"): + result["pack_id"] = plan["pack_id"] + if plan.get("template_id"): + result["template_id"] = plan["template_id"] + return result + + +@app.post("/generate/stream") +def generate_project_stream(request: GenerateRequest): + """Stream generation progress as Server-Sent Events (JSON data lines).""" + job_id = create_job(request.job_id) + + def event_stream() -> Iterator[str]: + plan: dict | None = None + try: + if is_cancelled(job_id): + yield _sse( + { + "event": "cancelled", + "job_id": job_id, + "detail": "Cancelled by user", + } + ) + return + + plan = _resolve_plan(request) + file_list = list(plan["files"]) + yield _sse( + { + "event": "start", + "job_id": job_id, + "project_name": plan["project_name"], + "total": len(file_list), + "stack": plan.get("stack") or "", + } + ) + + files: dict[str, str] = {} + cancelled = False + for event, partial in generate_files_iter( + plan, should_cancel=lambda: is_cancelled(job_id) + ): + files = partial + if event.get("event") == "cancelled": + cancelled = True + yield _sse( + { + "event": "cancelled", + "job_id": job_id, + "detail": event.get("detail") or "Cancelled by user", + } + ) + break + yield _sse(event) + + if cancelled: + return + + files = apply_bakeins(plan, files, _bakein_dict(request.bakeins)) + validate_files(files) + quality = run_quality_gates( + plan, files, expect_license=_expect_license(request.bakeins) + ) + if has_errors(quality): + detail = "; ".join( + f"{i.get('code')}: {i.get('message')}" + for i in quality + if i.get("severity") == "error" + ) + yield _sse({"event": "error", "detail": f"Quality gate failed: {detail}", "quality": quality}) + return + + done: dict = { + "event": "done", + "job_id": job_id, + "project_name": plan["project_name"], + "files": files, + "stack": plan.get("stack") or "", + "quality": quality, + } + if plan.get("pack_id"): + done["pack_id"] = plan["pack_id"] + if plan.get("template_id"): + done["template_id"] = plan["template_id"] + yield _sse(done) + except ValueError as exc: + yield _sse({"event": "error", "detail": str(exc)}) + except Exception as exc: + yield _sse({"event": "error", "detail": f"Generation failed: {exc}"}) + finally: + finish_job(job_id) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/generate/cancel") +def generate_cancel(request: CancelRequest): + ok = cancel_job(request.job_id) + if not ok: + raise HTTPException(status_code=400, detail="Invalid job_id") + return {"cancelled": True, "job_id": request.job_id} + + +@app.post("/quality") +def quality_check(request: QualityRequest): + plan = request.plan.model_dump() if request.plan is not None else {"files": list(request.files.keys())} + plan.setdefault("files", list(request.files.keys())) + quality = run_quality_gates( + plan, + request.files, + expect_license=_expect_license(request.bakeins), + ) + return {"quality": quality, "ok": not has_errors(quality)} + + +@app.post("/github/create-repo") +def github_create_repo( + request: GitHubCreateRepoRequest, + authorization: str | None = Header(default=None), +): + """Create a GitHub repo. Prefer Authorization: Bearer ; body.token also accepted.""" + token = None + if authorization: + parts = authorization.split(" ", 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + token = parts[1].strip() + else: + token = authorization.strip() + if not token: + token = request.token + if not token: + raise HTTPException( + status_code=401, + detail="GitHub token required via Authorization: Bearer or body.token", + ) + + try: + result = create_github_repo( + token=token, + name=request.name, + private=request.private, + description=request.description, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"GitHub create failed: {exc}") from exc + + return result diff --git a/backend/packs/express-ts.yaml b/backend/packs/express-ts.yaml new file mode 100644 index 0000000..45b257d --- /dev/null +++ b/backend/packs/express-ts.yaml @@ -0,0 +1,13 @@ +id: express-ts +name: Express TypeScript +description: Express API in TypeScript with tsconfig and basic router. +stack: Node.js + Express + TypeScript +version: "1.0.0" +files: + - package.json + - tsconfig.json + - src/index.ts + - src/routes/health.ts + - README.md + - .gitignore + - .env.example diff --git a/backend/packs/fastapi-crud.json b/backend/packs/fastapi-crud.json new file mode 100644 index 0000000..443adcf --- /dev/null +++ b/backend/packs/fastapi-crud.json @@ -0,0 +1,17 @@ +{ + "id": "fastapi-crud", + "name": "FastAPI CRUD", + "description": "FastAPI starter with models, CRUD routes, and uvicorn entrypoint.", + "stack": "FastAPI + Uvicorn", + "version": "1.0.0", + "files": [ + "main.py", + "requirements.txt", + "README.md", + "app/__init__.py", + "app/models.py", + "app/routes.py", + ".env.example", + ".gitignore" + ] +} diff --git a/backend/packs/installed/.gitkeep b/backend/packs/installed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/packs/python-lib.json b/backend/packs/python-lib.json new file mode 100644 index 0000000..e9fcd8e --- /dev/null +++ b/backend/packs/python-lib.json @@ -0,0 +1,15 @@ +{ + "id": "python-lib", + "name": "Python Library", + "description": "Publishable Python library with pyproject, package layout, and tests.", + "stack": "Python library", + "version": "1.0.0", + "files": [ + "pyproject.toml", + "README.md", + "src/mylib/__init__.py", + "src/mylib/core.py", + "tests/test_core.py", + ".gitignore" + ] +} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4de1c63 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +openai>=1.55.0 +python-dotenv>=1.0.0 +pydantic>=2.9.0 +httpx>=0.27.0 +PyYAML>=6.0.0 +pytest>=8.0.0 diff --git a/backend/tests/test_federation.py b/backend/tests/test_federation.py new file mode 100644 index 0000000..171c111 --- /dev/null +++ b/backend/tests/test_federation.py @@ -0,0 +1,189 @@ +"""Tests for federated multi-host registry discovery (v0.8).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +import main +from app import federation as fed +from app.federation import list_federated, parse_peers +from main import VERSION + + +def test_parse_peers_normalize_dedupe(): + raw = ( + " http://127.0.0.1:8001/,https://creer-packs.example.com," + "http://127.0.0.1:8001,ftp://bad.example,not-a-url,,https://creer-packs.example.com/ " + ) + peers = parse_peers(raw) + assert peers == [ + "http://127.0.0.1:8001", + "https://creer-packs.example.com", + ] + + +def test_parse_peers_from_config(monkeypatch): + monkeypatch.setattr( + fed, + "CREER_REGISTRY_PEERS", + "https://a.example/,https://b.example", + ) + assert parse_peers() == ["https://a.example", "https://b.example"] + + +def test_list_federated_mocked_peer(monkeypatch): + monkeypatch.setattr( + fed, + "CREER_REGISTRY_PEERS", + "http://peer.test:9000", + ) + + peer_item = { + "id": "peer-only-pack", + "name": "Peer Pack", + "description": "from peer", + "stack": "python", + "version": "1.0.0", + "source": "bundled", + "files": ["README.md"], + "download_url": "/registry/packs/peer-only-pack/download", + "install_url": "/registry/packs/peer-only-pack/download", + } + + def fake_fetch(base_url, *, q=None, source=None, timeout=8.0): + assert base_url == "http://peer.test:9000" + return [ + { + **peer_item, + "peer": base_url, + "download_url": f"{base_url}/registry/packs/peer-only-pack/download", + "install_url": f"{base_url}/registry/packs/peer-only-pack/download", + } + ], None + + monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch) + + result = list_federated() + assert result["version"] == "0.9.0" + assert "items" in result["local"] + assert len(result["peers"]) == 1 + assert result["peers"][0]["ok"] is True + assert result["peers"][0]["count"] == 1 + assert result["peers"][0]["error"] is None + + ids = [i["id"] for i in result["items"]] + assert "peer-only-pack" in ids + # Local packs come first + local_ids = {i["id"] for i in result["local"]["items"]} + assert ids[: len(local_ids)] == [i["id"] for i in result["local"]["items"]] + + peer_entry = next(i for i in result["items"] if i["id"] == "peer-only-pack") + assert peer_entry["peer"] == "http://peer.test:9000" + assert peer_entry["download_url"].startswith("http://peer.test:9000/") + + +def test_peer_failure_keeps_local(monkeypatch): + monkeypatch.setattr( + fed, + "CREER_REGISTRY_PEERS", + "http://dead.peer:9999", + ) + + def fake_fetch(base_url, *, q=None, source=None, timeout=8.0): + return [], "connection refused" + + monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch) + + result = list_federated() + assert result["version"] == "0.9.0" + local_count = len(result["local"]["items"]) + assert local_count >= 3 + assert len(result["items"]) == local_count + assert result["peers"][0]["ok"] is False + assert result["peers"][0]["count"] == 0 + assert "connection refused" in (result["peers"][0]["error"] or "") + + +def test_dedupe_prefers_local(monkeypatch): + monkeypatch.setattr( + fed, + "CREER_REGISTRY_PEERS", + "http://peer.test:9000", + ) + + def fake_fetch(base_url, *, q=None, source=None, timeout=8.0): + # Overlap with bundled fastapi-crud id + return [ + { + "id": "fastapi-crud", + "name": "Peer FastAPI", + "peer": base_url, + "download_url": f"{base_url}/registry/packs/fastapi-crud/download", + } + ], None + + monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch) + result = list_federated() + matches = [i for i in result["items"] if i["id"] == "fastapi-crud"] + assert len(matches) == 1 + assert matches[0].get("peer") is None # local wins + + +def test_health_0_9_and_federated_route(monkeypatch): + monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "https://a.example,https://b.example") + # Also patch config import used if parse_peers reads module-level — already patched fed + c = TestClient(main.app) + h = c.get("/health").json() + assert h["version"] == "0.9.0" + assert VERSION == "0.9.0" + assert h["peers_configured"] == 2 + + # No live peers — empty mock via monkeypatch on fetch + monkeypatch.setattr( + fed, + "_fetch_peer_registry", + lambda *a, **k: ([], "offline"), + ) + r = c.get("/registry/federated") + assert r.status_code == 200 + body = r.json() + assert body["version"] == "0.9.0" + assert "local" in body + assert len(body["items"]) == len(body["local"]["items"]) + + +def test_fetch_peer_registry_absolutizes(monkeypatch): + class FakeResp: + def raise_for_status(self): + return None + + def json(self): + return { + "items": [ + { + "id": "remote-pack", + "download_url": "/registry/packs/remote-pack/download", + "install_url": "/registry/packs/remote-pack/download", + } + ] + } + + class FakeClient: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url, params=None): + assert url == "http://peer.example/registry" + return FakeResp() + + monkeypatch.setattr(fed.httpx, "Client", FakeClient) + items = fed.fetch_peer_registry("http://peer.example") + assert len(items) == 1 + assert items[0]["peer"] == "http://peer.example" + assert items[0]["download_url"] == "http://peer.example/registry/packs/remote-pack/download" diff --git a/backend/tests/test_federation_ux.py b/backend/tests/test_federation_ux.py new file mode 100644 index 0000000..d7e66f6 --- /dev/null +++ b/backend/tests/test_federation_ux.py @@ -0,0 +1,292 @@ +"""Tests for federation UX — peer status, probe, ad-hoc peers (v0.9).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +import main +from app import federation as fed +from app.federation import list_federated, list_peer_status, probe_peer, resolve_peers +from main import VERSION + + +def test_probe_peer_ok(monkeypatch): + calls: list[str] = [] + + class FakeResp: + def __init__(self, data): + self._data = data + + def raise_for_status(self): + return None + + def json(self): + return self._data + + class FakeClient: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url, params=None): + calls.append(url) + if url.endswith("/health"): + return FakeResp( + {"status": "ok", "version": "0.8.0", "registry_count": 3} + ) + raise AssertionError(f"unexpected url: {url}") + + monkeypatch.setattr(fed.httpx, "Client", FakeClient) + result = probe_peer("http://peer.example:8001") + assert result["ok"] is True + assert result["base_url"] == "http://peer.example:8001" + assert result["version"] == "0.8.0" + assert result["count"] == 3 + assert result["error"] is None + assert isinstance(result["latency_ms"], (int, float)) + assert result["latency_ms"] >= 0 + assert any(u.endswith("/health") for u in calls) + + +def test_probe_peer_fail(monkeypatch): + class FakeClient: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url, params=None): + raise ConnectionError("connection refused") + + monkeypatch.setattr(fed.httpx, "Client", FakeClient) + result = probe_peer("http://dead.peer:9999") + assert result["ok"] is False + assert result["base_url"] == "http://dead.peer:9999" + assert result["error"] + assert "connection refused" in result["error"] + assert result["count"] is None + assert isinstance(result["latency_ms"], (int, float)) + + +def test_probe_peer_registry_fallback(monkeypatch): + """When /health fails, /registry still yields ok + count.""" + + class FakeResp: + def __init__(self, data): + self._data = data + + def raise_for_status(self): + return None + + def json(self): + return self._data + + class FakeClient: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url, params=None): + if url.endswith("/health"): + raise ConnectionError("no health") + if url.endswith("/registry"): + return FakeResp( + { + "version": "0.8.0", + "items": [{"id": "a"}, {"id": "b"}], + } + ) + raise AssertionError(url) + + monkeypatch.setattr(fed.httpx, "Client", FakeClient) + result = probe_peer("http://peer.example") + assert result["ok"] is True + assert result["count"] == 2 + assert result["version"] == "0.8.0" + assert result["error"] is None + + +def test_resolve_peers_merges_extra_dedupe_max(): + # configured via monkeypatch below in other tests; unit-test via parse_peers path + peers = resolve_peers(None) + assert isinstance(peers, list) + + # With extras only when no config + from app import federation as f + + # Temporarily empty config + original = f.CREER_REGISTRY_PEERS + try: + f.CREER_REGISTRY_PEERS = "http://a.example,http://b.example" + merged = resolve_peers( + [ + "http://b.example/", + "http://c.example", + "ftp://bad", + "http://d.example", + ] + ) + assert merged == [ + "http://a.example", + "http://b.example", + "http://c.example", + "http://d.example", + ] + + f.CREER_REGISTRY_PEERS = ",".join(f"http://p{i}.example" for i in range(10)) + capped = resolve_peers(["http://extra.example"]) + assert len(capped) == 8 + assert "http://extra.example" not in capped # configured fills the cap + finally: + f.CREER_REGISTRY_PEERS = original + + +def test_list_peer_status_concurrent(monkeypatch): + monkeypatch.setattr( + fed, + "CREER_REGISTRY_PEERS", + "http://a.example,http://b.example", + ) + + def fake_probe(base_url, timeout=5.0): + return { + "base_url": base_url, + "ok": base_url.endswith("a.example"), + "latency_ms": 1.0, + "count": 1 if base_url.endswith("a.example") else None, + "version": "0.9.0" if base_url.endswith("a.example") else None, + "error": None if base_url.endswith("a.example") else "down", + } + + monkeypatch.setattr(fed, "probe_peer", fake_probe) + statuses = list_peer_status() + assert len(statuses) == 2 + assert statuses[0]["base_url"] == "http://a.example" + assert statuses[0]["ok"] is True + assert statuses[1]["base_url"] == "http://b.example" + assert statuses[1]["ok"] is False + + +def test_registry_peers_route(monkeypatch): + monkeypatch.setattr( + fed, + "CREER_REGISTRY_PEERS", + "https://peer.example", + ) + monkeypatch.setattr( + fed, + "probe_peer", + lambda base_url, timeout=5.0: { + "base_url": base_url, + "ok": True, + "latency_ms": 2.5, + "count": 4, + "version": "0.9.0", + "error": None, + }, + ) + c = TestClient(main.app) + r = c.get("/registry/peers") + assert r.status_code == 200 + body = r.json() + assert body["configured"] == ["https://peer.example"] + assert len(body["peers"]) == 1 + assert body["peers"][0]["ok"] is True + assert body["peers"][0]["count"] == 4 + + +def test_registry_peers_probe_validation(): + c = TestClient(main.app) + bad = c.post("/registry/peers/probe", json={"url": "ftp://evil.example"}) + assert bad.status_code == 400 + + bad2 = c.post("/registry/peers/probe", json={"url": "not-a-url"}) + assert bad2.status_code == 400 + + bad3 = c.post("/registry/peers/probe", json={"url": "http://"}) + assert bad3.status_code == 400 + + +def test_registry_peers_probe_ok(monkeypatch): + monkeypatch.setattr( + main, + "probe_peer", + lambda base_url, timeout=5.0: { + "base_url": base_url.rstrip("/"), + "ok": True, + "latency_ms": 10.0, + "count": 2, + "version": "0.9.0", + "error": None, + }, + ) + c = TestClient(main.app) + r = c.post("/registry/peers/probe", json={"url": "http://peer.test:9000/"}) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["base_url"] == "http://peer.test:9000" + assert body["count"] == 2 + + +def test_federated_with_extra_peers_query(monkeypatch): + monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "") + + def fake_fetch(base_url, *, q=None, source=None, timeout=8.0): + assert base_url == "http://adhoc.peer:8002" + return [ + { + "id": "adhoc-pack", + "name": "Adhoc", + "peer": base_url, + "download_url": f"{base_url}/registry/packs/adhoc-pack/download", + } + ], None + + monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch) + c = TestClient(main.app) + r = c.get("/registry/federated", params={"peers": "http://adhoc.peer:8002/"}) + assert r.status_code == 200 + body = r.json() + assert body["version"] == "0.9.0" + assert any(p["base_url"] == "http://adhoc.peer:8002" for p in body["peers"]) + assert any(i["id"] == "adhoc-pack" for i in body["items"]) + + +def test_list_federated_extra_peers_arg(monkeypatch): + monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "http://cfg.peer") + + seen: list[str] = [] + + def fake_fetch(base_url, *, q=None, source=None, timeout=8.0): + seen.append(base_url) + return [], None + + monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch) + result = list_federated(extra_peers=["http://extra.peer/", "http://cfg.peer"]) + assert result["version"] == "0.9.0" + assert seen == ["http://cfg.peer", "http://extra.peer"] + + +def test_health_0_9(monkeypatch): + monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "https://a.example,https://b.example") + c = TestClient(main.app) + h = c.get("/health").json() + assert h["version"] == "0.9.0" + assert VERSION == "0.9.0" + assert h["peers_configured"] == 2 + assert h["status"] == "ok" diff --git a/backend/tests/test_marketplace.py b/backend/tests/test_marketplace.py new file mode 100644 index 0000000..5a70869 --- /dev/null +++ b/backend/tests/test_marketplace.py @@ -0,0 +1,205 @@ +"""Creer v0.6 marketplace / remote pack install tests (offline).""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +# Ensure offline before importing the app +os.environ["CREER_OFFLINE"] = "1" +# Isolate installs into a temp dir via fixture; clear CREER_PACKS_DIR by default +os.environ.pop("CREER_PACKS_DIR", None) + +from app import packs as packs_mod +from main import VERSION, app + + +SAMPLE_PACK = { + "id": "marketplace-demo", + "name": "Marketplace Demo", + "description": "Installed via tests", + "stack": "demo", + "version": "1.0.0", + "files": ["README.md", "main.py"], +} + + +@pytest.fixture() +def install_dir(tmp_path, monkeypatch): + """Point writable installs at a temp directory (not repo packs/).""" + target = tmp_path / "installed" + target.mkdir() + monkeypatch.setenv("CREER_PACKS_DIR", str(target)) + # Also keep INSTALLED_DIR clean conceptually — writable_packs_dir uses env + yield target + + +@pytest.fixture() +def client(install_dir): + return TestClient(app) + + +def test_health_version_0_9(client, monkeypatch): + monkeypatch.setattr("main.CREER_OFFLINE", True) + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["version"] == "0.9.0" + assert VERSION == "0.9.0" + assert data["offline"] is True + assert data["packs_count"] >= 3 + assert "peers_configured" in data + + +def test_marketplace_catalog(client): + resp = client.get("/marketplace") + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) >= 4 + bundled = [i for i in items if i.get("source") == "bundled"] + remote = [i for i in items if i.get("source") == "remote"] + assert len(bundled) == 3 + assert {b["id"] for b in bundled} == {"fastapi-crud", "python-lib", "express-ts"} + assert len(remote) >= 1 + assert all("url" in r for r in remote) + + +def test_install_pack_from_bytes(install_dir): + body = json.dumps(SAMPLE_PACK).encode("utf-8") + installed = packs_mod.install_pack_from_bytes(body, overwrite=False) + assert installed["id"] == "marketplace-demo" + assert (install_dir / "marketplace-demo.json").is_file() + ids = {p["id"] for p in packs_mod.list_packs()} + assert "marketplace-demo" in ids + + +def test_install_endpoint_mocked_httpx(client, install_dir, monkeypatch): + body = json.dumps(SAMPLE_PACK).encode("utf-8") + + class FakeResponse: + status_code = 200 + headers = {"content-type": "application/json"} + + def iter_bytes(self): + yield body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def stream(self, method, url): + assert method == "GET" + assert url.startswith("https://") + return FakeResponse() + + monkeypatch.setattr(packs_mod.httpx, "Client", FakeClient) + + resp = client.post( + "/packs/install", + json={"url": "https://example.com/packs/marketplace-demo.json", "overwrite": False}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["installed"] is True + assert data["pack"]["id"] == "marketplace-demo" + + packs = client.get("/packs").json()["packs"] + assert any(p["id"] == "marketplace-demo" for p in packs) + + +def test_install_overwrite_false_conflict(client, install_dir, monkeypatch): + packs_mod.install_pack(SAMPLE_PACK, overwrite=False) + with pytest.raises(packs_mod.PackConflictError): + packs_mod.install_pack(SAMPLE_PACK, overwrite=False) + + body = json.dumps(SAMPLE_PACK).encode("utf-8") + + class FakeResponse: + status_code = 200 + headers = {"content-type": "application/json"} + + def iter_bytes(self): + yield body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def stream(self, method, url): + return FakeResponse() + + monkeypatch.setattr(packs_mod.httpx, "Client", FakeClient) + resp = client.post( + "/packs/install", + json={ + "url": "https://example.com/packs/marketplace-demo.json", + "overwrite": False, + }, + ) + assert resp.status_code == 409 + + +def test_delete_installed_only(client, install_dir): + packs_mod.install_pack(SAMPLE_PACK, overwrite=True) + assert any(p["id"] == "marketplace-demo" for p in packs_mod.list_packs()) + + resp = client.delete("/packs/marketplace-demo") + assert resp.status_code == 200 + assert resp.json()["deleted"] is True + assert not (install_dir / "marketplace-demo.json").exists() + assert not any(p["id"] == "marketplace-demo" for p in packs_mod.list_packs()) + + # Built-in cannot be deleted via writable dir + resp = client.delete("/packs/fastapi-crud") + assert resp.status_code == 404 + # Still listed (bundled) + assert any(p["id"] == "fastapi-crud" for p in client.get("/packs").json()["packs"]) + + +def test_reject_non_http_scheme(): + with pytest.raises(ValueError, match="http/https"): + packs_mod.validate_remote_pack_url("file:///etc/passwd") + with pytest.raises(ValueError, match="http/https"): + packs_mod.validate_remote_pack_url("ftp://example.com/pack.json") + + +def test_parse_yaml_pack(): + text = """ +id: yaml-demo +name: YAML Demo +description: from yaml +stack: yaml +version: "1.0.0" +files: + - README.md +""" + pack = packs_mod.parse_pack_content(text, "pack.yaml") + assert pack["id"] == "yaml-demo" diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py new file mode 100644 index 0000000..9b3cf64 --- /dev/null +++ b/backend/tests/test_registry.py @@ -0,0 +1,69 @@ +"""Tests for self-hosted pack registry (v0.7).""" + +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +import main +from app.packs import install_pack, uninstall_pack + + +def test_health_registry_fields(): + c = TestClient(main.app) + h = c.get("/health").json() + assert h["version"] == "0.9.0" + assert h["registry_count"] >= 3 + assert "peers_configured" in h + + +def test_registry_lists_and_search(): + c = TestClient(main.app) + r = c.get("/registry") + assert r.status_code == 200 + items = r.json()["items"] + assert len(items) >= 3 + assert all("download_url" in i for i in items) + + r = c.get("/registry", params={"q": "fastapi"}) + assert r.status_code == 200 + ids = [i["id"] for i in r.json()["items"]] + assert "fastapi-crud" in ids + + r = c.get("/registry", params={"source": "bundled"}) + assert r.status_code == 200 + assert all(i["source"] == "bundled" for i in r.json()["items"]) + + +def test_registry_download_and_reinstall(): + c = TestClient(main.app) + r = c.get("/registry/packs/python-lib/download") + assert r.status_code == 200 + assert "attachment" in r.headers.get("content-disposition", "") + pack = r.json() + assert pack["id"] == "python-lib" + assert isinstance(pack["files"], list) and pack["files"] + + # Install under a new id via bytes path (avoid conflicting with bundled id) + data = dict(pack) + data["id"] = "python-lib-copy" + data["name"] = "Python Lib Copy" + install_pack(data, overwrite=True) + try: + ids = [p["id"] for p in c.get("/packs").json()["packs"]] + assert "python-lib-copy" in ids + detail = c.get("/registry/packs/python-lib-copy") + assert detail.status_code == 200 + assert detail.json()["source"] == "installed" + finally: + uninstall_pack("python-lib-copy") + + +def test_marketplace_enriched(): + c = TestClient(main.app) + items = c.get("/marketplace").json()["items"] + bundled = [i for i in items if i.get("source") == "bundled" and i["id"] == "fastapi-crud"] + assert bundled + # Local download URL attached when pack exists + assert bundled[0].get("url") or bundled[0].get("download_url") diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000..9fdd493 --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +out/ +*.vsix +.vscode-test/ +media/icon-1024.png diff --git a/extension/.vscode/launch.json b/extension/.vscode/launch.json new file mode 100644 index 0000000..a142310 --- /dev/null +++ b/extension/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: compile" + } + ] +} diff --git a/extension/.vscode/tasks.json b/extension/.vscode/tasks.json new file mode 100644 index 0000000..c8e2ec3 --- /dev/null +++ b/extension/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "compile", + "group": "build", + "problemMatcher": ["$tsc"] + } + ] +} diff --git a/extension/.vscodeignore b/extension/.vscodeignore new file mode 100644 index 0000000..14b22ea --- /dev/null +++ b/extension/.vscodeignore @@ -0,0 +1,18 @@ +.vscode/** +.vscode-test/** +src/** +tsconfig.json +**/*.ts +!**/*.d.ts +**/*.map +.gitignore +.eslintignore +.eslintrc* +**/.DS_Store +**/*.vsix +node_modules/@types/** +node_modules/typescript/** +*.md +!README.md +!CHANGELOG.md +media/icon-1024.png diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md new file mode 100644 index 0000000..6bd19ed --- /dev/null +++ b/extension/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +## 0.9.0 + +- Richer federation UX: `creer.registryPeers` + `creer.showPeerStatus` settings +- API: `fetchPeerStatus`, `probePeer`; federated fetch passes `peers` query +- **Creer: Browse Federated Registry** — peer health summary, local/`$(cloud)` labels +- **Creer: Manage Registry Peers** — add/remove setting peers, probe all (soft-fail) +- Release polish: GitHub Release on tag with `.vsix` attached; [`RELEASE.md`](../RELEASE.md) + +## 0.8.0 + +- Federated multi-host registry: `GET /registry/federated`, `CREER_REGISTRY_PEERS` +- Extension: **Creer: Browse Federated Registry** (search + install via absolute download/install URLs) +- GitHub Actions release workflow (`.vsix` artifact; optional Marketplace / Open VSX publish when secrets exist) +- Lightweight CI (backend pytest + extension compile) + +## 0.7.0 + +- Self-hosted pack registry: `GET /registry`, pack detail + JSON download +- Extension icon (`media/icon.png`) for Marketplace / Open VSX packaging +- **Creer: Browse Pack Registry** command (search + install via download URL) +- `CREER_PUBLIC_BASE_URL` for absolute registry links +- Marketplace items enriched with local download URLs when available + +## 0.6.0 + +- Pack marketplace + remote URL install/delete +- Side-by-side conflict diffs (`creer-generated`) +- Publish packaging (`PUBLISH.md`, vsce/ovsx scripts) + +## 0.5.0 + +- Content diff preview before write +- Multi-root workspace targeting +- Installable JSON/YAML template packs + +## 0.4.0 + +- Stream cancellation +- Selectable license/CI bake-ins +- Quality gates + +## 0.3.0 + +- Streaming generation +- Offline / local model backends +- OSS bake-ins + token hardening + +## 0.2.0 + +- Plan preview, templates, overwrite protection, GitHub push, `/creer` chat + +## 0.1.0 + +- Initial FastAPI backend + VS Code scaffold command diff --git a/extension/LICENSE b/extension/LICENSE new file mode 100644 index 0000000..88cdccb --- /dev/null +++ b/extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sanath S Patil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extension/PUBLISH.md b/extension/PUBLISH.md new file mode 100644 index 0000000..53a5072 --- /dev/null +++ b/extension/PUBLISH.md @@ -0,0 +1,109 @@ +# Publishing Creer (VS Code / Open VSX) + +The extension is packaged from compiled `out/` JavaScript. **Do not commit secrets** (personal access tokens). Tokens stay in your environment only. + +## Prerequisites + +- Node.js 18+ +- Compiled extension: `cd extension && npm install && npm run compile` +- Publisher identity: + - **VS Marketplace:** Azure DevOps PAT with Marketplace (Acquire / Publish) scopes, and a [publisher](https://marketplace.visualstudio.com/manage) matching `package.json` → `publisher` + - **Open VSX:** account + token from [open-vsx.org](https://open-vsx.org/) + +## Package a `.vsix` (no publish) + +```bash +cd extension +npm run compile +npm run package +# or: npx --yes @vscode/vsce package +``` + +This runs `vsce package`, respects `.vscodeignore`, and includes production dependencies (e.g. `axios`) plus `media/icon.png`. Output: `creer-0.9.0.vsix` (version from `package.json`). + +### Publisher signing (human step) + +vsce/ovsx publish with a PAT **signs the release to your publisher identity**. This repo is packaging-ready (icon, changelog, license, repository metadata). Actual signing/publish requires: + +1. Create publisher `creer` (or change `package.json` → `publisher`) +2. Export `VSCE_PAT` / `OVSX_PAT` in your shell only +3. Run `npx @vscode/vsce publish` or `npx ovsx publish` + +No tokens are stored in this repository. + +## GitHub Actions release + +Workflow: [`.github/workflows/release.yml`](../.github/workflows/release.yml) + +| Trigger | Behavior | +|---|---| +| Tag `v*` (e.g. `v0.9.0`) | Build + package `.vsix`, upload artifact, create GitHub Release with `.vsix` attached | +| `workflow_dispatch` | Build + package + artifact (no GitHub Release); publish only if secrets set | + +**Publish job** downloads the artifact and: + +1. If repository secret `VSCE_PAT` is set → `npx @vscode/vsce publish --packagePath *.vsix -p "$VSCE_PAT"` +2. If repository secret `OVSX_PAT` is set → `npx ovsx publish *.vsix -p "$OVSX_PAT"` +3. If neither → logs `No publish tokens configured — artifact only` (signed publish is skipped) + +Configure secrets under **Settings → Secrets and variables → Actions** (never commit PATs). CI (`.github/workflows/ci.yml`) runs backend pytest + extension compile on push/PR and does not publish. + +Install locally for a smoke test: + +```bash +code --install-extension creer-0.9.0.vsix +# or Cursor: cursor --install-extension creer-0.9.0.vsix +``` + +## Publish to VS Marketplace + +1. Create a publisher if needed and ensure `package.json` `publisher` matches. +2. Export a PAT (do not paste into git or chat logs): + + ```bash + export VSCE_PAT=your_marketplace_pat + ``` + +3. Publish: + + ```bash + cd extension + npx --yes @vscode/vsce publish + # or: npm run publish:marketplace # prints the command reminder + ``` + +Optional: `npx @vscode/vsce publish -p "$VSCE_PAT"`. + +## Publish to Open VSX + +1. Create an Open VSX token. +2. Export it: + + ```bash + export OVSX_PAT=your_open_vsx_token + ``` + +3. Publish the same extension (package first if you want a local `.vsix`): + + ```bash + cd extension + npx --yes ovsx publish + # with an existing vsix: + # npx --yes ovsx publish creer-0.9.0.vsix + ``` + +`npm run publish:ovsx` only documents this flow (exits non-zero so CI does not publish by accident). + +## Checklist before publish + +- [ ] `version` bumped in `extension/package.json` +- [ ] `npm run compile` succeeds +- [ ] `license`, `repository`, `homepage`, `bugs` fields look correct +- [ ] `.vscodeignore` excludes `src/`, maps, and tooling (ships `out/`) +- [ ] Manual smoke: Create New Repo + Install Pack from URL against a running backend +- [ ] No tokens in repo files or commit history + +## Notes + +- Icon: add `icon.png` and `"icon": "icon.png"` in `package.json` when you have artwork. +- Prefer `npx @vscode/vsce` / `npx ovsx` so `@vscode/vsce` need not be a permanent `devDependency`. diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..222decf --- /dev/null +++ b/extension/README.md @@ -0,0 +1,18 @@ +# Creer (VS Code extension) + +AI-powered repo scaffolding inside your workspace. + +See the [repository README](https://github.com/seven0070/Creer#readme) for backend setup, commands, and settings. + +## Develop + +```bash +npm install +npm run compile +``` + +Press **F5** in VS Code/Cursor to launch an Extension Development Host (see `.vscode/launch.json`). + +## Publishing + +Package and publish steps (Marketplace / Open VSX) are documented in [PUBLISH.md](./PUBLISH.md). No publish tokens belong in this repo. diff --git a/extension/media/icon.png b/extension/media/icon.png new file mode 100644 index 0000000..d4fb02a Binary files /dev/null and b/extension/media/icon.png differ diff --git a/extension/package-lock.json b/extension/package-lock.json new file mode 100644 index 0000000..af6747f --- /dev/null +++ b/extension/package-lock.json @@ -0,0 +1,392 @@ +{ + "name": "creer", + "version": "0.9.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "creer", + "version": "0.9.0", + "dependencies": { + "axios": "^1.7.9" + }, + "devDependencies": { + "@types/node": "^20.17.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.6.3" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..eebcb33 --- /dev/null +++ b/extension/package.json @@ -0,0 +1,175 @@ +{ + "name": "creer", + "displayName": "Creer", + "description": "AI-powered repo scaffolding inside your workspace", + "version": "0.9.0", + "publisher": "creer", + "license": "MIT", + "icon": "media/icon.png", + "repository": { + "type": "git", + "url": "https://github.com/seven0070/Creer.git" + }, + "homepage": "https://github.com/seven0070/Creer", + "bugs": { + "url": "https://github.com/seven0070/Creer/issues" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Other", + "Snippets" + ], + "activationEvents": [], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "creer.createRepo", + "title": "Creer: Create New Repo" + }, + { + "command": "creer.createRepoFromChat", + "title": "Creer: Create from Chat Prompt" + }, + { + "command": "creer.setGitHubToken", + "title": "Creer: Set GitHub Token" + }, + { + "command": "creer.clearGitHubToken", + "title": "Creer: Clear GitHub Token" + }, + { + "command": "creer.installPackFromUrl", + "title": "Creer: Install Pack from URL" + }, + { + "command": "creer.browseMarketplace", + "title": "Creer: Browse Pack Marketplace" + }, + { + "command": "creer.browseRegistry", + "title": "Creer: Browse Pack Registry" + }, + { + "command": "creer.browseFederatedRegistry", + "title": "Creer: Browse Federated Registry" + }, + { + "command": "creer.manageRegistryPeers", + "title": "Creer: Manage Registry Peers" + } + ], + "chatParticipants": [ + { + "id": "creer.participant", + "fullName": "Creer", + "name": "creer", + "description": "Scaffold a repo with Creer", + "isSticky": false + } + ], + "configuration": { + "title": "Creer", + "properties": { + "creer.backendUrl": { + "type": "string", + "default": "http://localhost:8000", + "description": "Creer backend base URL" + }, + "creer.initGit": { + "type": "boolean", + "default": true, + "description": "Initialize a git repo after scaffolding" + }, + "creer.githubToken": { + "type": "string", + "default": "", + "description": "DEPRECATED: Prefer SecretStorage via 'Creer: Set GitHub Token'. Used only as a fallback when SecretStorage is empty.", + "deprecationMessage": "Use 'Creer: Set GitHub Token' (SecretStorage) instead of storing the token in settings." + }, + "creer.githubPrivate": { + "type": "boolean", + "default": true, + "description": "Create GitHub repositories as private" + }, + "creer.createGitHubRepo": { + "type": "boolean", + "default": false, + "description": "After scaffolding, create a GitHub remote repository" + }, + "creer.previewBeforeWrite": { + "type": "boolean", + "default": true, + "description": "Show a plan preview and confirm before generating and writing files" + }, + "creer.useStreaming": { + "type": "boolean", + "default": true, + "description": "Use SSE streaming (/generate/stream) with per-file progress; falls back to /generate on failure" + }, + "creer.license": { + "type": "string", + "default": "mit", + "enum": ["mit", "apache-2.0", "none"], + "description": "License bake-in for generated projects" + }, + "creer.ciPreset": { + "type": "string", + "default": "auto", + "enum": ["auto", "python", "node", "none"], + "description": "CI workflow bake-in preset for generated projects" + }, + "creer.promptBakeins": { + "type": "boolean", + "default": true, + "description": "When true, QuickPick license and CI each run; when false, use creer.license and creer.ciPreset" + }, + "creer.contentPreview": { + "type": "boolean", + "default": true, + "description": "After generate, before write, show a content preview/diff and confirm" + }, + "creer.showConflictDiffs": { + "type": "boolean", + "default": true, + "description": "When conflicts exist, offer Review diffs (side-by-side vscode.diff). When false, only Overwrite all / Skip existing / Cancel" + }, + "creer.defaultWorkspaceFolder": { + "type": "string", + "default": "", + "description": "Optional workspace folder name or path hint for multi-root workspaces; empty prompts when multiple folders are open" + }, + "creer.registryPeers": { + "type": "string", + "default": "", + "description": "Comma-separated peer base URLs used when browsing the federated registry (passed as peers= query)" + }, + "creer.showPeerStatus": { + "type": "boolean", + "default": true, + "description": "When browsing the federated registry, show peer health summary (ok/fail/latency)" + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "tsc --noEmit -p ./", + "package": "npx --yes @vscode/vsce package", + "publish:ovsx": "echo \"Set OVSX_PAT then run: npx --yes ovsx publish\" && exit 1", + "publish:marketplace": "echo \"Set VSCE_PAT then run: npx --yes @vscode/vsce publish\" && exit 1" + }, + "dependencies": { + "axios": "^1.7.9" + }, + "devDependencies": { + "@types/node": "^20.17.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.6.3" + } +} diff --git a/extension/src/api.ts b/extension/src/api.ts new file mode 100644 index 0000000..7e62e8b --- /dev/null +++ b/extension/src/api.ts @@ -0,0 +1,440 @@ +import axios, { AxiosError } from 'axios'; +import * as vscode from 'vscode'; + +export interface Template { + id: string; + name: string; + description: string; + stack: string; + files: string[]; +} + +export interface Pack { + id: string; + name: string; + description: string; + stack: string; + version?: string; + files: string[]; +} + +export interface MarketplaceItem { + id: string; + name: string; + description: string; + /** e.g. bundled | remote | url host */ + source: string; + url?: string; + download_url?: string; +} + +export interface PlanResponse { + project_name: string; + stack?: string; + files: string[]; + template_id?: string; + pack_id?: string; + description?: string; +} + +export type LicenseBakein = 'mit' | 'apache-2.0' | 'none'; +export type CiBakein = 'auto' | 'python' | 'node' | 'none'; + +export interface BakeinOptions { + license: LicenseBakein; + ci: CiBakein; + include_readme?: boolean; +} + +export interface BakeinChoice { + id: string; + name: string; +} + +export interface BakeinsResponse { + licenses: BakeinChoice[]; + ci: BakeinChoice[]; +} + +export interface QualityIssue { + code: string; + severity: string; + message: string; + path?: string; +} + +export interface GenerateResponse { + project_name: string; + stack?: string; + files: Record; + template_id?: string; + pack_id?: string; + quality?: QualityIssue[]; +} + +export interface GitHubCreateRepoResponse { + html_url: string; + clone_url: string; + full_name: string; +} + +export interface PlanOptions { + templateId?: string; + packId?: string; +} + +export interface GenerateOptions { + templateId?: string; + packId?: string; + plan?: PlanResponse; + jobId?: string; + bakeins?: BakeinOptions; +} + +function getBackendUrl(): string { + const config = vscode.workspace.getConfiguration('creer'); + return (config.get('backendUrl') || 'http://localhost:8000').replace(/\/$/, ''); +} + +export function formatAxiosError(err: unknown, fallback: string): string { + if (axios.isAxiosError(err)) { + const ax = err as AxiosError<{ detail?: string }>; + const detail = ax.response?.data?.detail; + if (typeof detail === 'string' && detail.trim()) { + return detail; + } + if (ax.message) { + return ax.message; + } + } + if (err instanceof Error) { + return err.message; + } + return fallback; +} + +export async function fetchTemplates(): Promise { + const backendUrl = getBackendUrl(); + const response = await axios.get<{ templates: Template[] }>(`${backendUrl}/templates`, { + timeout: 30_000, + }); + return response.data.templates ?? []; +} + +export async function fetchPacks(): Promise { + const backendUrl = getBackendUrl(); + const response = await axios.get<{ packs: Pack[] }>(`${backendUrl}/packs`, { + timeout: 30_000, + }); + return response.data.packs ?? []; +} + +/** + * GET /marketplace → { items: MarketplaceItem[] } + * Callers should treat missing/404 endpoints as “unavailable”. + */ +export async function fetchMarketplace(): Promise { + const backendUrl = getBackendUrl(); + const response = await axios.get<{ items: MarketplaceItem[] }>( + `${backendUrl}/marketplace`, + { timeout: 30_000 } + ); + return response.data.items ?? []; +} + +/** + * POST /packs/install → { url, overwrite? } → installed pack + * Backend returns `{ installed: true, pack: {...} }` (also tolerate a bare pack body). + */ +export async function installPack( + url: string, + overwrite?: boolean +): Promise { + const backendUrl = getBackendUrl(); + const body: { url: string; overwrite?: boolean } = { url }; + if (overwrite !== undefined) { + body.overwrite = overwrite; + } + const response = await axios.post<{ installed?: boolean; pack?: Pack } & Pack>( + `${backendUrl}/packs/install`, + body, + { timeout: 120_000 } + ); + const data = response.data; + if (data?.pack && typeof data.pack === 'object') { + return data.pack; + } + return data as Pack; +} + +export interface RegistryItem { + id: string; + name: string; + description?: string; + stack?: string; + version?: string; + source?: string; + files?: string[]; + download_url?: string; + install_url?: string; +} + +export interface RegistryResponse { + version: string; + base_url?: string | null; + items: RegistryItem[]; +} + +/** + * GET /registry?q=&source= — searchable self-hosted pack registry. + */ +export async function fetchRegistry(options?: { + q?: string; + source?: 'all' | 'bundled' | 'installed'; +}): Promise { + const backendUrl = getBackendUrl(); + const response = await axios.get(`${backendUrl}/registry`, { + timeout: 30_000, + params: { + q: options?.q || undefined, + source: options?.source || undefined, + }, + }); + return { + version: response.data.version, + base_url: response.data.base_url, + items: response.data.items ?? [], + }; +} + +/** Item from a federated registry merge; `peer` is set when sourced from a remote host. */ +export interface FederatedRegistryItem extends RegistryItem { + /** Peer base URL when the item came from a remote Creer registry. */ + peer?: string; +} + +export interface FederatedRegistryPeer { + base_url: string; + ok?: boolean; + count?: number; + error?: string | null; +} + +export interface FederatedRegistryResponse { + version?: string; + /** Local registry snapshot. */ + local: RegistryResponse | { + version?: string; + base_url?: string | null; + items?: RegistryItem[]; + }; + /** Configured peer hosts (reachable or not). */ + peers: FederatedRegistryPeer[]; + /** Merged catalog; peer-sourced rows may include `peer`. */ + items: FederatedRegistryItem[]; +} + +/** + * GET /registry/federated?q=&source=&peers= — local + peer registry merge. + * `peers` is a comma-separated list of extra peer base URLs (from settings or callers). + */ +export async function fetchFederatedRegistry(options?: { + q?: string; + source?: string; + /** Extra peer base URLs (comma-separated string or array). */ + peers?: string | string[]; +}): Promise { + const backendUrl = getBackendUrl(); + let peersParam: string | undefined; + if (Array.isArray(options?.peers)) { + const joined = options.peers.map((p) => p.trim()).filter(Boolean).join(','); + peersParam = joined || undefined; + } else if (typeof options?.peers === 'string' && options.peers.trim()) { + peersParam = options.peers.trim(); + } + const response = await axios.get( + `${backendUrl}/registry/federated`, + { + timeout: 60_000, + params: { + q: options?.q || undefined, + source: options?.source || undefined, + peers: peersParam, + }, + } + ); + return { + version: response.data.version, + local: response.data.local ?? { items: [] }, + peers: response.data.peers ?? [], + items: response.data.items ?? [], + }; +} + +/** Live peer probe / status from GET /registry/peers or POST /registry/peers/probe. */ +export interface PeerStatus { + /** Peer base URL (preferred). */ + url?: string; + /** Alternate field some backends may return. */ + base_url?: string; + ok: boolean; + latency_ms?: number | null; + error?: string | null; + count?: number | null; +} + +export interface PeerStatusListResponse { + peers: PeerStatus[]; + /** Backend-configured peer URLs (CREER_REGISTRY_PEERS). */ + configured: string[]; +} + +function normalizePeerStatus(raw: PeerStatus): PeerStatus { + return { + url: raw.url || raw.base_url || '', + base_url: raw.base_url || raw.url || '', + ok: Boolean(raw.ok), + latency_ms: raw.latency_ms ?? null, + error: raw.error ?? null, + count: raw.count ?? null, + }; +} + +/** + * GET /registry/peers → `{ peers: PeerStatus[], configured: string[] }` + */ +export async function fetchPeerStatus(): Promise { + const backendUrl = getBackendUrl(); + const response = await axios.get( + `${backendUrl}/registry/peers`, + { timeout: 30_000 } + ); + return { + peers: (response.data.peers ?? []).map(normalizePeerStatus), + configured: response.data.configured ?? [], + }; +} + +/** + * POST /registry/peers/probe `{ url }` → PeerStatus + */ +export async function probePeer(url: string): Promise { + const backendUrl = getBackendUrl(); + const response = await axios.post( + `${backendUrl}/registry/peers/probe`, + { url }, + { timeout: 30_000 } + ); + return normalizePeerStatus(response.data); +} + +/** + * DELETE /packs/{id} → delete installed pack + */ +export async function deletePack(id: string): Promise { + const backendUrl = getBackendUrl(); + await axios.delete(`${backendUrl}/packs/${encodeURIComponent(id)}`, { + timeout: 30_000, + }); +} + +export async function fetchBakeins(): Promise { + const backendUrl = getBackendUrl(); + const response = await axios.get(`${backendUrl}/bakeins`, { + timeout: 30_000, + }); + return { + licenses: response.data.licenses ?? [], + ci: response.data.ci ?? [], + }; +} + +export async function postCancel(jobId: string): Promise<{ cancelled: true }> { + const backendUrl = getBackendUrl(); + const response = await axios.post<{ cancelled: true }>( + `${backendUrl}/generate/cancel`, + { job_id: jobId }, + { timeout: 30_000 } + ); + return response.data; +} + +export async function postPlan(idea: string, options?: PlanOptions): Promise { + const backendUrl = getBackendUrl(); + const body: { idea: string; template_id?: string; pack_id?: string } = { idea }; + if (options?.packId) { + body.pack_id = options.packId; + } else if (options?.templateId) { + body.template_id = options.templateId; + } + const response = await axios.post(`${backendUrl}/plan`, body, { + timeout: 300_000, + }); + return response.data; +} + +export async function postGenerate( + idea: string, + options?: GenerateOptions +): Promise { + const backendUrl = getBackendUrl(); + const body: { + idea: string; + template_id?: string; + pack_id?: string; + plan?: PlanResponse; + job_id?: string; + bakeins?: BakeinOptions; + } = { idea }; + if (options?.packId) { + body.pack_id = options.packId; + } else if (options?.templateId) { + body.template_id = options.templateId; + } + if (options?.plan) { + body.plan = options.plan; + } + if (options?.jobId) { + body.job_id = options.jobId; + } + if (options?.bakeins) { + body.bakeins = options.bakeins; + } + const response = await axios.post(`${backendUrl}/generate`, body, { + timeout: 300_000, + }); + return response.data; +} + +/** Re-export streaming generate for callers that import from api. */ +export { streamGenerate, CancelledError, isCancellationError } from './streamGenerate'; +export type { + StreamProgressEvent, + StreamGenerateOptions, + StreamStartEvent, + StreamFileEvent, + StreamDoneEvent, + StreamErrorEvent, + StreamCancelledEvent, +} from './streamGenerate'; + +export async function createGitHubRepo( + token: string, + name: string, + options?: { private?: boolean; description?: string } +): Promise { + const backendUrl = getBackendUrl(); + const isPrivate = options?.private ?? true; + const description = options?.description ?? ''; + const response = await axios.post( + `${backendUrl}/github/create-repo`, + { name, private: isPrivate, description }, + { + timeout: 60_000, + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data; +} diff --git a/extension/src/conflictDiff.ts b/extension/src/conflictDiff.ts new file mode 100644 index 0000000..1e1d42c --- /dev/null +++ b/extension/src/conflictDiff.ts @@ -0,0 +1,196 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { resolveSafeProjectPath, type ConflictResolution } from './writeFiles'; + +export const CREER_GENERATED_SCHEME = 'creer-generated'; + +const MAX_DIFF_FILES = 15; + +/** + * Serves generated file contents for side-by-side `vscode.diff` against on-disk files. + * Content is keyed by the `path` query parameter (relative project path). + */ +export class CreerGeneratedContentProvider implements vscode.TextDocumentContentProvider { + private readonly contents = new Map(); + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + setContent(relativePath: string, content: string): void { + const key = normalizeRelPath(relativePath); + this.contents.set(key, content); + this._onDidChange.fire(generatedUri(key)); + } + + setMany(files: Record, relativePaths: string[]): void { + for (const rel of relativePaths) { + this.setContent(rel, files[rel] ?? ''); + } + } + + clear(): void { + this.contents.clear(); + } + + provideTextDocumentContent(uri: vscode.Uri): string { + const key = pathFromUri(uri); + return this.contents.get(key) ?? ''; + } +} + +let sharedProvider: CreerGeneratedContentProvider | undefined; + +export function registerConflictDiffProvider( + context: vscode.ExtensionContext +): CreerGeneratedContentProvider { + const provider = new CreerGeneratedContentProvider(); + sharedProvider = provider; + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(CREER_GENERATED_SCHEME, provider), + { dispose: () => { + if (sharedProvider === provider) { + sharedProvider = undefined; + } + provider.clear(); + } } + ); + return provider; +} + +function getProvider(): CreerGeneratedContentProvider { + if (!sharedProvider) { + // Lazy fallback if activate forgot to register (tests / unexpected hosts) + sharedProvider = new CreerGeneratedContentProvider(); + } + return sharedProvider; +} + +function normalizeRelPath(relativePath: string): string { + return relativePath.replace(/\\/g, '/'); +} + +function pathFromUri(uri: vscode.Uri): string { + const q = new URLSearchParams(uri.query); + const fromQuery = q.get('path'); + if (fromQuery) { + return normalizeRelPath(fromQuery); + } + return normalizeRelPath(uri.path.replace(/^\//, '')); +} + +export function generatedUri(relativePath: string): vscode.Uri { + const normalized = normalizeRelPath(relativePath); + return vscode.Uri.from({ + scheme: CREER_GENERATED_SCHEME, + path: '/' + normalized, + query: `path=${encodeURIComponent(normalized)}`, + }); +} + +/** + * Open side-by-side diffs: existing on-disk file (left) vs generated content (right). + * Caps at MAX_DIFF_FILES to avoid flooding the editor. + */ +export async function openConflictDiffs( + projectPath: string, + files: Record, + conflicts: string[], + provider?: CreerGeneratedContentProvider +): Promise { + const p = provider ?? getProvider(); + const toShow = conflicts.slice(0, MAX_DIFF_FILES); + p.setMany(files, toShow); + + for (const rel of toShow) { + let existingUri: vscode.Uri; + try { + const fullPath = resolveSafeProjectPath(projectPath, rel); + existingUri = vscode.Uri.file(fullPath); + } catch { + continue; + } + const generated = generatedUri(rel); + const title = `${path.basename(rel)} (existing ↔ generated)`; + await vscode.commands.executeCommand('vscode.diff', existingUri, generated, title); + } + + return toShow.length; +} + +function conflictMessage(conflicts: string[]): string { + const preview = conflicts.slice(0, 10); + const extra = conflicts.length > 10 ? `\n…and ${conflicts.length - 10} more` : ''; + const list = preview.map((p) => `• ${p}`).join('\n'); + return `${conflicts.length} file(s) already exist under the project folder:\n${list}${extra}`; +} + +async function promptOverwriteSkipCancel( + message: string, + detail?: string +): Promise { + const choice = await vscode.window.showWarningMessage( + message, + { modal: true, detail }, + 'Overwrite all', + 'Skip existing', + 'Cancel' + ); + if (choice === 'Overwrite all') { + return 'overwrite'; + } + if (choice === 'Skip existing') { + return 'skip'; + } + return 'cancel'; +} + +/** + * Conflict resolution with optional side-by-side review via `creer-generated` diffs. + * When `creer.showConflictDiffs` is false, only Overwrite / Skip / Cancel are offered. + */ +export async function resolveConflictsWithDiffs( + projectPath: string, + files: Record, + conflicts: string[] +): Promise { + if (conflicts.length === 0) { + return 'overwrite'; + } + + const config = vscode.workspace.getConfiguration('creer'); + const showDiffs = config.get('showConflictDiffs') ?? true; + const message = conflictMessage(conflicts); + + if (!showDiffs) { + return promptOverwriteSkipCancel(message); + } + + const choice = await vscode.window.showWarningMessage( + message, + { modal: true }, + 'Review diffs', + 'Overwrite all', + 'Skip existing', + 'Cancel' + ); + + if (choice === 'Overwrite all') { + return 'overwrite'; + } + if (choice === 'Skip existing') { + return 'skip'; + } + if (choice !== 'Review diffs') { + return 'cancel'; + } + + const opened = await openConflictDiffs(projectPath, files, conflicts); + const more = + conflicts.length > MAX_DIFF_FILES + ? ` (showing ${opened} of ${conflicts.length})` + : ''; + + return promptOverwriteSkipCancel( + `Reviewed ${opened} conflicted file(s)${more}. How should Creer proceed?`, + 'Overwrite all replaces existing files. Skip existing keeps them and writes only new paths.' + ); +} diff --git a/extension/src/contentPreview.ts b/extension/src/contentPreview.ts new file mode 100644 index 0000000..011741b --- /dev/null +++ b/extension/src/contentPreview.ts @@ -0,0 +1,279 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const MAX_FILES_SHOWN = 30; +const MAX_PREVIEW_LINES = 40; +const MAX_DIFF_LINES = 60; + +function fenceLang(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const map: Record = { + '.ts': 'typescript', + '.tsx': 'tsx', + '.js': 'javascript', + '.jsx': 'jsx', + '.py': 'python', + '.json': 'json', + '.md': 'markdown', + '.yml': 'yaml', + '.yaml': 'yaml', + '.toml': 'toml', + '.sh': 'bash', + '.css': 'css', + '.html': 'html', + '.rs': 'rust', + '.go': 'go', + '.java': 'java', + '.rb': 'ruby', + }; + return map[ext] || ''; +} + +function byteSize(content: string): number { + return Buffer.byteLength(content, 'utf8'); +} + +function formatBytes(n: number): string { + if (n < 1024) { + return `${n} B`; + } + if (n < 1024 * 1024) { + return `${(n / 1024).toFixed(1)} KB`; + } + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function truncateLines(text: string, maxLines: number): { text: string; truncated: boolean } { + const lines = text.split('\n'); + if (lines.length <= maxLines) { + return { text, truncated: false }; + } + return { + text: lines.slice(0, maxLines).join('\n'), + truncated: true, + }; +} + +/** + * Simple line-based unified diff (no external deps). + * Uses LCS on lines for moderate-sized inputs; truncates output. + */ +export function buildUnifiedDiff( + existing: string, + generated: string, + maxOutputLines = MAX_DIFF_LINES +): string { + const a = existing.split('\n'); + const b = generated.split('\n'); + + // Cap LCS input to keep it snappy on huge files + const CAP = 400; + const aCapped = a.length > CAP; + const bCapped = b.length > CAP; + const aLines = aCapped ? a.slice(0, CAP) : a; + const bLines = bCapped ? b.slice(0, CAP) : b; + + const n = aLines.length; + const m = bLines.length; + const dp: number[][] = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + if (aLines[i] === bLines[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + const ops: Array<{ kind: 'eq' | 'del' | 'add'; line: string }> = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (aLines[i] === bLines[j]) { + ops.push({ kind: 'eq', line: aLines[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + ops.push({ kind: 'del', line: aLines[i] }); + i++; + } else { + ops.push({ kind: 'add', line: bLines[j] }); + j++; + } + } + while (i < n) { + ops.push({ kind: 'del', line: aLines[i++] }); + } + while (j < m) { + ops.push({ kind: 'add', line: bLines[j++] }); + } + + const out: string[] = ['--- existing', '+++ generated']; + let shown = 0; + let skippedEq = 0; + + const flushSkipped = () => { + if (skippedEq > 0) { + out.push(` … ${skippedEq} unchanged line(s)`); + skippedEq = 0; + } + }; + + for (const op of ops) { + if (shown >= maxOutputLines) { + flushSkipped(); + out.push(` … diff truncated (${ops.length - shown} more op(s))`); + break; + } + if (op.kind === 'eq') { + skippedEq++; + continue; + } + flushSkipped(); + if (op.kind === 'del') { + out.push(`-${op.line}`); + } else { + out.push(`+${op.line}`); + } + shown++; + } + flushSkipped(); + + if (aCapped || bCapped) { + out.push( + ` … input capped for diff (existing ${a.length} lines, generated ${b.length} lines)` + ); + } + + return out.join('\n'); +} + +function buildNewFileSection(relPath: string, content: string): string { + const size = formatBytes(byteSize(content)); + const lang = fenceLang(relPath); + const { text, truncated } = truncateLines(content, MAX_PREVIEW_LINES); + const note = truncated + ? `\n_Preview truncated to ${MAX_PREVIEW_LINES} lines (${content.split('\n').length} total)._\n` + : ''; + + return [ + `### \`${relPath}\` — NEW · ${size}`, + '', + note, + '```' + lang, + text, + '```', + '', + ].join('\n'); +} + +function buildExistingFileSection( + relPath: string, + existing: string, + generated: string +): string { + const size = formatBytes(byteSize(generated)); + if (existing === generated) { + return [ + `### \`${relPath}\` — UNCHANGED · ${size}`, + '', + '_Generated content matches the existing file._', + '', + ].join('\n'); + } + + const diff = buildUnifiedDiff(existing, generated); + return [ + `### \`${relPath}\` — EXISTING (will overwrite) · ${size}`, + '', + '```diff', + diff, + '```', + '', + ].join('\n'); +} + +export function buildContentPreviewMarkdown( + projectName: string, + projectPath: string, + files: Record +): string { + const paths = Object.keys(files).sort((a, b) => a.localeCompare(b)); + const shown = paths.slice(0, MAX_FILES_SHOWN); + const omitted = paths.length - shown.length; + + const sections: string[] = [ + `# Creer content preview`, + '', + `**Project:** \`${projectName}\``, + '', + `**Path:** \`${projectPath}\``, + '', + `**Files:** ${paths.length}`, + '', + '---', + '', + ]; + + for (const relPath of shown) { + const generated = files[relPath] ?? ''; + const fullPath = path.join(projectPath, relPath); + let existing: string | undefined; + try { + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { + existing = fs.readFileSync(fullPath, 'utf8'); + } + } catch { + existing = undefined; + } + + if (existing === undefined) { + sections.push(buildNewFileSection(relPath, generated)); + } else { + sections.push(buildExistingFileSection(relPath, existing, generated)); + } + } + + if (omitted > 0) { + sections.push(`_…and ${omitted} more file(s) not shown in this preview._`, ''); + } + + sections.push('---', '', '_Confirm to write these files to disk._', ''); + return sections.join('\n'); +} + +/** + * Open an untitled markdown preview of generated file contents and ask the user + * to confirm writing. Returns true if the user confirms. + * When `creer.contentPreview` is false, skips the UI and returns true. + */ +export async function showContentPreviewAndConfirm( + projectName: string, + projectPath: string, + files: Record +): Promise { + const config = vscode.workspace.getConfiguration('creer'); + const enabled = config.get('contentPreview') ?? true; + if (!enabled) { + return true; + } + + const markdown = buildContentPreviewMarkdown(projectName, projectPath, files); + const doc = await vscode.workspace.openTextDocument({ + content: markdown, + language: 'markdown', + }); + await vscode.window.showTextDocument(doc, { preview: true, preserveFocus: false }); + + const fileCount = Object.keys(files).length; + const choice = await vscode.window.showInformationMessage( + `Creer content ready: ${projectName} (${fileCount} files). Write files to disk?`, + { modal: true }, + 'Write files', + 'Cancel' + ); + + return choice === 'Write files'; +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts new file mode 100644 index 0000000..406d206 --- /dev/null +++ b/extension/src/extension.ts @@ -0,0 +1,124 @@ +import * as vscode from 'vscode'; +import { registerConflictDiffProvider } from './conflictDiff'; +import { + browseMarketplaceCommand, + browseRegistryCommand, + installPackFromUrlCommand, +} from './marketplace'; +import { browseFederatedRegistryCommand, manageRegistryPeersCommand } from './registry'; +import { runScaffoldFlow } from './scaffold'; +import { clearGitHubToken, setGitHubToken } from './secrets'; + +export function activate(context: vscode.ExtensionContext) { + registerConflictDiffProvider(context); + + const createRepo = vscode.commands.registerCommand('creer.createRepo', async () => { + await runScaffoldFlow({ context, fromChat: false }); + }); + + const createRepoFromChat = vscode.commands.registerCommand( + 'creer.createRepoFromChat', + async (idea?: string) => { + const initial = typeof idea === 'string' ? idea : undefined; + await runScaffoldFlow({ context, idea: initial, fromChat: true }); + } + ); + + const setToken = vscode.commands.registerCommand('creer.setGitHubToken', async () => { + const token = await vscode.window.showInputBox({ + prompt: 'GitHub personal access token (repo scope) — stored in SecretStorage', + placeHolder: 'ghp_…', + password: true, + ignoreFocusOut: true, + }); + const trimmed = token?.trim(); + if (!trimmed) { + return; + } + await setGitHubToken(context, trimmed); + void vscode.window.showInformationMessage('Creer: GitHub token saved to SecretStorage.'); + }); + + const clearToken = vscode.commands.registerCommand('creer.clearGitHubToken', async () => { + await clearGitHubToken(context); + void vscode.window.showInformationMessage('Creer: GitHub token cleared from SecretStorage.'); + }); + + const installPackFromUrl = vscode.commands.registerCommand( + 'creer.installPackFromUrl', + () => installPackFromUrlCommand() + ); + + const browseMarketplace = vscode.commands.registerCommand( + 'creer.browseMarketplace', + () => browseMarketplaceCommand() + ); + + const browseRegistry = vscode.commands.registerCommand( + 'creer.browseRegistry', + () => browseRegistryCommand() + ); + + const browseFederatedRegistry = vscode.commands.registerCommand( + 'creer.browseFederatedRegistry', + () => browseFederatedRegistryCommand() + ); + + const manageRegistryPeers = vscode.commands.registerCommand( + 'creer.manageRegistryPeers', + () => manageRegistryPeersCommand() + ); + + context.subscriptions.push( + createRepo, + createRepoFromChat, + setToken, + clearToken, + installPackFromUrl, + browseMarketplace, + browseRegistry, + browseFederatedRegistry, + manageRegistryPeers + ); + registerChatParticipant(context); +} + +function registerChatParticipant(context: vscode.ExtensionContext): void { + // Runtime feature-detect: chat API may be missing on older VS Code hosts. + const create = + typeof vscode.chat?.createChatParticipant === 'function' + ? vscode.chat.createChatParticipant.bind(vscode.chat) + : undefined; + + if (!create) { + return; + } + + try { + const participant = create( + 'creer.participant', + async (request, _context, stream, _token) => { + const idea = (request.prompt || '').trim(); + if (!idea) { + stream.markdown( + 'Provide an idea after `@creer`, for example: `@creer Build a FastAPI todo app`.\n\n' + + 'You can also run **Creer: Create from Chat Prompt** and type `/creer …`.' + ); + return; + } + + stream.markdown( + `Scaffolding with Creer: **${idea}**…\n\n` + + 'Follow the prompts to pick a template, preview the plan, and confirm.' + ); + await runScaffoldFlow({ context, idea, fromChat: true }); + } + ); + + context.subscriptions.push(participant); + } catch { + // Hosts without chat support — ignore registration failure. + } +} + +export function deactivate() {} diff --git a/extension/src/git.ts b/extension/src/git.ts new file mode 100644 index 0000000..daf7643 --- /dev/null +++ b/extension/src/git.ts @@ -0,0 +1,138 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +async function git( + cwd: string, + args: string[], + env?: NodeJS.ProcessEnv +): Promise { + await execFileAsync('git', args, { + cwd, + env: env ?? process.env, + }); +} + +export async function isGitRepo(projectPath: string): Promise { + return fs.existsSync(path.join(projectPath, '.git')); +} + +export async function initGit(projectPath: string): Promise { + await git(projectPath, ['init']); + await git(projectPath, ['add', '.']); + try { + await git(projectPath, ['commit', '-m', 'Initial commit']); + } catch { + // Commit can fail if git user.name/email are unset — still leave the repo initialized. + } +} + +export async function ensureGitRepo(projectPath: string): Promise { + if (!(await isGitRepo(projectPath))) { + await initGit(projectPath); + } +} + +/** + * Write a temporary GIT_ASKPASS helper that reads the token from CREER_GITHUB_TOKEN. + * Never put the token in the remote URL or argv. + */ +function writeAskpassScript(): string { + const isWin = process.platform === 'win32'; + const askpassPath = path.join( + os.tmpdir(), + `creer-askpass-${process.pid}-${Date.now()}${isWin ? '.cmd' : '.sh'}` + ); + + if (isWin) { + // Git asks with prompts containing "Username" / "Password". + const script = [ + '@echo off', + 'setlocal EnableExtensions', + 'echo(%* | findstr /I "Username" >nul', + 'if not errorlevel 1 (', + ' echo x-access-token', + ') else (', + ' echo(%CREER_GITHUB_TOKEN%', + ')', + '', + ].join('\r\n'); + fs.writeFileSync(askpassPath, script, { encoding: 'utf8' }); + } else { + const script = [ + '#!/bin/sh', + 'case "$1" in', + ' *[Uu]sername*) printf "%s" "x-access-token" ;;', + ' *) printf "%s" "$CREER_GITHUB_TOKEN" ;;', + 'esac', + '', + ].join('\n'); + fs.writeFileSync(askpassPath, script, { encoding: 'utf8', mode: 0o700 }); + } + + return askpassPath; +} + +export async function addRemoteAndPush( + projectPath: string, + cloneUrl: string, + token?: string +): Promise { + try { + await git(projectPath, ['remote', 'remove', 'origin']); + } catch { + // No existing origin — fine. + } + + // Always store a clean clone URL (no embedded credentials). + await git(projectPath, ['remote', 'add', 'origin', cloneUrl]); + + // Ensure we have a branch name for -u push. + try { + await git(projectPath, ['rev-parse', '--verify', 'HEAD']); + } catch { + await git(projectPath, ['add', '.']); + try { + await git(projectPath, ['commit', '-m', 'Initial commit']); + } catch { + throw new Error('Cannot push: repository has no commits (set git user.name/email).'); + } + } + + let askpassPath: string | undefined; + try { + const pushEnv: NodeJS.ProcessEnv = { ...process.env }; + + if (token) { + askpassPath = writeAskpassScript(); + pushEnv.GIT_ASKPASS = askpassPath; + pushEnv.GIT_TERMINAL_PROMPT = '0'; + pushEnv.CREER_GITHUB_TOKEN = token; + // Prefer askpass over interactive prompts / GUI helpers for this child only. + pushEnv.SSH_ASKPASS = askpassPath; + pushEnv.SSH_ASKPASS_REQUIRE = 'never'; + } + + // Push to the clean clone URL — credentials come from askpass/env only. + try { + await git(projectPath, ['push', '-u', cloneUrl, 'HEAD'], pushEnv); + } catch { + await git(projectPath, ['push', '-u', cloneUrl, 'HEAD:main'], pushEnv); + } + } finally { + if (askpassPath) { + try { + fs.unlinkSync(askpassPath); + } catch { + // Best-effort cleanup. + } + } + } + + // Keep origin pointing at the clean clone URL. + await git(projectPath, ['remote', 'set-url', 'origin', cloneUrl]); +} diff --git a/extension/src/marketplace.ts b/extension/src/marketplace.ts new file mode 100644 index 0000000..a9a2167 --- /dev/null +++ b/extension/src/marketplace.ts @@ -0,0 +1,279 @@ +import * as vscode from 'vscode'; +import { + deletePack, + fetchMarketplace, + fetchRegistry, + formatAxiosError, + installPack, + type MarketplaceItem, + type RegistryItem, +} from './api'; + +export function resolveInstallUrl(url: string | undefined): string | undefined { + const trimmed = url?.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + // Relative registry paths → absolute against backend + const config = vscode.workspace.getConfiguration('creer'); + const backendUrl = (config.get('backendUrl') || 'http://localhost:8000').replace( + /\/$/, + '' + ); + if (trimmed.startsWith('/')) { + return `${backendUrl}${trimmed}`; + } + return `${backendUrl}/${trimmed}`; +} + +/** + * Creer: Install Pack from URL — prompt for URL, POST /packs/install. + */ +export async function installPackFromUrlCommand(): Promise { + const url = await vscode.window.showInputBox({ + prompt: 'Pack URL (JSON/YAML pack definition or registry download URL)', + placeHolder: 'http://localhost:8000/registry/packs/fastapi-crud/download', + ignoreFocusOut: true, + }); + const trimmed = url?.trim(); + if (!trimmed) { + return; + } + + try { + const pack = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: installing pack…', + cancellable: false, + }, + () => installPack(trimmed) + ); + const name = pack.name || pack.id || 'pack'; + void vscode.window.showInformationMessage( + `Creer: installed pack “${name}”${pack.id ? ` (${pack.id})` : ''}.` + ); + } catch (err) { + const message = formatAxiosError(err, 'Failed to install pack'); + void vscode.window.showErrorMessage(`Creer: could not install pack — ${message}`); + } +} + +export function isBundledSource(source: string | undefined): boolean { + if (!source) { + return false; + } + const s = source.toLowerCase(); + return s === 'bundled' || s === 'builtin' || s === 'built-in' || s === 'local'; +} + +export async function installFromResolvedUrl( + label: string, + url: string | undefined +): Promise { + const resolved = resolveInstallUrl(url); + if (!resolved) { + void vscode.window.showInformationMessage(`Creer: “${label}” has no install URL.`); + return; + } + + const action = await vscode.window.showInformationMessage( + `Install pack “${label}”?\n${resolved}`, + 'Install', + 'Cancel' + ); + if (action !== 'Install') { + return; + } + + try { + const pack = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Creer: installing ${label}…`, + cancellable: false, + }, + () => installPack(resolved) + ); + void vscode.window.showInformationMessage( + `Creer: installed pack “${pack.name || pack.id || label}”.` + ); + } catch (err) { + const message = formatAxiosError(err, 'Failed to install pack'); + void vscode.window.showErrorMessage(`Creer: could not install pack — ${message}`); + } +} + +/** + * Creer: Browse Pack Marketplace — GET /marketplace, QuickPick, optional install. + */ +export async function browseMarketplaceCommand(): Promise { + let items: MarketplaceItem[]; + try { + items = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: loading marketplace…', + cancellable: false, + }, + () => fetchMarketplace() + ); + } catch (err) { + const message = formatAxiosError(err, 'Marketplace unavailable'); + void vscode.window.showWarningMessage( + `Creer: marketplace endpoint not available (${message}). ` + + 'Try “Creer: Browse Pack Registry” or “Install Pack from URL”.' + ); + return; + } + + if (items.length === 0) { + void vscode.window.showInformationMessage('Creer: marketplace has no items.'); + return; + } + + type PickItem = vscode.QuickPickItem & { market?: MarketplaceItem }; + + const picks: PickItem[] = items.map((item) => { + const bundled = isBundledSource(item.source); + const hasUrl = Boolean(item.url?.trim() || item.download_url?.trim()); + let description = item.source || ''; + if (bundled) { + description = description ? `${description} · bundled` : 'bundled'; + } else if (hasUrl) { + description = description ? `${description} · remote` : 'remote'; + } + return { + label: item.name || item.id, + description, + detail: item.description, + market: item, + }; + }); + + const picked = await vscode.window.showQuickPick(picks, { + placeHolder: 'Select a marketplace pack', + ignoreFocusOut: true, + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!picked?.market) { + return; + } + + const item = picked.market; + if (isBundledSource(item.source) && !item.url && !item.download_url) { + void vscode.window.showInformationMessage( + `Creer: “${item.name || item.id}” is already available (bundled).` + ); + return; + } + + await installFromResolvedUrl( + item.name || item.id, + item.url || item.download_url + ); +} + +/** + * Creer: Browse Pack Registry — searchable self-hosted /registry catalog. + */ +export async function browseRegistryCommand(): Promise { + const q = await vscode.window.showInputBox({ + prompt: 'Search registry (leave empty for all packs)', + placeHolder: 'fastapi, express, cli…', + ignoreFocusOut: true, + }); + if (q === undefined) { + return; + } + + let items: RegistryItem[]; + try { + const registry = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: loading registry…', + cancellable: false, + }, + () => fetchRegistry({ q: q.trim() || undefined }) + ); + items = registry.items; + } catch (err) { + const message = formatAxiosError(err, 'Registry unavailable'); + void vscode.window.showWarningMessage( + `Creer: registry endpoint not available (${message}).` + ); + return; + } + + if (items.length === 0) { + void vscode.window.showInformationMessage('Creer: no registry packs matched.'); + return; + } + + type PickItem = vscode.QuickPickItem & { reg?: RegistryItem }; + const picks: PickItem[] = items.map((item) => ({ + label: item.name || item.id, + description: [item.source, item.stack, item.version].filter(Boolean).join(' · '), + detail: item.description, + reg: item, + })); + + const picked = await vscode.window.showQuickPick(picks, { + placeHolder: 'Select a registry pack to install (or reinstall)', + ignoreFocusOut: true, + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!picked?.reg) { + return; + } + + const item = picked.reg; + if (isBundledSource(item.source)) { + const choice = await vscode.window.showInformationMessage( + `“${item.name || item.id}” is bundled. Install a local copy via registry download anyway?`, + 'Install copy', + 'Cancel' + ); + if (choice !== 'Install copy') { + return; + } + } + + await installFromResolvedUrl( + item.name || item.id, + item.install_url || item.download_url + ); +} + +/** Optional helper for hosts that expose delete UI later. */ +export async function deletePackCommand(packId?: string): Promise { + let id = packId?.trim(); + if (!id) { + id = ( + await vscode.window.showInputBox({ + prompt: 'Pack id to delete (installed packs only)', + placeHolder: 'fastapi-crud', + ignoreFocusOut: true, + }) + )?.trim(); + } + if (!id) { + return; + } + + try { + await deletePack(id); + void vscode.window.showInformationMessage(`Creer: deleted pack “${id}”.`); + } catch (err) { + const message = formatAxiosError(err, 'Failed to delete pack'); + void vscode.window.showErrorMessage(`Creer: could not delete pack — ${message}`); + } +} diff --git a/extension/src/preview.ts b/extension/src/preview.ts new file mode 100644 index 0000000..e98c1ea --- /dev/null +++ b/extension/src/preview.ts @@ -0,0 +1,62 @@ +import * as vscode from 'vscode'; +import type { PlanResponse } from './api'; + +function buildFileTreeMarkdown(files: string[]): string { + const sorted = [...files].sort((a, b) => a.localeCompare(b)); + return sorted.map((f) => `- \`${f}\``).join('\n'); +} + +export function buildPlanPreviewMarkdown(plan: PlanResponse, idea: string): string { + const stack = plan.stack?.trim() || '(unspecified)'; + let sourceLine = '\n**Template:** AI plan (no template)\n'; + if (plan.pack_id) { + sourceLine = `\n**Pack:** \`${plan.pack_id}\`\n`; + } else if (plan.template_id) { + sourceLine = `\n**Template:** \`${plan.template_id}\`\n`; + } + const description = plan.description?.trim() + ? `\n**Description:** ${plan.description.trim()}\n` + : ''; + + return [ + `# Creer plan preview`, + '', + `**Idea:** ${idea}`, + '', + `**Project:** \`${plan.project_name}\``, + '', + `**Stack:** ${stack}`, + sourceLine, + description, + `**Files (${plan.files.length}):**`, + '', + buildFileTreeMarkdown(plan.files), + '', + '---', + '', + '_Confirm generation to write these files into your workspace._', + '', + ].join('\n'); +} + +/** + * Open an untitled markdown preview document and ask the user to confirm generation. + * Returns true if the user confirms, false if they cancel. + */ +export async function showPlanPreviewAndConfirm(plan: PlanResponse, idea: string): Promise { + const markdown = buildPlanPreviewMarkdown(plan, idea); + const doc = await vscode.workspace.openTextDocument({ + content: markdown, + language: 'markdown', + }); + await vscode.window.showTextDocument(doc, { preview: true, preserveFocus: false }); + + const choice = await vscode.window.showInformationMessage( + `Creer plan ready: ${plan.project_name} (${plan.files.length} files). Generate & write files?`, + { modal: true }, + 'Generate & write files', + 'Cancel' + ); + + return choice === 'Generate & write files'; +} diff --git a/extension/src/registry.ts b/extension/src/registry.ts new file mode 100644 index 0000000..dbd2916 --- /dev/null +++ b/extension/src/registry.ts @@ -0,0 +1,447 @@ +import * as vscode from 'vscode'; +import { + fetchFederatedRegistry, + fetchPeerStatus, + formatAxiosError, + probePeer, + type FederatedRegistryItem, + type PeerStatus, +} from './api'; +import { + installFromResolvedUrl, + isBundledSource, +} from './marketplace'; + +function peerHostLabel(peer: string | undefined): string | undefined { + if (!peer?.trim()) { + return undefined; + } + try { + return new URL(peer).host || peer; + } catch { + return peer; + } +} + +function peerUrlOf(status: PeerStatus): string { + return (status.url || status.base_url || '').trim(); +} + +function formatPeerHealth(status: PeerStatus): string { + const host = peerHostLabel(peerUrlOf(status)) || peerUrlOf(status) || 'peer'; + if (status.ok) { + const latency = + typeof status.latency_ms === 'number' ? ` ${Math.round(status.latency_ms)}ms` : ''; + return `$(check) ${host}${latency}`; + } + const err = status.error ? `: ${status.error}` : ''; + return `$(error) ${host}${err}`; +} + +function parseRegistryPeersSetting(): string[] { + const raw = vscode.workspace.getConfiguration('creer').get('registryPeers') || ''; + return raw + .split(',') + .map((p) => p.trim().replace(/\/$/, '')) + .filter(Boolean); +} + +async function saveRegistryPeersSetting(peers: string[]): Promise { + const value = peers.join(', '); + await vscode.workspace + .getConfiguration('creer') + .update('registryPeers', value, vscode.ConfigurationTarget.Global); +} + +/** + * Creer: Browse Federated Registry — GET /registry/federated, QuickPick, install. + */ +export async function browseFederatedRegistryCommand(): Promise { + const config = vscode.workspace.getConfiguration('creer'); + const showPeerStatus = config.get('showPeerStatus') !== false; + const registryPeers = config.get('registryPeers') || ''; + + const q = await vscode.window.showInputBox({ + prompt: 'Search federated registry (leave empty for all packs)', + placeHolder: 'fastapi, express, peer host…', + ignoreFocusOut: true, + }); + if (q === undefined) { + return; + } + + let peerStatuses: PeerStatus[] | undefined; + if (showPeerStatus) { + try { + const statusResp = await fetchPeerStatus(); + peerStatuses = statusResp.peers; + const ok = peerStatuses.filter((p) => p.ok).length; + const fail = peerStatuses.length - ok; + if (peerStatuses.length > 0) { + void vscode.window.showInformationMessage( + `Creer peers: ${ok} ok, ${fail} fail` + + (peerStatuses.some((p) => typeof p.latency_ms === 'number') + ? ` · ${peerStatuses + .filter((p) => p.ok && typeof p.latency_ms === 'number') + .map((p) => `${peerHostLabel(peerUrlOf(p))}:${Math.round(p.latency_ms!)}ms`) + .slice(0, 3) + .join(', ')}` + : '') + ); + } + } catch { + // Soft-fail: peer status endpoints may be missing on older backends. + peerStatuses = undefined; + } + } + + let items: FederatedRegistryItem[]; + let peerCount = 0; + try { + const federated = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: loading federated registry…', + cancellable: false, + }, + () => + fetchFederatedRegistry({ + q: q.trim() || undefined, + peers: registryPeers.trim() || undefined, + }) + ); + items = federated.items; + peerCount = federated.peers?.length ?? 0; + } catch (err) { + const message = formatAxiosError(err, 'Federated registry unavailable'); + void vscode.window.showWarningMessage( + `Creer: federated registry endpoint not available (${message}). ` + + 'Try “Creer: Browse Pack Registry” or configure creer.registryPeers / CREER_REGISTRY_PEERS.' + ); + return; + } + + if (items.length === 0) { + void vscode.window.showInformationMessage( + peerCount > 0 + ? 'Creer: no federated packs matched (peers configured).' + : 'Creer: no federated packs matched.' + ); + return; + } + + type PickItem = vscode.QuickPickItem & { fed?: FederatedRegistryItem }; + const picks: PickItem[] = []; + + if (showPeerStatus && peerStatuses && peerStatuses.length > 0) { + picks.push({ + label: 'Peer health', + kind: vscode.QuickPickItemKind.Separator, + }); + for (const status of peerStatuses) { + picks.push({ + label: formatPeerHealth(status), + description: peerUrlOf(status), + detail: status.ok + ? typeof status.count === 'number' + ? `${status.count} packs` + : 'reachable' + : status.error || 'unreachable', + }); + } + picks.push({ + label: 'Packs', + kind: vscode.QuickPickItemKind.Separator, + }); + } + + const localItems = items.filter((i) => !i.peer); + const remoteItems = items.filter((i) => Boolean(i.peer)); + + const toPick = (item: FederatedRegistryItem): PickItem => { + const host = peerHostLabel(item.peer); + const parts: string[] = []; + if (host) { + parts.push(host); + } else { + parts.push('local'); + } + if (item.source) { + parts.push(item.source); + } + if (item.stack) { + parts.push(item.stack); + } + if (item.version) { + parts.push(item.version); + } + return { + label: host + ? `$(cloud) ${item.name || item.id}` + : `$(home) ${item.name || item.id}`, + description: parts.join(' · '), + detail: item.description, + fed: item, + }; + }; + + for (const item of localItems) { + picks.push(toPick(item)); + } + for (const item of remoteItems) { + picks.push(toPick(item)); + } + + const picked = await vscode.window.showQuickPick(picks, { + placeHolder: 'Select a federated pack to install (peer host shown in description)', + ignoreFocusOut: true, + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!picked?.fed) { + return; + } + + const item = picked.fed; + if (isBundledSource(item.source) && !item.peer) { + const choice = await vscode.window.showInformationMessage( + `“${item.name || item.id}” is bundled locally. Install a copy via download URL anyway?`, + 'Install copy', + 'Cancel' + ); + if (choice !== 'Install copy') { + return; + } + } + + await installFromResolvedUrl( + item.name || item.id, + item.install_url || item.download_url + ); +} + +/** + * Creer: Manage Registry Peers — view/add/remove setting peers; probe via backend. + */ +export async function manageRegistryPeersCommand(): Promise { + const settingPeers = parseRegistryPeersSetting(); + + let configured: string[] = []; + let livePeers: PeerStatus[] = []; + let peersEndpointOk = true; + try { + const status = await fetchPeerStatus(); + configured = status.configured ?? []; + livePeers = status.peers ?? []; + } catch { + peersEndpointOk = false; + } + + const lines: string[] = []; + lines.push( + settingPeers.length + ? `Setting peers (${settingPeers.length}): ${settingPeers.join(', ')}` + : 'Setting peers: (none)' + ); + if (peersEndpointOk) { + lines.push( + configured.length + ? `Backend configured (${configured.length}): ${configured.join(', ')}` + : 'Backend configured: (none)' + ); + if (livePeers.length > 0) { + lines.push( + 'Status: ' + + livePeers + .map((p) => { + const host = peerHostLabel(peerUrlOf(p)) || peerUrlOf(p); + return p.ok + ? `${host} ok${typeof p.latency_ms === 'number' ? ` ${Math.round(p.latency_ms)}ms` : ''}` + : `${host} fail`; + }) + .join('; ') + ); + } + } else { + lines.push('Backend peer endpoints unavailable (soft-fail). You can still edit creer.registryPeers.'); + } + + type Action = 'add' | 'remove' | 'probe' | 'done'; + type ActionPick = vscode.QuickPickItem & { action?: Action }; + + const actions: ActionPick[] = [ + { + label: '$(info) Current peers', + description: 'Summary', + detail: lines.join('\n'), + }, + { + label: '$(add) Add peer', + description: 'Probe URL then append to creer.registryPeers', + action: 'add', + }, + { + label: '$(remove) Remove peer', + description: 'Remove from creer.registryPeers', + action: 'remove', + }, + { + label: '$(debug-alt) Probe all', + description: peersEndpointOk + ? 'POST /registry/peers/probe for each setting peer' + : 'Requires backend probe endpoint', + action: 'probe', + }, + ]; + + const picked = await vscode.window.showQuickPick(actions, { + placeHolder: 'Creer: Manage Registry Peers', + ignoreFocusOut: true, + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!picked?.action) { + if (picked && !picked.action) { + void vscode.window.showInformationMessage(`Creer:\n${lines.join('\n')}`); + } + return; + } + + if (picked.action === 'add') { + const url = await vscode.window.showInputBox({ + prompt: 'Peer base URL (e.g. https://creer.example.com)', + placeHolder: 'http://localhost:8001', + ignoreFocusOut: true, + }); + const trimmed = url?.trim().replace(/\/$/, ''); + if (!trimmed) { + return; + } + + let ok = true; + let probeError: string | undefined; + try { + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Creer: probing ${trimmed}…`, + cancellable: false, + }, + () => probePeer(trimmed) + ); + ok = result.ok; + probeError = result.error || undefined; + if (ok) { + const latency = + typeof result.latency_ms === 'number' + ? ` (${Math.round(result.latency_ms)}ms)` + : ''; + void vscode.window.showInformationMessage( + `Creer: peer reachable${latency}.` + ); + } + } catch (err) { + // Soft-fail: if probe endpoint missing, still allow adding after confirm. + const message = formatAxiosError(err, 'Probe failed'); + const choice = await vscode.window.showWarningMessage( + `Creer: could not probe peer (${message}). Add anyway?`, + 'Add', + 'Cancel' + ); + if (choice !== 'Add') { + return; + } + ok = true; + } + + if (!ok) { + void vscode.window.showErrorMessage( + `Creer: peer probe failed${probeError ? ` — ${probeError}` : ''}. Not added.` + ); + return; + } + + const next = parseRegistryPeersSetting(); + if (!next.includes(trimmed)) { + next.push(trimmed); + await saveRegistryPeersSetting(next); + } + void vscode.window.showInformationMessage( + `Creer: added peer ${trimmed} to creer.registryPeers.` + ); + return; + } + + if (picked.action === 'remove') { + const current = parseRegistryPeersSetting(); + if (current.length === 0) { + void vscode.window.showInformationMessage('Creer: no setting peers to remove.'); + return; + } + const toRemove = await vscode.window.showQuickPick( + current.map((url) => ({ label: url, description: peerHostLabel(url) })), + { + placeHolder: 'Select peer to remove from creer.registryPeers', + ignoreFocusOut: true, + canPickMany: true, + } + ); + if (!toRemove || toRemove.length === 0) { + return; + } + const removeSet = new Set(toRemove.map((p) => p.label)); + const remaining = current.filter((u) => !removeSet.has(u)); + await saveRegistryPeersSetting(remaining); + void vscode.window.showInformationMessage( + `Creer: removed ${toRemove.length} peer(s) from creer.registryPeers.` + ); + return; + } + + if (picked.action === 'probe') { + const current = parseRegistryPeersSetting(); + const targets = + current.length > 0 + ? current + : configured.length > 0 + ? configured + : []; + if (targets.length === 0) { + void vscode.window.showInformationMessage( + 'Creer: no peers to probe. Add peers via this command or CREER_REGISTRY_PEERS.' + ); + return; + } + + const results: string[] = []; + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: probing peers…', + cancellable: false, + }, + async (progress) => { + for (let i = 0; i < targets.length; i++) { + const url = targets[i]; + progress.report({ + message: `${i + 1}/${targets.length} ${peerHostLabel(url) || url}`, + }); + try { + const status = await probePeer(url); + results.push(formatPeerHealth(status).replace(/\$\([^)]+\)\s*/g, '')); + } catch (err) { + results.push( + `${peerHostLabel(url) || url}: ${formatAxiosError(err, 'probe failed')}` + ); + } + } + } + ); + + void vscode.window.showInformationMessage( + `Creer probe results:\n${results.join('\n')}` + ); + } +} diff --git a/extension/src/scaffold.ts b/extension/src/scaffold.ts new file mode 100644 index 0000000..013a938 --- /dev/null +++ b/extension/src/scaffold.ts @@ -0,0 +1,658 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { + createGitHubRepo, + fetchBakeins, + fetchPacks, + fetchTemplates, + formatAxiosError, + postGenerate, + postPlan, + type BakeinOptions, + type CiBakein, + type GenerateResponse, + type LicenseBakein, + type Pack, + type PlanResponse, + type QualityIssue, + type Template, +} from './api'; +import { showContentPreviewAndConfirm } from './contentPreview'; +import { addRemoteAndPush, ensureGitRepo, initGit } from './git'; +import { showPlanPreviewAndConfirm } from './preview'; +import { resolveGitHubToken } from './secrets'; +import { + isCancellationError, + streamGenerate, + type StreamProgressEvent, +} from './streamGenerate'; +import { resolveConflictsWithDiffs } from './conflictDiff'; +import { pickWorkspaceRoot } from './workspace'; +import { + assertSafeProjectName, + findConflicts, + writeProjectFiles, +} from './writeFiles'; + +export interface ScaffoldOptions { + /** Extension context (required for SecretStorage). */ + context: vscode.ExtensionContext; + /** Pre-filled idea (e.g. from chat). If omitted, prompts the user. */ + idea?: string; + /** Use chat-style InputBox placeholder (/creer …). */ + fromChat?: boolean; +} + +export type SourcePick = + | { kind: 'ai' } + | { kind: 'template'; id: string } + | { kind: 'pack'; id: string }; + +const LICENSE_FALLBACK: Array<{ id: LicenseBakein; name: string }> = [ + { id: 'mit', name: 'MIT' }, + { id: 'apache-2.0', name: 'Apache-2.0' }, + { id: 'none', name: 'None' }, +]; + +const CI_FALLBACK: Array<{ id: CiBakein; name: string }> = [ + { id: 'auto', name: 'Auto (detect from stack)' }, + { id: 'python', name: 'Python' }, + { id: 'node', name: 'Node' }, + { id: 'none', name: 'None' }, +]; + +function stripCreerPrefix(raw: string): string { + return raw.replace(/^\s*\/creer\b\s*/i, '').trim(); +} + +async function promptForIdea(fromChat: boolean, initial?: string): Promise { + if (initial?.trim()) { + const stripped = stripCreerPrefix(initial); + if (stripped) { + return stripped; + } + } + + const value = await vscode.window.showInputBox({ + prompt: fromChat + ? 'Enter a /creer prompt describing the project to scaffold' + : 'Describe the project you want to create', + placeHolder: fromChat + ? '/creer Build a FastAPI todo' + : 'Build a FastAPI todo app', + value: initial?.trim() || undefined, + ignoreFocusOut: true, + }); + + if (!value?.trim()) { + return undefined; + } + return stripCreerPrefix(value); +} + +/** + * QuickPick: AI plan | built-in templates | packs. + * Returns null if cancelled. + */ +async function pickSource( + templates: Template[], + packs: Pack[] +): Promise { + type Item = vscode.QuickPickItem & { + kindSelect?: SourcePick['kind']; + sourceId?: string; + }; + + const items: Item[] = [ + { + label: 'AI plan (no template)', + description: 'Let Creer choose the stack and file layout', + kindSelect: 'ai', + }, + ]; + + if (templates.length > 0) { + items.push({ + label: 'Built-in templates', + kind: vscode.QuickPickItemKind.Separator, + }); + for (const t of templates) { + items.push({ + label: t.name, + description: t.stack, + detail: t.description, + kindSelect: 'template', + sourceId: t.id, + }); + } + } + + if (packs.length > 0) { + items.push({ + label: 'Packs', + kind: vscode.QuickPickItemKind.Separator, + }); + for (const p of packs) { + const versionNote = p.version ? ` v${p.version}` : ''; + items.push({ + label: `[pack] ${p.name}`, + description: `${p.stack}${versionNote}`, + detail: p.description, + kindSelect: 'pack', + sourceId: p.id, + }); + } + } + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Select AI plan, template, or pack', + ignoreFocusOut: true, + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!picked || picked.kind === vscode.QuickPickItemKind.Separator) { + return null; + } + + if (picked.kindSelect === 'template' && picked.sourceId) { + return { kind: 'template', id: picked.sourceId }; + } + if (picked.kindSelect === 'pack' && picked.sourceId) { + return { kind: 'pack', id: picked.sourceId }; + } + return { kind: 'ai' }; +} + +function isLicenseBakein(v: string): v is LicenseBakein { + return v === 'mit' || v === 'apache-2.0' || v === 'none'; +} + +function isCiBakein(v: string): v is CiBakein { + return v === 'auto' || v === 'python' || v === 'node' || v === 'none'; +} + +/** + * Resolve bake-in options from settings, optionally prompting via QuickPick + * when `creer.promptBakeins` is true. + * Returns null if the user cancels a prompt. + */ +async function resolveBakeins(): Promise { + const config = vscode.workspace.getConfiguration('creer'); + const promptBakeins = config.get('promptBakeins') ?? true; + const settingsLicense = config.get('license') ?? 'mit'; + const settingsCi = config.get('ciPreset') ?? 'auto'; + + const defaultLicense: LicenseBakein = isLicenseBakein(settingsLicense) + ? settingsLicense + : 'mit'; + const defaultCi: CiBakein = isCiBakein(settingsCi) ? settingsCi : 'auto'; + + if (!promptBakeins) { + return { license: defaultLicense, ci: defaultCi }; + } + + let licenses: Array<{ id: string; name: string }> = LICENSE_FALLBACK.map((l) => ({ + id: l.id, + name: l.name, + })); + let ciOptions: Array<{ id: string; name: string }> = CI_FALLBACK.map((c) => ({ + id: c.id, + name: c.name, + })); + + try { + const remote = await fetchBakeins(); + if (remote.licenses.length > 0) { + licenses = remote.licenses; + } + if (remote.ci.length > 0) { + ciOptions = remote.ci; + } + } catch { + // Use built-in fallbacks when /bakeins is unavailable. + } + + const licenseItems: Array = licenses.map( + (l) => ({ + label: l.name, + description: l.id, + value: l.id, + }) + ); + const licensePick = await vscode.window.showQuickPick(licenseItems, { + placeHolder: `Select a license (default: ${defaultLicense})`, + ignoreFocusOut: true, + matchOnDescription: true, + }); + if (!licensePick) { + return null; + } + + const ciItems: Array = ciOptions.map((c) => ({ + label: c.name, + description: c.id, + value: c.id, + })); + const ciPick = await vscode.window.showQuickPick(ciItems, { + placeHolder: `Select a CI preset (default: ${defaultCi})`, + ignoreFocusOut: true, + matchOnDescription: true, + }); + if (!ciPick) { + return null; + } + + const license = isLicenseBakein(licensePick.value) ? licensePick.value : defaultLicense; + const ci = isCiBakein(ciPick.value) ? ciPick.value : defaultCi; + return { license, ci }; +} + +async function maybeCreateGitHubRemote( + context: vscode.ExtensionContext, + projectPath: string, + projectName: string, + idea: string +): Promise { + const config = vscode.workspace.getConfiguration('creer'); + const autoCreate = config.get('createGitHubRepo') ?? false; + const isPrivate = config.get('githubPrivate') ?? true; + + let shouldCreate = autoCreate; + if (!shouldCreate) { + const answer = await vscode.window.showInformationMessage( + 'Create GitHub repository?', + 'Yes', + 'No' + ); + shouldCreate = answer === 'Yes'; + } + + if (!shouldCreate) { + return; + } + + const token = await resolveGitHubToken(context); + if (!token) { + vscode.window.showWarningMessage('GitHub token required to create a repository. Skipped.'); + return; + } + + try { + const repo = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: creating GitHub repository…', + cancellable: false, + }, + async () => + createGitHubRepo(token, projectName, { + private: isPrivate, + description: idea.slice(0, 200), + }) + ); + + await ensureGitRepo(projectPath); + await addRemoteAndPush(projectPath, repo.clone_url, token); + + const open = await vscode.window.showInformationMessage( + `GitHub repo created: ${repo.html_url}`, + 'Open' + ); + if (open === 'Open') { + await vscode.env.openExternal(vscode.Uri.parse(repo.html_url)); + } + } catch (err) { + const message = formatAxiosError(err, 'GitHub create/push failed'); + vscode.window.showWarningMessage(`Project created, but GitHub step failed: ${message}`); + } +} + +function reportStreamProgress( + progress: vscode.Progress<{ message?: string; increment?: number }>, + ev: StreamProgressEvent +): void { + if (ev.event === 'start') { + const jobNote = ev.job_id ? ` · job ${ev.job_id}` : ''; + progress.report({ + message: `Starting ${ev.project_name} (${ev.total} files)${jobNote}…`, + }); + return; + } + if (ev.event === 'file') { + const status = ev.status === 'done' ? 'done' : 'generating'; + progress.report({ + message: `[${ev.index}/${ev.total}] ${ev.path} (${status})`, + }); + } +} + +function reportQualityIssues(quality: QualityIssue[] | undefined): void { + if (!quality || quality.length === 0) { + return; + } + + const errors = quality.filter((q) => { + const s = (q.severity || '').toLowerCase(); + return s === 'error' || s === 'critical' || s === 'fatal'; + }); + const warnings = quality.filter((q) => { + const s = (q.severity || '').toLowerCase(); + return s === 'warning' || s === 'warn'; + }); + const other = quality.length - errors.length - warnings.length; + + if (errors.length > 0) { + const sample = errors + .slice(0, 3) + .map((e) => (e.path ? `${e.code} (${e.path})` : e.code)) + .join(', '); + const more = errors.length > 3 ? ` (+${errors.length - 3} more)` : ''; + void vscode.window.showErrorMessage( + `Creer quality gates reported ${errors.length} error(s)` + + (warnings.length ? `, ${warnings.length} warning(s)` : '') + + `: ${sample}${more}` + ); + return; + } + + const parts: string[] = []; + if (warnings.length) { + parts.push(`${warnings.length} warning(s)`); + } + if (other > 0) { + parts.push(`${other} other issue(s)`); + } + void vscode.window.showWarningMessage( + `Creer quality gates: ${parts.join(', ') || `${quality.length} issue(s)`}.` + ); +} + +function sourceToIds(source: SourcePick): { + templateId?: string; + packId?: string; +} { + if (source.kind === 'template') { + return { templateId: source.id }; + } + if (source.kind === 'pack') { + return { packId: source.id }; + } + return {}; +} + +async function generateWithOptionalStream( + idea: string, + plan: PlanResponse, + source: SourcePick, + useStreaming: boolean, + bakeins: BakeinOptions +): Promise { + const { templateId, packId } = sourceToIds(source); + const resolvedTemplateId = plan.template_id ?? templateId; + const resolvedPackId = plan.pack_id ?? packId; + + // pack_id and template_id are mutually exclusive + const genOpts = { + templateId: resolvedPackId ? undefined : resolvedTemplateId, + packId: resolvedPackId, + plan, + bakeins, + }; + + if (!useStreaming) { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: generating project…', + cancellable: false, + }, + () => postGenerate(idea, genOpts) + ); + } + + try { + return await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: generating project…', + cancellable: true, + }, + async (progress, cancellationToken) => { + const controller = new AbortController(); + const sub = cancellationToken.onCancellationRequested(() => { + controller.abort(); + }); + try { + return await streamGenerate({ + idea, + templateId: genOpts.templateId, + packId: genOpts.packId, + plan: genOpts.plan, + bakeins: genOpts.bakeins, + signal: controller.signal, + onProgress: (ev) => reportStreamProgress(progress, ev), + }); + } finally { + sub.dispose(); + } + } + ); + } catch (err) { + if (isCancellationError(err)) { + throw err; + } + const message = formatAxiosError(err, 'Streaming generate failed'); + vscode.window.showWarningMessage( + `Streaming failed (${message}); falling back to non-streaming generate.` + ); + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: generating project (fallback)…', + cancellable: false, + }, + () => postGenerate(idea, genOpts) + ); + } +} + +/** + * Shared scaffold flow used by createRepo, createRepoFromChat, and the chat participant. + */ +export async function runScaffoldFlow(options: ScaffoldOptions): Promise { + const { context } = options; + const fromChat = options.fromChat ?? false; + const idea = await promptForIdea(fromChat, options.idea); + if (!idea) { + return; + } + + const rootPath = await pickWorkspaceRoot(); + if (!rootPath) { + vscode.window.showErrorMessage('Open a workspace folder first.'); + return; + } + + const config = vscode.workspace.getConfiguration('creer'); + const shouldInitGit = config.get('initGit') ?? true; + const previewBeforeWrite = config.get('previewBeforeWrite') ?? true; + const useStreaming = config.get('useStreaming') ?? true; + + try { + // 1) Templates + packs + let templates: Template[] = []; + let packs: Pack[] = []; + + try { + templates = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: loading templates…', + cancellable: false, + }, + () => fetchTemplates() + ); + } catch (err) { + const message = formatAxiosError(err, 'Failed to load templates'); + vscode.window.showWarningMessage( + `Could not load templates (${message}). Continuing with AI plan only.` + ); + } + + try { + packs = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: loading packs…', + cancellable: false, + }, + () => fetchPacks() + ); + } catch { + // Backend /packs may not be ready yet — show templates only. + packs = []; + } + + const source = await pickSource(templates, packs); + if (!source) { + return; + } + const { templateId, packId } = sourceToIds(source); + + // 2) Plan + const plan: PlanResponse = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creer: planning project…', + cancellable: false, + }, + () => postPlan(idea, { templateId, packId }) + ); + + if (!plan.project_name || !Array.isArray(plan.files)) { + vscode.window.showErrorMessage('Creer backend returned an invalid plan.'); + return; + } + + if (templateId && !plan.template_id) { + plan.template_id = templateId; + } + if (packId && !plan.pack_id) { + plan.pack_id = packId; + } + + // 3) Plan preview / confirm (tree) before generate + if (previewBeforeWrite) { + const confirmed = await showPlanPreviewAndConfirm(plan, idea); + if (!confirmed) { + return; + } + } + + // 4) Bake-ins (license / CI) + const bakeins = await resolveBakeins(); + if (!bakeins) { + return; + } + + // 5) Generate (streaming with cancellable progress when enabled) + let generated: GenerateResponse; + try { + generated = await generateWithOptionalStream( + idea, + plan, + source, + useStreaming, + bakeins + ); + } catch (err) { + if (isCancellationError(err)) { + void vscode.window.showInformationMessage('Creer generation cancelled.'); + return; + } + throw err; + } + + reportQualityIssues(generated.quality); + + const projectName = generated.project_name || plan.project_name; + const files = generated.files; + + if (!projectName || !files || typeof files !== 'object') { + vscode.window.showErrorMessage('Creer backend returned an invalid generate response.'); + return; + } + + try { + assertSafeProjectName(projectName); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Creer refused unsafe project name: ${message}`); + return; + } + + const projectPath = path.join(rootPath, projectName); + // Ensure the resolved project folder stays under the workspace root + const resolvedRoot = path.resolve(rootPath); + const resolvedProject = path.resolve(projectPath); + const rootPrefix = resolvedRoot.endsWith(path.sep) + ? resolvedRoot + : resolvedRoot + path.sep; + if (resolvedProject !== resolvedRoot && !resolvedProject.startsWith(rootPrefix)) { + vscode.window.showErrorMessage( + `Creer refused project path outside workspace: ${projectName}` + ); + return; + } + + // 6) Content preview / diff before write + const contentConfirmed = await showContentPreviewAndConfirm( + projectName, + projectPath, + files + ); + if (!contentConfirmed) { + return; + } + + // 7) Conflict resolution (optional side-by-side diffs) + const conflicts = findConflicts(projectPath, files); + const resolution = await resolveConflictsWithDiffs(projectPath, files, conflicts); + if (resolution === 'cancel') { + return; + } + + // 8) Write + const { written, skipped } = writeProjectFiles(projectPath, files, resolution); + if (written === 0 && skipped === 0) { + vscode.window.showWarningMessage('No files were written.'); + return; + } + + // 9) Git init + if (shouldInitGit) { + try { + await initGit(projectPath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showWarningMessage(`Project created, but git init failed: ${message}`); + } + } + + // 10) GitHub remote (optional) + await maybeCreateGitHubRemote(context, projectPath, projectName, idea); + + const skipNote = skipped > 0 ? ` (${skipped} existing skipped)` : ''; + vscode.window.showInformationMessage( + `Project ${projectName} created at ${projectPath}${skipNote}.` + ); + } catch (err) { + if (isCancellationError(err)) { + void vscode.window.showInformationMessage('Creer generation cancelled.'); + return; + } + const message = formatAxiosError(err, 'Creer failed'); + vscode.window.showErrorMessage(`Creer failed: ${message}`); + } +} diff --git a/extension/src/secrets.ts b/extension/src/secrets.ts new file mode 100644 index 0000000..a1341fd --- /dev/null +++ b/extension/src/secrets.ts @@ -0,0 +1,91 @@ +import * as vscode from 'vscode'; + +/** SecretStorage key for the GitHub personal access token. */ +export const GITHUB_TOKEN_SECRET_KEY = 'creer.githubToken'; + +/** + * Read GitHub token: SecretStorage first, then deprecated config fallback. + */ +export async function getGitHubToken( + context: vscode.ExtensionContext +): Promise { + const secret = (await context.secrets.get(GITHUB_TOKEN_SECRET_KEY))?.trim(); + if (secret) { + return secret; + } + + // Migration path: plaintext setting (deprecated) + const fromConfig = ( + vscode.workspace.getConfiguration('creer').get('githubToken') || '' + ).trim(); + return fromConfig || undefined; +} + +export async function setGitHubToken( + context: vscode.ExtensionContext, + token: string +): Promise { + await context.secrets.store(GITHUB_TOKEN_SECRET_KEY, token.trim()); +} + +export async function clearGitHubToken(context: vscode.ExtensionContext): Promise { + await context.secrets.delete(GITHUB_TOKEN_SECRET_KEY); +} + +/** + * Prompt for a token and optionally persist it in SecretStorage. + */ +export async function promptAndStoreGitHubToken( + context: vscode.ExtensionContext +): Promise { + const token = await vscode.window.showInputBox({ + prompt: 'GitHub personal access token (repo scope)', + placeHolder: 'ghp_…', + password: true, + ignoreFocusOut: true, + }); + + const trimmed = token?.trim(); + if (!trimmed) { + return undefined; + } + + const saveChoice = await vscode.window.showQuickPick( + [ + { + label: 'Save to SecretStorage (recommended)', + description: 'Stored securely; preferred over settings.json', + id: 'save', + }, + { + label: 'Use once (do not save)', + description: 'Token is only used for this operation', + id: 'once', + }, + ], + { + placeHolder: 'Save GitHub token?', + ignoreFocusOut: true, + } + ); + + if (saveChoice?.id === 'save') { + await setGitHubToken(context, trimmed); + void vscode.window.showInformationMessage('Creer: GitHub token saved to SecretStorage.'); + } + + return trimmed; +} + +/** + * Resolve token: SecretStorage → config fallback → prompt (offer save). + */ +export async function resolveGitHubToken( + context: vscode.ExtensionContext +): Promise { + const existing = await getGitHubToken(context); + if (existing) { + return existing; + } + return promptAndStoreGitHubToken(context); +} diff --git a/extension/src/streamGenerate.ts b/extension/src/streamGenerate.ts new file mode 100644 index 0000000..4517d8d --- /dev/null +++ b/extension/src/streamGenerate.ts @@ -0,0 +1,382 @@ +import axios from 'axios'; +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; +import * as vscode from 'vscode'; +import type { BakeinOptions, GenerateResponse, PlanResponse, QualityIssue } from './api'; + +export type StreamStartEvent = { + event: 'start'; + project_name: string; + total: number; + stack: string; + job_id?: string; +}; + +export type StreamFileEvent = { + event: 'file'; + index: number; + total: number; + path: string; + status: 'generating' | 'done'; + bytes?: number; +}; + +export type StreamDoneEvent = { + event: 'done'; + project_name: string; + stack: string; + files: Record; + template_id?: string; + pack_id?: string; + quality?: QualityIssue[]; +}; + +export type StreamErrorEvent = { + event: 'error'; + detail: string; +}; + +export type StreamCancelledEvent = { + event: 'cancelled'; + job_id: string; + detail: string; +}; + +export type StreamProgressEvent = + | StreamStartEvent + | StreamFileEvent + | StreamDoneEvent + | StreamErrorEvent + | StreamCancelledEvent; + +export interface StreamGenerateOptions { + idea: string; + templateId?: string; + packId?: string; + plan?: PlanResponse; + bakeins?: BakeinOptions; + jobId?: string; + signal?: AbortSignal; + onJobId?: (jobId: string) => void; + onProgress?: (event: StreamProgressEvent) => void; +} + +/** Thrown when the user or AbortSignal cancels streaming generation. */ +export class CancelledError extends Error { + readonly jobId?: string; + + constructor(message = 'Generation cancelled', jobId?: string) { + super(message); + this.name = 'AbortError'; + this.jobId = jobId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isCancellationError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false; + } + const e = err as { name?: string; code?: string }; + return ( + err instanceof CancelledError || + e.name === 'AbortError' || + e.name === 'CancelledError' || + e.code === 'ERR_CANCELED' + ); +} + +function getBackendUrl(): string { + const config = vscode.workspace.getConfiguration('creer'); + return (config.get('backendUrl') || 'http://localhost:8000').replace(/\/$/, ''); +} + +function parseSseChunk( + buffer: string, + onEvent: (data: StreamProgressEvent) => void +): string { + // Normalize CRLF so event separators are always `\n\n` (proxies / Windows). + let remaining = buffer.replace(/\r\n/g, '\n'); + for (;;) { + const sep = remaining.indexOf('\n\n'); + if (sep === -1) { + break; + } + const rawEvent = remaining.slice(0, sep); + remaining = remaining.slice(sep + 2); + + for (const line of rawEvent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) { + continue; + } + const payload = trimmed.slice(5).trim(); + if (!payload || payload === '[DONE]') { + continue; + } + try { + const parsed = JSON.parse(payload) as StreamProgressEvent; + if (parsed && typeof parsed === 'object' && 'event' in parsed) { + onEvent(parsed); + } + } catch { + // Ignore malformed JSON fragments; wait for a complete event. + } + } + } + return remaining; +} + +/** + * POST /generate/stream and parse SSE (`data: {...}\\n\\n`). + * Uses Node http/https so streaming works in the VS Code extension CommonJS host. + * Supports AbortSignal: posts /generate/cancel with job_id (when known) and destroys the request. + */ +export function streamGenerate(options: StreamGenerateOptions): Promise { + const backendUrl = getBackendUrl(); + const url = new URL(`${backendUrl}/generate/stream`); + const body: { + idea: string; + template_id?: string; + pack_id?: string; + plan?: PlanResponse; + job_id?: string; + bakeins?: BakeinOptions; + } = { idea: options.idea }; + if (options.packId) { + body.pack_id = options.packId; + } else if (options.templateId) { + body.template_id = options.templateId; + } + if (options.plan) { + body.plan = options.plan; + } + if (options.jobId) { + body.job_id = options.jobId; + } + if (options.bakeins) { + body.bakeins = options.bakeins; + } + + const payload = JSON.stringify(body); + const lib = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + let settled = false; + let doneResult: GenerateResponse | undefined; + let buffer = ''; + let jobId: string | undefined = options.jobId; + let cancelPosted = false; + let req: http.ClientRequest; + + const fail = (err: Error) => { + if (settled) { + return; + } + settled = true; + cleanupAbort(); + reject(err); + }; + + const succeed = (result: GenerateResponse) => { + if (settled) { + return; + } + settled = true; + cleanupAbort(); + resolve(result); + }; + + const destroyRequest = () => { + try { + req.destroy(); + } catch { + // ignore + } + }; + + const requestBackendCancel = () => { + if (cancelPosted || !jobId) { + return; + } + cancelPosted = true; + // Inline cancel call to avoid a circular import with api.ts. + void axios + .post( + `${backendUrl}/generate/cancel`, + { job_id: jobId }, + { timeout: 30_000 } + ) + .catch(() => { + // Best-effort; local abort still tears down the HTTP stream. + }); + }; + + const abortNow = () => { + requestBackendCancel(); + destroyRequest(); + fail(new CancelledError('Generation cancelled', jobId)); + }; + + const onAbort = () => { + abortNow(); + }; + + const cleanupAbort = () => { + if (options.signal) { + options.signal.removeEventListener('abort', onAbort); + } + }; + + if (options.signal?.aborted) { + // No request yet — reject immediately (and cancel if we already have a job id). + requestBackendCancel(); + fail(new CancelledError('Generation cancelled', jobId)); + return; + } + + const handleEvent = (ev: StreamProgressEvent) => { + options.onProgress?.(ev); + + if (ev.event === 'start') { + if (ev.job_id) { + jobId = ev.job_id; + options.onJobId?.(ev.job_id); + // Race: user may have aborted between request start and start event. + if (options.signal?.aborted) { + abortNow(); + } + } + return; + } + + if (ev.event === 'cancelled') { + destroyRequest(); + fail( + new CancelledError( + ev.detail || 'Generation cancelled', + ev.job_id || jobId + ) + ); + return; + } + + if (ev.event === 'error') { + // Stop reading further events; destroy the socket so we do not hang. + destroyRequest(); + fail(new Error(ev.detail || 'Streaming generation failed')); + return; + } + + if (ev.event === 'done') { + doneResult = { + project_name: ev.project_name, + stack: ev.stack, + files: ev.files, + }; + if (ev.template_id) { + doneResult.template_id = ev.template_id; + } + if (ev.pack_id) { + doneResult.pack_id = ev.pack_id; + } + if (ev.quality) { + doneResult.quality = ev.quality; + } + } + }; + + req = lib.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: `${url.pathname}${url.search}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'Content-Length': Buffer.byteLength(payload), + Connection: 'keep-alive', + }, + timeout: 600_000, + }, + (res) => { + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + let detail = text; + try { + const parsed = JSON.parse(text) as { detail?: string }; + if (typeof parsed.detail === 'string') { + detail = parsed.detail; + } + } catch { + // keep raw text + } + fail(new Error(detail || `HTTP ${status} from /generate/stream`)); + }); + return; + } + + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + buffer = parseSseChunk(buffer + chunk, handleEvent); + }); + res.on('end', () => { + if (buffer.trim()) { + parseSseChunk(buffer + '\n\n', handleEvent); + } + if (doneResult) { + succeed(doneResult); + } else if (!settled) { + fail(new Error('Stream ended without a done event')); + } + }); + res.on('error', (err) => { + if (settled) { + return; + } + // Destroy after abort often surfaces as a socket error — treat as cancel. + if (options.signal?.aborted) { + fail(new CancelledError('Generation cancelled', jobId)); + return; + } + fail(err instanceof Error ? err : new Error(String(err))); + }); + } + ); + + req.on('timeout', () => { + req.destroy(); + fail(new Error('Streaming generation timed out')); + }); + req.on('error', (err) => { + if (settled) { + return; + } + if (options.signal?.aborted || isCancellationError(err)) { + fail(new CancelledError('Generation cancelled', jobId)); + return; + } + // Node often emits Error with code after destroy(); ignore if we already aborted. + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ECONNRESET' && cancelPosted) { + fail(new CancelledError('Generation cancelled', jobId)); + return; + } + fail(err instanceof Error ? err : new Error(String(err))); + }); + + if (options.signal) { + options.signal.addEventListener('abort', onAbort, { once: true }); + } + + req.write(payload); + req.end(); + }); +} diff --git a/extension/src/workspace.ts b/extension/src/workspace.ts new file mode 100644 index 0000000..618d399 --- /dev/null +++ b/extension/src/workspace.ts @@ -0,0 +1,44 @@ +import * as vscode from 'vscode'; + +/** + * Resolve which workspace folder root to use for scaffolding. + * - Single folder → that folder's fsPath + * - Multiple → honor `creer.defaultWorkspaceFolder` if it matches name or fsPath; + * otherwise QuickPick by folder name / path + * Returns undefined if no folders are open or the user cancels the pick. + */ +export async function pickWorkspaceRoot(): Promise { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + return undefined; + } + + if (folders.length === 1) { + return folders[0].uri.fsPath; + } + + const config = vscode.workspace.getConfiguration('creer'); + const hint = (config.get('defaultWorkspaceFolder') || '').trim(); + if (hint) { + const matched = folders.find( + (f) => f.name === hint || f.uri.fsPath === hint || f.uri.fsPath.endsWith(hint) + ); + if (matched) { + return matched.uri.fsPath; + } + } + + const items: Array = folders.map((f) => ({ + label: f.name, + description: f.uri.fsPath, + fsPath: f.uri.fsPath, + })); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a workspace folder for the new project', + ignoreFocusOut: true, + matchOnDescription: true, + }); + + return picked?.fsPath; +} diff --git a/extension/src/writeFiles.ts b/extension/src/writeFiles.ts new file mode 100644 index 0000000..af5f6c9 --- /dev/null +++ b/extension/src/writeFiles.ts @@ -0,0 +1,139 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +export type ConflictResolution = 'overwrite' | 'skip' | 'cancel'; + +/** Safe project folder name: alphanumeric start, then [a-zA-Z0-9._-], max 64. */ +const SAFE_PROJECT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/; + +/** + * Validate project_name before joining under the workspace root. + * Rejects path separators and `..` so the folder cannot escape the workspace. + */ +export function assertSafeProjectName(name: string): void { + if (typeof name !== 'string' || !name) { + throw new Error(`Invalid project name: ${JSON.stringify(name)}`); + } + if (name.includes('\0') || name.includes('/') || name.includes('\\')) { + throw new Error(`Unsafe project name: ${JSON.stringify(name)}`); + } + // Reject `.` / `..` as the whole name (path-like); substring `foo..bar` is allowed by backend SAFE_NAME + if (name === '.' || name === '..') { + throw new Error(`Unsafe project name: ${JSON.stringify(name)}`); + } + if (!SAFE_PROJECT_NAME.test(name)) { + throw new Error( + `Invalid project name (use 1–64 chars, alphanumeric start, [a-zA-Z0-9._-]): ${JSON.stringify(name)}` + ); + } +} + +/** + * Resolve a relative path under projectPath and ensure it cannot escape the project root. + * Rejects absolute paths, null bytes, and `..` traversal (defense in depth vs backend). + */ +export function resolveSafeProjectPath(projectPath: string, relativePath: string): string { + if (typeof relativePath !== 'string' || !relativePath) { + throw new Error(`Invalid file path: ${JSON.stringify(relativePath)}`); + } + if (relativePath.includes('\0')) { + throw new Error(`Unsafe file path (null byte): ${JSON.stringify(relativePath)}`); + } + if (path.isAbsolute(relativePath)) { + throw new Error(`Absolute file paths are not allowed: ${JSON.stringify(relativePath)}`); + } + // Windows drive / UNC when running on win32; also reject drive-like prefixes on any OS + if (/^[a-zA-Z]:/.test(relativePath) || relativePath.startsWith('\\\\')) { + throw new Error(`Windows drive/UNC paths are not allowed: ${JSON.stringify(relativePath)}`); + } + + const normalizedSep = relativePath.replace(/\\/g, '/'); + const segments = normalizedSep.split('/'); + if (segments.some((s) => s === '..')) { + throw new Error(`Path traversal ('..') is not allowed: ${JSON.stringify(relativePath)}`); + } + if (segments.some((s) => s === '')) { + // Leading/trailing/double slashes → empty segment + throw new Error(`Invalid file path: ${JSON.stringify(relativePath)}`); + } + + const root = path.resolve(projectPath); + const fullPath = path.resolve(root, ...segments); + const prefix = root.endsWith(path.sep) ? root : root + path.sep; + if (fullPath !== root && !fullPath.startsWith(prefix)) { + throw new Error(`Path escapes project root: ${JSON.stringify(relativePath)}`); + } + return fullPath; +} + +export function findConflicts( + projectPath: string, + files: Record +): string[] { + const conflicts: string[] = []; + for (const relativePath of Object.keys(files)) { + const fullPath = resolveSafeProjectPath(projectPath, relativePath); + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { + conflicts.push(relativePath); + } + } + return conflicts.sort((a, b) => a.localeCompare(b)); +} + +export async function resolveConflicts(conflicts: string[]): Promise { + if (conflicts.length === 0) { + return 'overwrite'; + } + + const preview = conflicts.slice(0, 10); + const extra = conflicts.length > 10 ? `\n…and ${conflicts.length - 10} more` : ''; + const list = preview.map((p) => `• ${p}`).join('\n'); + + const choice = await vscode.window.showWarningMessage( + `${conflicts.length} file(s) already exist under the project folder:\n${list}${extra}`, + { modal: true }, + 'Overwrite all', + 'Skip existing', + 'Cancel' + ); + + if (choice === 'Overwrite all') { + return 'overwrite'; + } + if (choice === 'Skip existing') { + return 'skip'; + } + return 'cancel'; +} + +export function writeProjectFiles( + projectPath: string, + files: Record, + resolution: ConflictResolution +): { written: number; skipped: number } { + if (resolution === 'cancel') { + return { written: 0, skipped: 0 }; + } + + fs.mkdirSync(projectPath, { recursive: true }); + + let written = 0; + let skipped = 0; + + for (const relativePath of Object.keys(files)) { + const fullPath = resolveSafeProjectPath(projectPath, relativePath); + const exists = fs.existsSync(fullPath) && fs.statSync(fullPath).isFile(); + + if (exists && resolution === 'skip') { + skipped += 1; + continue; + } + + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, files[relativePath], 'utf8'); + written += 1; + } + + return { written, skipped }; +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..80604a6 --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "out"] +}