Rename Castle -> Wild PC across the repo
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.
This commit is contained in:
0
cli/src/wildpc_cli/templates/__init__.py
Normal file
0
cli/src/wildpc_cli/templates/__init__.py
Normal file
975
cli/src/wildpc_cli/templates/scaffold.py
Normal file
975
cli/src/wildpc_cli/templates/scaffold.py
Normal file
@@ -0,0 +1,975 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user