newproject/: the policy (guestDefaults.newProjects — quota, templates, the ones the owner approves first, visibility, level, domain — and a per-guest createProjects + address bucket), the New project form built from it, and the flow: POST /api/new-project/form (the run's guesses as defaults) → submit → shape/policy and host checks (a problem sends a corrected form back) → an owner ask for a backend template → sidecar /scaffold → grant → /relocate + resume the same session inside the project with an app-authored turn. SpawnBody.newProject starts a guest's run in ~/projects/.new-project/<uid> (staging_scope: /tmp only); /api/me reports newProjects; the thread follows ConversationDetail.continuedIn. Fix: a guest's turn into an existing conversation resumed with no cwd, so the sidecar ran it in the lab checkout (its CLAUDE.md loaded) — it now resumes where the session lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
5.3 KiB
Python
126 lines
5.3 KiB
Python
"""
|
|
Who may start a project, and what the *New project* form offers them.
|
|
|
|
* The **owner** always may: every app template under ``TEMPLATES_DIR``, any
|
|
address bucket (``dev`` by default), every visibility, no quota, no
|
|
approval.
|
|
* A **guest** may once the owner turned ``createProjects`` on for them
|
|
(Settings → Users), within ``guestDefaults.newProjects``
|
|
(``users.store.DEFAULT_NEW_PROJECTS``): a quota of projects, the templates
|
|
offered — the ones in ``approve`` wait for the owner's OK, since they put a
|
|
backend on the zipgo host — the visibility choices, and always their own
|
|
address bucket: ``<name>.<bucket>.<domain>``, the bucket being their id
|
|
unless the owner set another.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from templates.catalog import TEMPLATES_DIR
|
|
from users.caller import OWNER_ID, OWNER_NAME, Caller
|
|
|
|
# Templates another generator owns (a homelab service, a Claude Code skill):
|
|
# the New project form only offers app scaffolds.
|
|
_NOT_APPS = ("homelab-service", "skill-cli")
|
|
OWNER_BUCKETS = ("dev", "game")
|
|
VISIBILITY_LABELS = {
|
|
"public": "Public — anyone with the link",
|
|
"password": "Password — a login page in front of the whole site (you get the password)",
|
|
"geo": "France + Germany only — visitors from elsewhere get a 404",
|
|
}
|
|
# What each known template is, in a line a guest can choose from; anything
|
|
# else falls back to the catalog's description.
|
|
TEMPLATE_BLURBS = {
|
|
"react-vite": "a static site or web app — Vite + React + Tailwind, no server",
|
|
"vite-react-pocketbase": "an app with sign-in and saved data — a PWA with "
|
|
"its own PocketBase backend, works offline",
|
|
"datakit": "a Python data project (collect → process → publish), not a website",
|
|
}
|
|
|
|
|
|
def app_templates() -> list[str]:
|
|
"""Every template the generator can scaffold as an app: a root
|
|
``package.json`` or ``pyproject.toml``, not owned by another generator."""
|
|
if not TEMPLATES_DIR.is_dir():
|
|
return []
|
|
out = []
|
|
for d in sorted(TEMPLATES_DIR.iterdir()):
|
|
if not d.is_dir() or d.name.startswith(".") or d.name.startswith(_NOT_APPS):
|
|
continue
|
|
if (d / "package.json").is_file() or (d / "pyproject.toml").is_file():
|
|
out.append(d.name)
|
|
return out
|
|
|
|
|
|
def template_blurb(name: str) -> str:
|
|
if name in TEMPLATE_BLURBS:
|
|
return TEMPLATE_BLURBS[name]
|
|
from templates.catalog import list_templates
|
|
for t in list_templates():
|
|
if t["name"] == name:
|
|
return " ".join((t.get("description") or "").replace("*", "").split())[:140]
|
|
return ""
|
|
|
|
|
|
def policy(deps) -> dict:
|
|
return deps.users.defaults()["newProjects"]
|
|
|
|
|
|
def bucket_of(user: dict) -> str:
|
|
return (user.get("bucket") or "").strip() or user["id"]
|
|
|
|
|
|
def options(deps, requester: str) -> dict:
|
|
"""What the form offers ``requester`` (``owner`` or a guest id)."""
|
|
pol = policy(deps)
|
|
apps = app_templates()
|
|
if requester == OWNER_ID:
|
|
return {"templates": apps, "approve": [], "visibility": list(VISIBILITY_LABELS),
|
|
"visibilityDefault": "public", "bucket": None,
|
|
"buckets": list(OWNER_BUCKETS), "domain": pol["domain"],
|
|
"level": "ship", "quota": None}
|
|
user = deps.users.get(requester) or {"id": requester}
|
|
templates = [t for t in pol["templates"] if t in apps]
|
|
return {"templates": templates,
|
|
"approve": [t for t in pol["approve"] if t in templates],
|
|
"visibility": pol["visibility"],
|
|
"visibilityDefault": pol["visibilityDefault"],
|
|
"bucket": bucket_of(user), "buckets": [bucket_of(user)],
|
|
"domain": pol["domain"], "level": pol["level"], "quota": pol["quota"]}
|
|
|
|
|
|
def access(deps, caller: Caller) -> dict:
|
|
"""``/api/me``'s ``newProjects``: may the caller start a project now?"""
|
|
pol = policy(deps)
|
|
if caller.is_owner:
|
|
return {"allowed": True, "reason": None, "remaining": None,
|
|
"bucket": None, "domain": pol["domain"]}
|
|
user = deps.users.active(caller.id) or {}
|
|
out = {"allowed": False, "reason": None, "remaining": None,
|
|
"bucket": bucket_of(user) if user else None, "domain": pol["domain"]}
|
|
if not user.get("createProjects"):
|
|
out["reason"] = "not enabled"
|
|
return out
|
|
if not options(deps, caller.id)["templates"]:
|
|
out["reason"] = "no template is offered to guests"
|
|
return out
|
|
used = deps.new_projects.used(caller.id)
|
|
out["remaining"] = max(0, pol["quota"] - used)
|
|
if out["remaining"] <= 0:
|
|
out["reason"] = f"you've created {used} projects, the most you can"
|
|
return out
|
|
out["allowed"] = True
|
|
return out
|
|
|
|
|
|
def scope_line(deps, caller: Caller) -> str:
|
|
"""The guest run's system-prompt line about starting projects."""
|
|
a = access(deps, caller)
|
|
if a["allowed"]:
|
|
return ("- They may start new projects (" + f"{a['remaining']} left): when they "
|
|
"ask for one, follow the `shared:new-frontend-project` skill — it opens "
|
|
"the *New project* form; never scaffold or deploy a new project by hand.")
|
|
if a["reason"] == "not enabled":
|
|
return ("- They can't start new projects: if they ask for one, say "
|
|
f"{OWNER_NAME} can enable it for them.")
|
|
return f"- They can't start a new project right now: {a['reason']}."
|