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..0b64265 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,38 @@ +# Creer — Final Plan + +v0.1 delivered the foundation: FastAPI planner/generator + VS Code command that writes a generated repo into the workspace (optional git init). + +## v0.2 — done + +1. **Preview before writing** — plan preview markdown + confirm before generate/write (`creer.previewBeforeWrite`). +2. **GitHub repo creation** — `POST /github/create-repo` + extension remote add/push. +3. **Curated templates** — `GET /templates` + template-anchored `/plan` & `/generate`. +4. **Overwrite protection** — per-file conflict detection with overwrite / skip / cancel. +5. **Chat command `/creer`** — `creer.createRepoFromChat` + `@creer` chat participant. + +## v0.3 — done + +1. **Streaming generation** — `POST /generate/stream` (SSE) + extension progress UI. +2. **Local / offline backends** — `OPENAI_BASE_URL`, `CREER_OFFLINE`. +3. **Open-source bake-ins** — LICENSE / README / CI via `bakeins.py`. +4. **Hardening** — `GIT_ASKPASS` + SecretStorage for GitHub tokens. + +## v0.4 — done + +1. **Cancellation** — `job_id` on stream + `POST /generate/cancel`; extension AbortSignal + cancellable progress. +2. **Selectable bake-ins** — license (`mit` / `apache-2.0` / `none`) and CI presets (`auto` / `python` / `node` / `none`); `GET /bakeins`. +3. **Quality gates** — telemetry-free tree checks (`quality` on generate/done; `POST /quality`). + +## v0.5 (optional next) + +- Diff preview of generated file contents before write +- Multi-root workspace targeting +- Template packs as installable JSON/YAML + +## 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..7d92e59 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Creer + +AI-powered repo scaffolding inside your workspace. + +**Current version: 0.5.0** + +## Architecture + +``` +creer/ +├── backend/ # Python FastAPI AI engine (+ packs/) +└── extension/ # VS Code extension +``` + +## Prerequisites + +- Python 3.10+ +- Node.js 18+ +- OpenAI API key **or** `OPENAI_BASE_URL` **or** `CREER_OFFLINE=1` (with template/pack) +- VS Code / Cursor + +## Backend (v0.5) + +```bash +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 +``` + +### Environment + +| Variable | Description | +|---|---| +| `OPENAI_API_KEY` | OpenAI API key | +| `OPENAI_BASE_URL` | OpenAI-compatible base URL (Ollama, etc.) | +| `CREER_MODEL` | Model name (default `gpt-4o-mini`) | +| `CREER_OFFLINE` | Template/pack-only stubs | +| `CREER_PACKS_DIR` | Extra packs directory (overrides same ids) | + +### Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/health` | Version `0.5.0`, packs_count | +| `GET` | `/templates` | Built-in templates | +| `GET` | `/packs` | Installable JSON/YAML packs | +| `GET` | `/packs/{id}` | Single pack | +| `GET` | `/bakeins` | License/CI options | +| `POST` | `/plan` | Plan (`template_id` **or** `pack_id`) | +| `POST` | `/generate` | Generate + bake-ins + quality | +| `POST` | `/generate/stream` | SSE progress (`job_id`) | +| `POST` | `/generate/cancel` | Cancel job | +| `POST` | `/quality` | Dry-run gates | +| `POST` | `/github/create-repo` | Create GitHub repo | + +### Packs + +Drop `.json` / `.yaml` files into `backend/packs/` (or `CREER_PACKS_DIR`): + +```json +{ + "id": "fastapi-crud", + "name": "FastAPI CRUD", + "description": "CRUD API starter", + "stack": "FastAPI + Uvicorn", + "version": "1.0.0", + "files": ["main.py", "requirements.txt", "README.md"] +} +``` + +Shipped examples: `fastapi-crud`, `express-ts`, `python-lib`. + +## Extension (v0.5) + +```bash +cd extension && npm install && npm run compile +``` + +Flow: idea → template/pack → bake-ins → plan preview → generate (cancellable) → **content diff preview** → write → optional git/GitHub. + +Multi-root: QuickPick workspace folder (or `creer.defaultWorkspaceFolder`). + +### Commands + +| Command | Title | +|---|---| +| `creer.createRepo` | Creer: Create New Repo | +| `creer.createRepoFromChat` | Creer: Create from Chat Prompt | +| `creer.setGitHubToken` | Creer: Set GitHub Token | +| `creer.clearGitHubToken` | Creer: Clear GitHub Token | + +### Settings + +| Setting | Default | Description | +|---|---|---| +| `creer.backendUrl` | `http://localhost:8000` | Backend URL | +| `creer.contentPreview` | `true` | Diff/content preview before write | +| `creer.defaultWorkspaceFolder` | `""` | Multi-root folder name/path hint | +| `creer.previewBeforeWrite` | `true` | Plan tree confirm before generate | +| `creer.useStreaming` | `true` | SSE progress | +| `creer.promptBakeins` | `true` | QuickPick license/CI | +| `creer.license` / `creer.ciPreset` | `mit` / `auto` | Defaults when not prompting | +| `creer.initGit` | `true` | git init + commit | +| `creer.createGitHubRepo` | `false` | Create remote | +| `creer.githubPrivate` | `true` | Private repos | + +## License + +MIT diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..bb3c577 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,17 @@ +# 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. +# CREER_PACKS_DIR=/path/to/my-packs diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..7d49b78 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +.env +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ 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/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..f3a06c8 --- /dev/null +++ b/backend/app/packs.py @@ -0,0 +1,179 @@ +"""Installable template packs (JSON/YAML) for Creer v0.5.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + +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" + +_PACK_FILE_SUFFIXES = (".json", ".yaml", ".yml") +_SAFE_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$") + + +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 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() + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"Cannot read pack file {path}: {exc}") from exc + + if suffix == ".json": + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in pack {path}: {exc}") from exc + elif suffix in (".yaml", ".yml"): + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise ValueError(f"Invalid YAML in pack {path}: {exc}") from exc + else: + raise ValueError(f"Unsupported pack file type: {path.suffix!r}") + + if not isinstance(data, dict): + raise ValueError(f"Pack must be a mapping/object: {path}") + + 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: {path}") + pack_id = pack_id.strip() + if not _SAFE_ID.match(pack_id): + raise ValueError(f"Invalid pack id {pack_id!r} in {path}") + + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"Pack missing valid name: {path}") + + if not isinstance(files, list) or not files: + raise ValueError(f"Pack must include a non-empty files list: {path}") + + 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 {path}: {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 {path}: {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: {path}") + + stack = data.get("stack", "") + if stack is not None and not isinstance(stack, str): + raise ValueError(f"Pack stack must be a string: {path}") + + description = data.get("description", "") + if description is not None and not isinstance(description, str): + raise ValueError(f"Pack description must be a string: {path}") + + 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 _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 with optional CREER_PACKS_DIR. + + User packs override built-in packs on id collision. + """ + packs = _load_packs_from_dir(PACKS_DIR) + extra = _extra_packs_dir() + if extra is not None: + packs.update(_load_packs_from_dir(extra)) + return packs + + +def list_packs() -> list[dict]: + """Return all available packs (built-in + 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 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/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..7845039 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,11 @@ +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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..8b98684 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,384 @@ +"""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 fastapi import FastAPI, Header, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from config import CREER_OFFLINE, 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 list_packs, get_pack +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.5.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 + + +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()), + } + + +@app.get("/templates") +def templates(): + return {"templates": list_templates()} + + +@app.get("/packs") +def packs(): + return {"packs": list_packs()} + + +@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.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/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..8726dde --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +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 diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000..e6b3087 --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +out/ +*.vsix +.vscode-test/ 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/package-lock.json b/extension/package-lock.json new file mode 100644 index 0000000..54caa3e --- /dev/null +++ b/extension/package-lock.json @@ -0,0 +1,392 @@ +{ + "name": "creer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "creer", + "version": "0.1.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..0ffed8a --- /dev/null +++ b/extension/package.json @@ -0,0 +1,127 @@ +{ + "name": "creer", + "displayName": "Creer", + "description": "AI-powered repo scaffolding inside your workspace", + "version": "0.5.0", + "publisher": "creer", + "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" + } + ], + "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.defaultWorkspaceFolder": { + "type": "string", + "default": "", + "description": "Optional workspace folder name or path hint for multi-root workspaces; empty prompts when multiple folders are open" + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "tsc --noEmit -p ./" + }, + "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..71f8c93 --- /dev/null +++ b/extension/src/api.ts @@ -0,0 +1,221 @@ +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 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 ?? []; +} + +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/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..abac31a --- /dev/null +++ b/extension/src/extension.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; +import { runScaffoldFlow } from './scaffold'; +import { clearGitHubToken, setGitHubToken } from './secrets'; + +export function activate(context: vscode.ExtensionContext) { + 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.'); + }); + + context.subscriptions.push(createRepo, createRepoFromChat, setToken, clearToken); + 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/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/scaffold.ts b/extension/src/scaffold.ts new file mode 100644 index 0000000..f267d79 --- /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 { pickWorkspaceRoot } from './workspace'; +import { + assertSafeProjectName, + findConflicts, + resolveConflicts, + 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 + const conflicts = findConflicts(projectPath, files); + const resolution = await resolveConflicts(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"] +}