Repo-side rename only (Phases 1-3 of the migration plan); the live box (~/.castle, systemd units, /data/castle, domains) is a separate cutover. - Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api), dist names, entry point `wildpc = wildpc_cli.main:main`. - Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*. - Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml -> wildpc.yaml, /data/castle -> /data/wildpc. - Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc. - Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend. - Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated; redundant nested uv.lock files dropped (workspace root lock is authoritative). Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds. Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
976 lines
26 KiB
Python
976 lines
26 KiB
Python
"""Project scaffolding - generates project files from templates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
def scaffold_project(
|
|
project_dir: Path,
|
|
name: str,
|
|
package_name: str,
|
|
stack: str,
|
|
description: str,
|
|
port: int | None = None,
|
|
) -> None:
|
|
"""Scaffold a new project from templates based on stack."""
|
|
if stack == "python-fastapi":
|
|
_scaffold_service(project_dir, name, package_name, description, port or 9000)
|
|
elif stack == "python-cli":
|
|
_scaffold_tool(project_dir, name, package_name, description)
|
|
elif stack == "supabase":
|
|
_scaffold_supabase(project_dir, name, description)
|
|
elif stack == "hugo":
|
|
_scaffold_hugo(project_dir, name, description)
|
|
else:
|
|
raise ValueError(f"No scaffold template for stack: {stack}")
|
|
|
|
|
|
def _scaffold_service(
|
|
project_dir: Path,
|
|
name: str,
|
|
package_name: str,
|
|
description: str,
|
|
port: int,
|
|
) -> None:
|
|
"""Scaffold a FastAPI service."""
|
|
src_dir = project_dir / "src" / package_name
|
|
tests_dir = project_dir / "tests"
|
|
src_dir.mkdir(parents=True)
|
|
tests_dir.mkdir(parents=True)
|
|
|
|
env_prefix = package_name.upper()
|
|
|
|
# pyproject.toml
|
|
_write(
|
|
project_dir / "pyproject.toml",
|
|
f'''[project]
|
|
name = "{name}"
|
|
version = "0.1.0"
|
|
description = "{description}"
|
|
requires-python = ">=3.13"
|
|
dependencies = [
|
|
"fastapi>=0.115.0",
|
|
"uvicorn>=0.34.0",
|
|
"pydantic-settings>=2.0.0",
|
|
"httpx>=0.27.0",
|
|
]
|
|
|
|
[project.scripts]
|
|
{name} = "{package_name}.main:run"
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["src/{package_name}"]
|
|
|
|
[dependency-groups]
|
|
dev = [
|
|
"pytest>=7.0.0",
|
|
"pytest-asyncio>=0.23.0",
|
|
"httpx>=0.27.0",
|
|
]
|
|
|
|
[tool.ruff.lint.isort]
|
|
known-first-party = ["{package_name}"]
|
|
''',
|
|
)
|
|
|
|
# __init__.py
|
|
_write(
|
|
src_dir / "__init__.py",
|
|
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
|
|
)
|
|
|
|
# config.py
|
|
_write(
|
|
src_dir / "config.py",
|
|
f'''"""Configuration for {name}."""
|
|
|
|
from pathlib import Path
|
|
|
|
from pydantic_settings import BaseSettings
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Service settings loaded from environment variables."""
|
|
|
|
data_dir: Path = Path("./data")
|
|
host: str = "0.0.0.0"
|
|
port: int = {port}
|
|
|
|
model_config = {{
|
|
"env_prefix": "{env_prefix}_",
|
|
"env_file": ".env",
|
|
}}
|
|
|
|
def ensure_data_dir(self) -> None:
|
|
"""Create data directory if it doesn't exist."""
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
settings = Settings()
|
|
''',
|
|
)
|
|
|
|
# main.py
|
|
_write(
|
|
src_dir / "main.py",
|
|
f'''"""Main application for {name}."""
|
|
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI
|
|
|
|
from {package_name}.config import settings
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
"""Application lifespan handler."""
|
|
settings.ensure_data_dir()
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="{name}",
|
|
description="{description}",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
"""Health check endpoint."""
|
|
return {{"status": "ok"}}
|
|
|
|
|
|
def run() -> None:
|
|
"""Run the application with uvicorn."""
|
|
uvicorn.run(
|
|
"{package_name}.main:app",
|
|
host=settings.host,
|
|
port=settings.port,
|
|
reload=False,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|
|
''',
|
|
)
|
|
|
|
# tests/__init__.py
|
|
_write(tests_dir / "__init__.py", "")
|
|
|
|
# tests/conftest.py
|
|
_write(
|
|
tests_dir / "conftest.py",
|
|
f'''"""Test fixtures for {name}."""
|
|
|
|
from collections.abc import Generator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from {package_name}.config import settings
|
|
from {package_name}.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_data_dir(tmp_path: Path) -> Generator[Path, None, None]:
|
|
"""Create a temporary data directory for tests."""
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
original = settings.data_dir
|
|
settings.data_dir = data_dir
|
|
yield data_dir
|
|
settings.data_dir = original
|
|
|
|
|
|
@pytest.fixture
|
|
def client(temp_data_dir: Path) -> Generator[TestClient, None, None]:
|
|
"""Create a test client with isolated data directory."""
|
|
with TestClient(app) as client:
|
|
yield client
|
|
''',
|
|
)
|
|
|
|
# tests/test_health.py
|
|
_write(
|
|
tests_dir / "test_health.py",
|
|
f'''"""Tests for {name} health endpoint."""
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
class TestHealth:
|
|
"""Health endpoint tests."""
|
|
|
|
def test_health(self, client: TestClient) -> None:
|
|
"""Health endpoint returns ok."""
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
assert response.json() == {{"status": "ok"}}
|
|
''',
|
|
)
|
|
|
|
# CLAUDE.md
|
|
_write(
|
|
project_dir / "CLAUDE.md",
|
|
f"""# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working
|
|
with code in this repository.
|
|
|
|
## Overview
|
|
|
|
{name} is a FastAPI service. {description}.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
uv sync # Install dependencies
|
|
uv run {name} # Run service (port {port})
|
|
uv run pytest tests/ -v # Run tests
|
|
uv run ruff check . # Lint
|
|
uv run ruff format . # Format
|
|
```
|
|
|
|
## Architecture
|
|
|
|
- `src/{package_name}/config.py` — Settings via pydantic-settings, env prefix `{env_prefix}_`
|
|
- `src/{package_name}/main.py` — FastAPI app, lifespan, health endpoint
|
|
- `tests/` — pytest with TestClient fixtures
|
|
|
|
## Configuration
|
|
|
|
Environment variables with `{env_prefix}_` prefix:
|
|
- `{env_prefix}_DATA_DIR` — Data directory (default: ./data)
|
|
- `{env_prefix}_HOST` — Bind host (default: 0.0.0.0)
|
|
- `{env_prefix}_PORT` — Port (default: {port})
|
|
""",
|
|
)
|
|
|
|
|
|
def _scaffold_tool(
|
|
project_dir: Path,
|
|
name: str,
|
|
package_name: str,
|
|
description: str,
|
|
) -> None:
|
|
"""Scaffold a CLI tool."""
|
|
src_dir = project_dir / "src" / package_name
|
|
tests_dir = project_dir / "tests"
|
|
src_dir.mkdir(parents=True)
|
|
tests_dir.mkdir(parents=True)
|
|
|
|
# pyproject.toml
|
|
_write(
|
|
project_dir / "pyproject.toml",
|
|
f'''[project]
|
|
name = "{name}"
|
|
version = "0.1.0"
|
|
description = "{description}"
|
|
requires-python = ">=3.11"
|
|
dependencies = []
|
|
|
|
[project.scripts]
|
|
{name} = "{package_name}.main:main"
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["src/{package_name}"]
|
|
|
|
[dependency-groups]
|
|
dev = [
|
|
"pytest>=7.0.0",
|
|
]
|
|
|
|
[tool.ruff.lint.isort]
|
|
known-first-party = ["{package_name}"]
|
|
''',
|
|
)
|
|
|
|
# __init__.py
|
|
_write(
|
|
src_dir / "__init__.py",
|
|
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
|
|
)
|
|
|
|
# main.py
|
|
_write(
|
|
src_dir / "main.py",
|
|
f'''#!/usr/bin/env python3
|
|
"""{name}: {description}
|
|
|
|
Usage:
|
|
{name} [options] [input]
|
|
cat input.txt | {name}
|
|
|
|
Examples:
|
|
{name} input.txt
|
|
{name} input.txt -o output.txt
|
|
cat input.txt | {name} > output.txt
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
from {package_name} import __version__
|
|
|
|
__all__ = ["main"]
|
|
|
|
|
|
def main() -> int:
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="{description}",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
parser.add_argument("input", nargs="?", help="Input file (default: stdin)")
|
|
parser.add_argument(
|
|
"-o", "--output", default=None, help="Output file (default: stdout)"
|
|
)
|
|
parser.add_argument(
|
|
"--version", action="version", version=f"{name} {{__version__}}"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.input:
|
|
with open(args.input) as f:
|
|
data = f.read()
|
|
else:
|
|
data = sys.stdin.read()
|
|
|
|
# TODO: implement tool logic
|
|
result = data
|
|
|
|
if args.output:
|
|
with open(args.output, "w") as f:
|
|
f.write(result)
|
|
else:
|
|
print(result, end="")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|
|
''',
|
|
)
|
|
|
|
# tests/__init__.py
|
|
_write(tests_dir / "__init__.py", "")
|
|
|
|
# tests/test_main.py
|
|
_write(
|
|
tests_dir / "test_main.py",
|
|
f'''"""Tests for {name}."""
|
|
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
class TestCLI:
|
|
"""CLI interface tests."""
|
|
|
|
def test_version(self) -> None:
|
|
"""--version prints version string."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "{package_name}.main", "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert "{name}" in result.stdout
|
|
assert "0.1.0" in result.stdout
|
|
|
|
def test_stdin(self) -> None:
|
|
"""Reads from stdin when no file argument."""
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "{package_name}.main"],
|
|
input="hello\\n",
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0
|
|
assert "hello" in result.stdout
|
|
|
|
def test_file_input(self, tmp_path) -> None:
|
|
"""Reads from file argument."""
|
|
input_file = tmp_path / "input.txt"
|
|
input_file.write_text("test data")
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "{package_name}.main", str(input_file)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0
|
|
assert "test data" in result.stdout
|
|
|
|
def test_output_file(self, tmp_path) -> None:
|
|
"""Writes to output file with -o flag."""
|
|
input_file = tmp_path / "input.txt"
|
|
input_file.write_text("test data")
|
|
output_file = tmp_path / "output.txt"
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable, "-m", "{package_name}.main",
|
|
str(input_file), "-o", str(output_file),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0
|
|
assert output_file.read_text() == "test data"
|
|
''',
|
|
)
|
|
|
|
# CLAUDE.md
|
|
_write(
|
|
project_dir / "CLAUDE.md",
|
|
f"""# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working
|
|
with code in this repository.
|
|
|
|
## Overview
|
|
|
|
{name} is a CLI tool. {description}.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
uv sync # Install dependencies
|
|
uv run {name} # Run the tool
|
|
uv run {name} --version # Show version
|
|
uv run pytest tests/ -v # Run tests
|
|
uv run ruff check . # Lint
|
|
uv run ruff format . # Format
|
|
```
|
|
|
|
## Architecture
|
|
|
|
- `src/{package_name}/main.py` — Entry point, argparse CLI, stdin/stdout interface
|
|
- `src/{package_name}/__init__.py` — Package version (`__version__`)
|
|
- `tests/` — pytest tests
|
|
|
|
## Conventions
|
|
|
|
- Reads from stdin or file argument
|
|
- Writes to stdout or `-o/--output` file
|
|
- `--version` flag for version info
|
|
- Returns 0 on success, 1 on error
|
|
- Composable via Unix pipes
|
|
- `argparse.RawDescriptionHelpFormatter` with module docstring as epilog
|
|
""",
|
|
)
|
|
|
|
|
|
def _scaffold_library(
|
|
project_dir: Path,
|
|
name: str,
|
|
package_name: str,
|
|
description: str,
|
|
) -> None:
|
|
"""Scaffold a Python library."""
|
|
src_dir = project_dir / "src" / package_name
|
|
tests_dir = project_dir / "tests"
|
|
src_dir.mkdir(parents=True)
|
|
tests_dir.mkdir(parents=True)
|
|
|
|
# pyproject.toml
|
|
_write(
|
|
project_dir / "pyproject.toml",
|
|
f'''[project]
|
|
name = "{name}"
|
|
version = "0.1.0"
|
|
description = "{description}"
|
|
requires-python = ">=3.11"
|
|
dependencies = []
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["src/{package_name}"]
|
|
|
|
[dependency-groups]
|
|
dev = [
|
|
"pytest>=7.0.0",
|
|
]
|
|
|
|
[tool.ruff.lint.isort]
|
|
known-first-party = ["{package_name}"]
|
|
''',
|
|
)
|
|
|
|
# __init__.py
|
|
_write(
|
|
src_dir / "__init__.py",
|
|
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
|
|
)
|
|
|
|
# tests/__init__.py
|
|
_write(tests_dir / "__init__.py", "")
|
|
|
|
# tests/test_placeholder.py
|
|
_write(
|
|
tests_dir / "test_placeholder.py",
|
|
f'''"""Tests for {name}."""
|
|
|
|
|
|
class TestPlaceholder:
|
|
"""Placeholder tests."""
|
|
|
|
def test_import(self) -> None:
|
|
"""Library can be imported."""
|
|
import {package_name}
|
|
assert {package_name}.__version__ == "0.1.0"
|
|
''',
|
|
)
|
|
|
|
# CLAUDE.md
|
|
_write(
|
|
project_dir / "CLAUDE.md",
|
|
f"""# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working
|
|
with code in this repository.
|
|
|
|
## Overview
|
|
|
|
{name} is a Python library. {description}.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
uv sync # Install dependencies
|
|
uv run pytest tests/ -v # Run tests
|
|
uv run ruff check . # Lint
|
|
uv run ruff format . # Format
|
|
```
|
|
|
|
## Architecture
|
|
|
|
- `src/{package_name}/` — Library source code
|
|
- `tests/` — pytest tests
|
|
""",
|
|
)
|
|
|
|
|
|
def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None:
|
|
"""Scaffold a Patch-shaped app that targets the shared Supabase substrate.
|
|
|
|
Produces migrations/ (applied to the substrate by `wildpc program build`),
|
|
functions/ (deno edge functions), public/ (static UI served in place at
|
|
<name>.<gateway.domain> by the gateway), and supabase.app.yaml (auth policy + wiring).
|
|
|
|
The app owns its code and stays repo-durable; only its rows/blobs live on the
|
|
shared substrate. Each app is isolated in its **own Postgres schema** (the app
|
|
id) rather than sharing `public` — so `wildpc program build` creates+grants the
|
|
schema and tracks migrations per-app, and `wildpc delete --purge-data` drops
|
|
the whole schema cleanly. Rows are further protected by RLS.
|
|
"""
|
|
ident = name.replace("-", "_") # a safe SQL/JS identifier — and the app schema
|
|
table = "entries" # unqualified; lives in the app's own schema (search_path)
|
|
|
|
def sub(text: str) -> str:
|
|
return (
|
|
text.replace("__NAME__", name)
|
|
.replace("__IDENT__", ident)
|
|
.replace("__SCHEMA__", ident)
|
|
.replace("__TABLE__", table)
|
|
.replace("__DESC__", description)
|
|
)
|
|
|
|
# --- supabase.app.yaml — app manifest (substrate wiring + auth policy) ---
|
|
_write(
|
|
project_dir / "supabase.app.yaml",
|
|
sub(
|
|
"""# Patch-shaped app targeting the shared Supabase substrate.
|
|
name: __NAME__
|
|
description: __DESC__
|
|
substrate: supabase # the shared wildpc service this app deploys against
|
|
|
|
# auth policy: public | private | shared
|
|
# public — anyone with the URL (anon read/write, still RLS-gated)
|
|
# private — only the owner; RLS locks rows to auth.uid()
|
|
# shared — owner + named people (list handles below)
|
|
auth: public
|
|
# shared: [alice, bob]
|
|
|
|
# This app is isolated in its own Postgres schema (created + exposed through
|
|
# PostgREST by `wildpc program build`). The frontend selects it via
|
|
# supabase-js `db: { schema }`.
|
|
schema: __SCHEMA__
|
|
"""
|
|
),
|
|
)
|
|
|
|
# --- migrations/0001_init.sql — versioned, idempotent, forward-only ---
|
|
_write(
|
|
project_dir / "migrations" / "0001_init.sql",
|
|
sub(
|
|
"""-- 0001_init: example table + RLS. Applied by `wildpc program build`
|
|
-- via the versioned migration runner (tracked per-app in
|
|
-- <schema>.schema_migrations; only unapplied migrations run). Forward-only —
|
|
-- never edit an applied migration; add a new numbered file instead.
|
|
--
|
|
-- The runner sets search_path to this app's own schema, so unqualified names
|
|
-- land there (NOT in public). Keep tables unqualified for schema portability.
|
|
|
|
create table if not exists __TABLE__ (
|
|
id bigint generated always as identity primary key,
|
|
message text not null check (char_length(message) <= 500),
|
|
author text,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
-- RLS is necessary but NOT sufficient on its own: it protects rows, not the
|
|
-- static app shell or Storage objects. For a `public` app, anon read/write is
|
|
-- intended (still row-gated). For `private`/`shared`, replace the policies below
|
|
-- with owner checks (auth.uid()) AND gate the shell + use signed Storage URLs.
|
|
alter table __TABLE__ enable row level security;
|
|
|
|
drop policy if exists "__IDENT___public_read" on __TABLE__;
|
|
create policy "__IDENT___public_read" on __TABLE__ for select using (true);
|
|
|
|
drop policy if exists "__IDENT___public_write" on __TABLE__;
|
|
create policy "__IDENT___public_write" on __TABLE__ for insert with check (true);
|
|
"""
|
|
),
|
|
)
|
|
|
|
# --- functions/hello/index.ts — deno edge-function stub ---
|
|
_write(
|
|
project_dir / "functions" / "hello" / "index.ts",
|
|
sub(
|
|
"""// Edge function for __NAME__. Runs on the substrate's edge-runtime.
|
|
// Deployed alongside migrations; call it from the app (never expose the
|
|
// service_role key to the browser — privileged work happens here, server-side).
|
|
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
|
|
|
|
serve((_req: Request) => {
|
|
return new Response(
|
|
JSON.stringify({ ok: true, app: "__NAME__", ts: new Date().toISOString() }),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
});
|
|
"""
|
|
),
|
|
)
|
|
|
|
# --- public/config.js — substrate wiring (anon key is public-safe by design) ---
|
|
_write(
|
|
project_dir / "public" / "config.js",
|
|
sub(
|
|
"""// Substrate wiring for __NAME__. The anon key is designed to be public
|
|
// (RLS enforces access) — paste yours here. Find it in the dashboard
|
|
// Secrets page (or `wildpc mesh`/vault), secret name SUPABASE_ANON_KEY.
|
|
window.APP = {
|
|
SUPABASE_URL: "https://supabase.lan",
|
|
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
|
|
SCHEMA: "__SCHEMA__", // this app's isolated Postgres schema
|
|
TABLE: "__TABLE__",
|
|
};
|
|
"""
|
|
),
|
|
)
|
|
|
|
# --- public/index.html — reads/writes the substrate via supabase-js ---
|
|
_write(
|
|
project_dir / "public" / "index.html",
|
|
sub(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>__NAME__</title>
|
|
<script src="./config.js"></script>
|
|
<script type="module">
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
const { SUPABASE_URL, SUPABASE_ANON_KEY, SCHEMA, TABLE } = window.APP;
|
|
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: SCHEMA } });
|
|
|
|
const list = document.getElementById("list");
|
|
async function refresh() {
|
|
const { data, error } = await db.from(TABLE)
|
|
.select("message, author, created_at")
|
|
.order("created_at", { ascending: false }).limit(50);
|
|
list.textContent = error ? "Error: " + error.message
|
|
: (data.map(r => `${r.author ?? "anon"}: ${r.message}`).join("\\n") || "(empty)");
|
|
}
|
|
document.getElementById("form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const message = e.target.message.value.trim();
|
|
if (!message) return;
|
|
const { error } = await db.from(TABLE).insert({ message });
|
|
if (error) { alert(error.message); return; }
|
|
e.target.reset();
|
|
refresh();
|
|
});
|
|
refresh();
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<h1>__NAME__</h1>
|
|
<p>__DESC__</p>
|
|
<form id="form"><input name="message" placeholder="Say something…" autocomplete="off" />
|
|
<button>Post</button></form>
|
|
<pre id="list">Loading…</pre>
|
|
</body>
|
|
</html>
|
|
"""
|
|
),
|
|
)
|
|
|
|
# --- CLAUDE.md — how this app works ---
|
|
_write(
|
|
project_dir / "CLAUDE.md",
|
|
sub(
|
|
"""# __NAME__
|
|
|
|
__DESC__
|
|
|
|
A **supabase-stack** app: it owns its code (this repo) and rents its backend from
|
|
the shared Supabase substrate (the `supabase` wildpc service). Only its rows/blobs
|
|
live on the substrate; rebuild the rest from git anytime.
|
|
|
|
## Layout
|
|
- `migrations/` — versioned, idempotent Postgres migrations (forward-only). Applied
|
|
to the substrate by `wildpc program build __NAME__`.
|
|
- `functions/` — deno edge functions deployed to the substrate's edge-runtime.
|
|
- `public/` — static UI served in place at `/__NAME__/` by the gateway. Talks to
|
|
the substrate with `@supabase/supabase-js` + the public anon key (RLS-gated).
|
|
- `supabase.app.yaml` — substrate wiring + auth policy (public/private/shared).
|
|
|
|
## Develop
|
|
- Edit `migrations/`, then `wildpc program build __NAME__` to apply new migrations
|
|
(re-running is a no-op — only unapplied migrations run).
|
|
- Set the anon key in `public/config.js` (find `SUPABASE_ANON_KEY` in the
|
|
dashboard Secrets page).
|
|
- `wildpc apply` → served at `__NAME__.<domain>`.
|
|
|
|
## Privacy note
|
|
RLS protects rows, not the static shell or Storage. For a `private`/`shared` app,
|
|
also gate the shell and use signed Storage URLs — RLS alone is not leak-proof.
|
|
Auth/WebCrypto apps should get their own HTTPS host route (secure context).
|
|
"""
|
|
),
|
|
)
|
|
|
|
|
|
def _hugo_new_site(project_dir: Path) -> bool:
|
|
"""Create the canonical site skeleton with Hugo's own scaffolder.
|
|
|
|
Delegates the standard structure (archetypes/, the content/layouts/static/…
|
|
dir tree, hugo.toml) to `hugo new site` rather than reinventing it. Returns
|
|
False if the hugo binary isn't present at create time — the caller then falls
|
|
back to a minimal hand-written skeleton (the build needs hugo regardless)."""
|
|
if shutil.which("hugo") is None:
|
|
return False
|
|
# `hugo new site` requires an empty/absent target; create runs before git init,
|
|
# so project_dir doesn't exist yet.
|
|
result = subprocess.run(
|
|
["hugo", "new", "site", str(project_dir), "--format", "toml"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def _scaffold_hugo(project_dir: Path, name: str, description: str) -> None:
|
|
"""Scaffold a Hugo site that builds and serves theme-less out of the box.
|
|
|
|
The canonical skeleton comes from Hugo's own `hugo new site`; on top of it we
|
|
overlay the pieces a bare skeleton lacks — the layouts needed to render a home
|
|
page without a theme, sample content, and a wildpc-flavored hugo.toml/README.
|
|
|
|
`wildpc program build` runs `hugo --gc --minify` → `public/`, which a caddy
|
|
deployment serves in place at <name>.<gateway.domain>. baseURL is `/` so assets
|
|
resolve at the root of the site's own subdomain. A theme (with its own asset
|
|
pipeline) can be dropped under themes/ later; such themes declare a two-step
|
|
`build.commands` in the program spec, which overrides the stack default."""
|
|
|
|
def sub(text: str) -> str:
|
|
return text.replace("__NAME__", name).replace("__DESC__", description)
|
|
|
|
# Prefer Hugo's native scaffolder; fall back to the bits it would have made.
|
|
if not _hugo_new_site(project_dir):
|
|
_write(
|
|
project_dir / "archetypes" / "default.md",
|
|
"""+++
|
|
date = '{{ .Date }}'
|
|
draft = true
|
|
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
|
+++
|
|
""",
|
|
)
|
|
|
|
# --- hugo.toml — overwrite Hugo's example.org default; baseURL '/' serves at
|
|
# the subdomain root ---
|
|
_write(
|
|
project_dir / "hugo.toml",
|
|
sub(
|
|
"""baseURL = "/"
|
|
languageCode = "en-us"
|
|
title = "__NAME__"
|
|
|
|
[params]
|
|
description = "__DESC__"
|
|
"""
|
|
),
|
|
)
|
|
|
|
# --- layouts/_default/baseof.html — the shared page skeleton (blocks) ---
|
|
_write(
|
|
project_dir / "layouts" / "_default" / "baseof.html",
|
|
sub(
|
|
"""<!DOCTYPE html>
|
|
<html lang="{{ .Site.LanguageCode | default "en" }}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
|
|
<meta name="description" content="{{ .Site.Params.description }}">
|
|
</head>
|
|
<body>
|
|
<header><a href="{{ "/" | relURL }}">{{ .Site.Title }}</a></header>
|
|
<main>{{ block "main" . }}{{ end }}</main>
|
|
<footer>© {{ now.Year }} {{ .Site.Title }}</footer>
|
|
</body>
|
|
</html>
|
|
"""
|
|
),
|
|
)
|
|
|
|
# --- layouts/index.html — the home page ---
|
|
_write(
|
|
project_dir / "layouts" / "index.html",
|
|
"""{{ define "main" }}
|
|
{{ .Content }}
|
|
<ul>
|
|
{{ range (where .Site.RegularPages "Type" "posts") }}
|
|
<li>
|
|
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
|
|
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
|
|
</li>
|
|
{{ end }}
|
|
</ul>
|
|
{{ end }}
|
|
""",
|
|
)
|
|
|
|
# --- layouts/_default/{single,list}.html — content pages ---
|
|
_write(
|
|
project_dir / "layouts" / "_default" / "single.html",
|
|
"""{{ define "title" }}{{ .Title }} · {{ .Site.Title }}{{ end }}
|
|
{{ define "main" }}
|
|
<article>
|
|
<h1>{{ .Title }}</h1>
|
|
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
|
|
{{ .Content }}
|
|
</article>
|
|
{{ end }}
|
|
""",
|
|
)
|
|
_write(
|
|
project_dir / "layouts" / "_default" / "list.html",
|
|
"""{{ define "title" }}{{ .Title }} · {{ .Site.Title }}{{ end }}
|
|
{{ define "main" }}
|
|
<h1>{{ .Title }}</h1>
|
|
{{ .Content }}
|
|
<ul>
|
|
{{ range .Pages }}
|
|
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
|
|
{{ end }}
|
|
</ul>
|
|
{{ end }}
|
|
""",
|
|
)
|
|
|
|
# --- content — home + an example post ---
|
|
_write(
|
|
project_dir / "content" / "_index.md",
|
|
sub(
|
|
"""---
|
|
title: "__NAME__"
|
|
---
|
|
|
|
Welcome to **__NAME__** — a Hugo site on wildpc. Edit `content/_index.md` and
|
|
add posts under `content/posts/`.
|
|
"""
|
|
),
|
|
)
|
|
_write(
|
|
project_dir / "content" / "posts" / "hello.md",
|
|
"""---
|
|
title: "Hello, world"
|
|
date: 2024-01-01
|
|
---
|
|
|
|
Your first post. Edit me in `content/posts/`, or run `hugo new posts/next.md`.
|
|
""",
|
|
)
|
|
|
|
# --- .gitignore — build output is regenerated, never committed ---
|
|
_write(
|
|
project_dir / ".gitignore",
|
|
"/public/\n/resources/\n.hugo_build.lock\n",
|
|
)
|
|
|
|
# --- README ---
|
|
_write(
|
|
project_dir / "README.md",
|
|
sub(
|
|
"""# __NAME__
|
|
|
|
__DESC__
|
|
|
|
A [Hugo](https://gohugo.io) static site managed by wildpc.
|
|
|
|
## Develop
|
|
|
|
hugo server -D # live preview at http://localhost:1313
|
|
|
|
## Build & serve
|
|
|
|
wildpc program build __NAME__ # hugo --gc --minify -> public/
|
|
wildpc apply __NAME__ # serve at __NAME__.<gateway.domain>
|
|
|
|
## Themes
|
|
|
|
Drop a theme under `themes/` (often a git submodule) and set `theme` in
|
|
`hugo.toml`. A theme with an asset pipeline (Tailwind, etc.) needs a pre-build
|
|
step; declare it in `programs/__NAME__.yaml` so it overrides the stack default:
|
|
|
|
build:
|
|
commands:
|
|
- [pnpm, build]
|
|
- [hugo, --gc, --minify]
|
|
outputs: [public]
|
|
"""
|
|
),
|
|
)
|
|
|
|
|
|
def _write(path: Path, content: str) -> None:
|
|
"""Write content to a file, creating parent directories."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content)
|