feat(flags): feature-flag control on the project page #5
41
CLAUDE.md
41
CLAUDE.md
@@ -201,6 +201,11 @@ store / metadata sidecar.
|
||||
checklist rollup that tags a card, `goal_detail()` adds the markdown for the
|
||||
detail page. Counting skips fenced code blocks, so a `- [ ]` inside a snippet
|
||||
isn't mistaken for a real task. No GOAL.md ⇒ `goal: null` (a valid state).
|
||||
- `projectflags.py` — a project's `public/feature-flags.json`: the query-param
|
||||
feature flags its frontend ships behind, each with an `enabled` "for everyone"
|
||||
default. Also mints and verifies the **admin token** (HMAC-SHA256, key at
|
||||
`/data/flags-secret` — delete it to revoke every token ever issued). See
|
||||
*Feature flags* below.
|
||||
- `memories.py` — surfaces the assistant's per-repo memory files (frontmatter).
|
||||
- `templates.py` — browse `~/projects/templates/` scaffolds.
|
||||
- `schemas.py` — Pydantic response models mirroring `frontend/src/types.ts`. They
|
||||
@@ -508,6 +513,8 @@ backend proxies to it over `host.docker.internal:8790` (Bearer `SIDECAR_TOKEN`):
|
||||
--model <model> --remote-control` (detached). Transcript lands in the watched
|
||||
projects dir and the new conversation shows up in the viewer within ~2s.
|
||||
- `POST /api/resume` / `POST /api/interrupt` → continue / SIGINT an existing run.
|
||||
- `POST /api/project-flags/publish` → sidecar `/publish-flags`: `zipgo deploy
|
||||
--no-delete` of one project's `feature-flags.json` onto its live hosts.
|
||||
|
||||
`model` is the `claude --model` argument the composer's **model tag** carries — a
|
||||
family alias (`opus`) for a family's newest model, or a pinned id
|
||||
@@ -516,6 +523,40 @@ family alias (`opus`) for a family's newest model, or a pinned id
|
||||
own. The sidecar only shape-checks the value (it goes on a command line) — the
|
||||
pickable set is whatever `/api/models` returned.
|
||||
|
||||
### Feature flags (project page → Feature flags)
|
||||
|
||||
Features ship behind `?flag=1` so they can be shown before they're finished.
|
||||
Each project declares its flags in `public/feature-flags.json`; the widget at
|
||||
`flags.dev.gabvdl.xyz/script.js` (source: `~/projects/feature-flags`) resolves
|
||||
them on the deployed site and, for an admin browser only, renders a toggle panel.
|
||||
|
||||
Flags are **declared in code and toggled here** — the card cannot create or
|
||||
delete them, because a flag with no code behind it is dead weight.
|
||||
|
||||
| endpoint | auth | who calls it |
|
||||
|---|---|---|
|
||||
| `GET/PUT /api/project-flags` | Authelia (same origin) | the `FlagsEditor` card |
|
||||
| `POST /api/project-flags/admin-link` | Authelia | the *Admin link* button |
|
||||
| `POST /api/project-flags/publish` | Authelia | the *Publish now* button |
|
||||
| `POST /api/flags/publish` | **minted token** | the panel on the live site |
|
||||
|
||||
Three things are load-bearing about that last row:
|
||||
|
||||
1. The public sites have no Authelia session, so the panel carries a token the
|
||||
*Admin link* button minted (stored in its browser after one visit to
|
||||
`?ff-admin=<token>`), and `projectflags.verify_token` checks its HMAC.
|
||||
2. Its Traefik router (`services/ai-agent/traefik.yml`) puts `/api/flags` on
|
||||
`api-chain`, not `auth-chain` — forward-auth would answer the CORS preflight
|
||||
with a login redirect the browser rejects, so the request could never reach
|
||||
the token check.
|
||||
3. The panel POSTs `text/plain` so there *is* no preflight (a CORS simple
|
||||
request). Don't "fix" it to `application/json`.
|
||||
|
||||
Publishing pushes the one JSON file to the live site with no rebuild. The
|
||||
container has neither zipgo nor the deploy key, so the host sidecar does it —
|
||||
`POST /publish-flags`, a *fixed* operation (one named file, to the hosts the
|
||||
project's own `package.json` declares), never a generic exec endpoint.
|
||||
|
||||
### Model tags
|
||||
|
||||
Models are a first-class tag on both sides of the app (`frontend/src/lib/models.tsx`):
|
||||
|
||||
184
backend/main.py
184
backend/main.py
@@ -53,6 +53,7 @@ import notify_audio as notify_audio_mod
|
||||
import notif_read as notif_read_mod
|
||||
import plans as plans_mod
|
||||
import project_costs as project_costs_mod
|
||||
import projectflags
|
||||
import projects as projects_mod
|
||||
import scaffold as scaffold_mod
|
||||
import schemas
|
||||
@@ -3163,6 +3164,189 @@ def project_env_save(body: EnvSaveBody):
|
||||
return _project_env_payload(body.slug, entry)
|
||||
|
||||
|
||||
# ── project feature flags ─────────────────────────────────────────────────────
|
||||
# A project declares its query-param feature flags in `public/feature-flags.json`
|
||||
# (see backend/projectflags.py). Three consumers:
|
||||
#
|
||||
# * this page's editor — flips the "for everyone" default, same origin
|
||||
# * the admin-link button — mints the token that unlocks the panel on the
|
||||
# deployed site
|
||||
# * the panel on that site — POSTs back to /api/flags/publish, cross-origin
|
||||
# and token-authed (it has no lab session)
|
||||
|
||||
def _flags_payload(slug: str, entry: pathlib.Path) -> dict:
|
||||
state = projectflags.read(entry)
|
||||
return {
|
||||
"slug": slug,
|
||||
"path": state["path"],
|
||||
"exists": state["exists"],
|
||||
"error": state["error"],
|
||||
"flags": state["flags"],
|
||||
"hosts": projectflags.deploy_hosts(entry),
|
||||
}
|
||||
|
||||
|
||||
def _flags_entry(slug: str) -> pathlib.Path:
|
||||
entry = projects_mod._safe_entry(slug)
|
||||
if entry is None:
|
||||
raise HTTPException(404, "project not found")
|
||||
return entry
|
||||
|
||||
|
||||
@app.get("/api/project-flags", responses=_r(schemas.ProjectFlagsResponse))
|
||||
def project_flags(slug: str):
|
||||
"""The project's declared feature flags and their for-everyone defaults."""
|
||||
return _flags_payload(slug, _flags_entry(slug))
|
||||
|
||||
|
||||
class FlagSetItem(BaseModel):
|
||||
key: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class FlagsSaveBody(BaseModel):
|
||||
slug: str
|
||||
set: list[FlagSetItem] = []
|
||||
|
||||
|
||||
@app.put("/api/project-flags", responses=_r(schemas.ProjectFlagsResponse))
|
||||
def project_flags_save(body: FlagsSaveBody):
|
||||
"""Flip the for-everyone default of flags the project already declares.
|
||||
|
||||
Deliberately toggle-only: a flag with no code behind it is dead weight, so
|
||||
creating and deleting them stays with whoever ships the feature (by editing
|
||||
the file). Unknown keys are rejected rather than silently created."""
|
||||
entry = _flags_entry(body.slug)
|
||||
state = projectflags.read(entry)
|
||||
if state["error"]:
|
||||
raise HTTPException(409, f"{state['path']} is unreadable: {state['error']}")
|
||||
by_key = {f["key"]: f for f in state["flags"]}
|
||||
for item in body.set:
|
||||
if item.key not in by_key:
|
||||
raise HTTPException(400, f"unknown flag: {item.key!r}")
|
||||
by_key[item.key]["enabled"] = item.enabled
|
||||
projectflags.write(entry, list(by_key.values()))
|
||||
return _flags_payload(body.slug, entry)
|
||||
|
||||
|
||||
class FlagsSlugBody(BaseModel):
|
||||
slug: str
|
||||
|
||||
|
||||
@app.post("/api/project-flags/admin-link", responses=_r(schemas.FlagAdminLinkResponse))
|
||||
def project_flags_admin_link(body: FlagsSlugBody):
|
||||
"""Mint the one-time link that unlocks the admin panel on the deployed site.
|
||||
|
||||
Reachable only from behind Authelia (this whole API is), which is what makes
|
||||
the token a credential worth trusting. Opening the link once stores it in
|
||||
that browser; `?ff-admin=` with no value signs out again."""
|
||||
entry = _flags_entry(body.slug)
|
||||
token = projectflags.mint_token()
|
||||
return {
|
||||
"token": token,
|
||||
"links": [
|
||||
{"host": host, "url": f"https://{host}/?ff-admin={token}"}
|
||||
for host in projectflags.deploy_hosts(entry)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _publish_flags(slug: str, entry: pathlib.Path) -> list[str]:
|
||||
"""Push the project's flag file to its live sites, no rebuild.
|
||||
|
||||
The container has neither zipgo nor the deploy key, so the host sidecar runs
|
||||
the actual `zipgo deploy --no-delete` of that single file."""
|
||||
hosts = projectflags.deploy_hosts(entry)
|
||||
if not hosts:
|
||||
return []
|
||||
state = projectflags.read(entry)
|
||||
if not state["exists"] or state["error"]:
|
||||
raise HTTPException(409, "no readable flag file to publish")
|
||||
out = _sidecar_call("/publish-flags", {"project": slug, "path": state["path"],
|
||||
"hosts": hosts})
|
||||
published = out.get("published")
|
||||
return published if isinstance(published, list) else []
|
||||
|
||||
|
||||
@app.post("/api/project-flags/publish", responses=_r(schemas.FlagPublishResponse))
|
||||
def project_flags_publish(body: FlagsSlugBody):
|
||||
"""Publish the committed flag file to the live sites."""
|
||||
entry = _flags_entry(body.slug)
|
||||
published = _publish_flags(body.slug, entry)
|
||||
return {"slug": body.slug, "published": published, "detail": None,
|
||||
"flags": projectflags.read(entry)["flags"]}
|
||||
|
||||
|
||||
# Origins the deployed sites live on. The panel's publish call carries its own
|
||||
# token, so this is not the auth boundary — it just keeps the endpoint from
|
||||
# being usable as a generic cross-origin write target from anywhere.
|
||||
_FLAGS_ORIGIN_RE = re.compile(r"^https://([a-z0-9-]+\.)*gabvdl\.xyz$")
|
||||
|
||||
|
||||
def _flags_cors(origin: str | None) -> dict:
|
||||
if origin and _FLAGS_ORIGIN_RE.match(origin):
|
||||
return {"Access-Control-Allow-Origin": origin, "Vary": "Origin"}
|
||||
return {}
|
||||
|
||||
|
||||
@app.post("/api/flags/publish")
|
||||
async def flags_publish(request: Request):
|
||||
"""Cross-origin flag flip from the admin panel on a deployed site.
|
||||
|
||||
Reached without any lab session, so the bearer here is the minted token and
|
||||
its HMAC is checked before anything is written. The panel sends `text/plain`
|
||||
so the browser treats this as a CORS *simple request* — no preflight, which
|
||||
matters because the forward-auth in front of this API answers an
|
||||
unauthenticated `OPTIONS` with a redirect the browser would reject."""
|
||||
origin = request.headers.get("origin")
|
||||
cors = _flags_cors(origin)
|
||||
|
||||
def fail(code: int, detail: str):
|
||||
return JSONResponse({"detail": detail}, status_code=code, headers=cors)
|
||||
|
||||
try:
|
||||
body = json.loads((await request.body()).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return fail(400, "malformed body")
|
||||
if not isinstance(body, dict):
|
||||
return fail(400, "malformed body")
|
||||
|
||||
if projectflags.verify_token(body.get("token")) is None:
|
||||
return fail(401, "invalid or expired admin token")
|
||||
|
||||
slug = body.get("project")
|
||||
key = body.get("key")
|
||||
if not isinstance(slug, str) or not isinstance(key, str):
|
||||
return fail(400, "project and key are required")
|
||||
entry = projects_mod._safe_entry(slug)
|
||||
if entry is None:
|
||||
return fail(404, f"unknown project: {slug}")
|
||||
|
||||
state = projectflags.read(entry)
|
||||
if state["error"]:
|
||||
return fail(409, f"{state['path']} is unreadable: {state['error']}")
|
||||
by_key = {f["key"]: f for f in state["flags"]}
|
||||
if key not in by_key:
|
||||
return fail(400, f"unknown flag: {key}")
|
||||
by_key[key]["enabled"] = bool(body.get("enabled"))
|
||||
projectflags.write(entry, list(by_key.values()))
|
||||
|
||||
# Getting the file live is best-effort: the repo is already updated, and a
|
||||
# sidecar that is down must not read as "the toggle failed".
|
||||
published: list[str] = []
|
||||
detail = None
|
||||
try:
|
||||
published = _publish_flags(slug, entry)
|
||||
except HTTPException as exc:
|
||||
detail = str(exc.detail)
|
||||
|
||||
return JSONResponse(
|
||||
{"slug": slug, "published": published, "detail": detail,
|
||||
"flags": projectflags.read(entry)["flags"]},
|
||||
headers=cors,
|
||||
)
|
||||
|
||||
|
||||
# Raster formats that are safe to render inline on the app origin. Anything
|
||||
# else served from user/repo-supplied bytes (SVG can carry scripts, HTML is
|
||||
# HTML) goes out as a download so it can never script against the API's cookies.
|
||||
|
||||
261
backend/projectflags.py
Normal file
261
backend/projectflags.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Read/write a project's ``feature-flags.json`` — the per-project declaration
|
||||
of the query-param feature flags its frontend ships behind.
|
||||
|
||||
The file lives in the project's **served** directory so the deployed site can
|
||||
fetch it at ``/feature-flags.json``:
|
||||
|
||||
<project>/public/feature-flags.json (Vite/CRA — preferred)
|
||||
<project>/feature-flags.json (fallback, plain static sites)
|
||||
|
||||
Shape::
|
||||
|
||||
{
|
||||
"flags": [
|
||||
{
|
||||
"key": "archives", # the query param: ?archives=1
|
||||
"label": "Archives",
|
||||
"description": "Past-grid browser in the header nav",
|
||||
"enabled": false, # the *for everyone* default
|
||||
"since": "2026-08-17"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
``enabled`` is the only field the admin panel flips for everyone; a visitor's
|
||||
own ``?key=1`` / panel toggle is a client-side override that never touches this
|
||||
file. Unknown keys on a flag are preserved across a round trip, so a project can
|
||||
carry extra metadata the editor doesn't know about.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
FILENAME = "feature-flags.json"
|
||||
|
||||
# A flag key doubles as a URL query parameter and a storage key, so keep it to
|
||||
# the characters that are unambiguous in both.
|
||||
KEY_RE = re.compile(r"^[a-z][a-z0-9-]{0,47}$")
|
||||
|
||||
# Fields the editor owns. Anything else on a flag object is passed through.
|
||||
_KNOWN = ("key", "label", "description", "enabled", "since")
|
||||
|
||||
|
||||
def flags_path(entry: Path) -> Path:
|
||||
"""Where this project's flags file lives (or would be created).
|
||||
|
||||
An existing file wins wherever it is; otherwise ``public/`` is preferred
|
||||
when the project has one (every Vite template does), so a newly declared
|
||||
flag is served by the deployed site without a build-config change.
|
||||
"""
|
||||
public = entry / "public" / FILENAME
|
||||
root = entry / FILENAME
|
||||
if public.is_file():
|
||||
return public
|
||||
if root.is_file():
|
||||
return root
|
||||
return public if (entry / "public").is_dir() else root
|
||||
|
||||
|
||||
def _coerce(raw: Any, index: int) -> dict | None:
|
||||
"""Normalise one entry of the ``flags`` array; None if unusable."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
key = raw.get("key")
|
||||
if not isinstance(key, str) or not KEY_RE.match(key):
|
||||
return None
|
||||
flag = dict(raw)
|
||||
flag["key"] = key
|
||||
flag["label"] = raw["label"] if isinstance(raw.get("label"), str) else key
|
||||
flag["description"] = raw["description"] if isinstance(raw.get("description"), str) else ""
|
||||
flag["enabled"] = bool(raw.get("enabled"))
|
||||
if not isinstance(raw.get("since"), str):
|
||||
flag.pop("since", None)
|
||||
return flag
|
||||
|
||||
|
||||
def read(entry: Path) -> dict:
|
||||
"""Parse the project's flags file. A missing file is not an error — it reads
|
||||
as an empty, not-yet-created flag set. A malformed one is reported rather
|
||||
than silently emptied, so the editor never offers to overwrite a file it
|
||||
failed to understand."""
|
||||
path = flags_path(entry)
|
||||
if not path.is_file():
|
||||
return {"path": None, "exists": False, "error": None, "flags": []}
|
||||
rel = str(path.relative_to(entry))
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
return {"path": rel, "exists": True, "error": str(exc), "flags": []}
|
||||
raw = data.get("flags") if isinstance(data, dict) else data
|
||||
if not isinstance(raw, list):
|
||||
return {"path": rel, "exists": True, "error": "no `flags` array", "flags": []}
|
||||
flags = [f for f in (_coerce(r, i) for i, r in enumerate(raw)) if f is not None]
|
||||
# Last write wins on a duplicated key — the file is hand-editable, and a
|
||||
# duplicate would otherwise make the editor's save ambiguous.
|
||||
seen: dict[str, dict] = {}
|
||||
for f in flags:
|
||||
seen[f["key"]] = f
|
||||
return {"path": rel, "exists": True, "error": None, "flags": list(seen.values())}
|
||||
|
||||
|
||||
def write(entry: Path, flags: list[dict]) -> dict:
|
||||
"""Replace the project's flag list, preserving any sibling top-level keys
|
||||
(``$schema``, project metadata…) already in the file."""
|
||||
path = flags_path(entry)
|
||||
doc: dict[str, Any] = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(existing, dict):
|
||||
doc = {k: v for k, v in existing.items() if k != "flags"}
|
||||
except (OSError, ValueError):
|
||||
doc = {}
|
||||
doc["flags"] = flags
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
||||
# Atomic replace so a half-written file can never be served.
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".flags-", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
return read(entry)
|
||||
|
||||
|
||||
def merge(current: list[dict], updates: list[dict], allow_create: bool) -> list[dict]:
|
||||
"""Apply editor updates onto the current list.
|
||||
|
||||
``updates`` is the full desired list — order included, since the panel shows
|
||||
flags in file order. Unknown fields on an existing flag survive because the
|
||||
stored object is updated in place rather than rebuilt.
|
||||
"""
|
||||
by_key = {f["key"]: f for f in current}
|
||||
out: list[dict] = []
|
||||
for upd in updates:
|
||||
key = upd.get("key")
|
||||
if not isinstance(key, str) or not KEY_RE.match(key):
|
||||
raise ValueError(f"invalid flag key: {key!r}")
|
||||
prev = by_key.get(key)
|
||||
if prev is None and not allow_create:
|
||||
raise ValueError(f"unknown flag: {key!r}")
|
||||
flag = dict(prev) if prev else {"key": key}
|
||||
for field in ("label", "description", "since"):
|
||||
if field in upd:
|
||||
flag[field] = upd[field]
|
||||
if "enabled" in upd:
|
||||
flag["enabled"] = bool(upd["enabled"])
|
||||
flag.setdefault("label", key)
|
||||
flag.setdefault("description", "")
|
||||
flag.setdefault("enabled", False)
|
||||
out.append(flag)
|
||||
return out
|
||||
|
||||
|
||||
# ── admin token ───────────────────────────────────────────────────────────────
|
||||
# The deployed sites are public and have no session with the lab, so the admin
|
||||
# panel is unlocked by a token instead: mint it here (behind Authelia), carry it
|
||||
# to the site once as `?ff-admin=…`, and it lives in that browser's
|
||||
# localStorage. Showing the panel is a client-side check; *publishing* a flag
|
||||
# for everyone comes back here and is verified below, which is the boundary that
|
||||
# actually matters.
|
||||
|
||||
TOKEN_PREFIX = "ffa1"
|
||||
TOKEN_TTL = 365 * 24 * 3600 # a year — this is a bookmarklet-grade credential
|
||||
|
||||
_SECRET_PATH = Path(os.environ.get("FLAGS_SECRET_PATH", "/data/flags-secret"))
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _unb64(text: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
"""The HMAC key, created on first use. Deleting the file revokes every token
|
||||
ever minted — that is the intended panic button."""
|
||||
try:
|
||||
return bytes.fromhex(_SECRET_PATH.read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
raw = secrets.token_bytes(32)
|
||||
_SECRET_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Write via a private temp file so the secret is never briefly world-readable.
|
||||
fd, tmp = tempfile.mkstemp(dir=str(_SECRET_PATH.parent), prefix=".secret-")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write(raw.hex())
|
||||
os.replace(tmp, _SECRET_PATH)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
return raw
|
||||
|
||||
|
||||
def mint_token(subject: str = "gabrielvidal", ttl: int = TOKEN_TTL) -> str:
|
||||
now = int(time.time())
|
||||
payload = _b64(json.dumps({"sub": subject, "iat": now, "exp": now + ttl}).encode())
|
||||
sig = _b64(hmac.new(_secret(), payload.encode(), hashlib.sha256).digest())
|
||||
return f"{TOKEN_PREFIX}.{payload}.{sig}"
|
||||
|
||||
|
||||
def verify_token(token: str | None) -> dict | None:
|
||||
"""Decoded payload for a valid, unexpired token; None otherwise."""
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3 or parts[0] != TOKEN_PREFIX:
|
||||
return None
|
||||
_, payload, sig = parts
|
||||
expected = _b64(hmac.new(_secret(), payload.encode(), hashlib.sha256).digest())
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(_unb64(payload))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict) or int(data.get("exp", 0)) < time.time():
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def revoke_all() -> None:
|
||||
"""Rotate the signing key, invalidating every issued token."""
|
||||
try:
|
||||
_SECRET_PATH.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
# ── deploy hosts ──────────────────────────────────────────────────────────────
|
||||
def deploy_hosts(entry: Path) -> list[str]:
|
||||
"""The hosts this project deploys to, from `package.json` → `zipgo.deploy`.
|
||||
|
||||
These are where an admin link points and where a publish pushes the file.
|
||||
"""
|
||||
try:
|
||||
pkg = json.loads((entry / "package.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
deploy = (pkg.get("zipgo") or {}).get("deploy") if isinstance(pkg, dict) else None
|
||||
if not isinstance(deploy, dict):
|
||||
return []
|
||||
return [h for h in deploy if isinstance(h, str) and h]
|
||||
@@ -997,6 +997,46 @@ class ProjectEnvResponse(Schema):
|
||||
vars: list[ProjectEnvVar]
|
||||
|
||||
|
||||
class FeatureFlag(Schema):
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
# The "for everyone" default, as committed in the project's flag file.
|
||||
enabled: bool
|
||||
since: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFlagsResponse(Schema):
|
||||
slug: str
|
||||
# Repo-relative path of the flag file (None when the project has none yet).
|
||||
path: Optional[str] = None
|
||||
exists: bool
|
||||
# Set when the file is present but unparseable — the editor refuses to
|
||||
# overwrite a file it could not read.
|
||||
error: Optional[str] = None
|
||||
flags: list[FeatureFlag]
|
||||
# Hosts from package.json → zipgo.deploy: where a publish pushes the file.
|
||||
hosts: list[str]
|
||||
|
||||
|
||||
class FlagAdminLink(Schema):
|
||||
host: str
|
||||
url: str
|
||||
|
||||
|
||||
class FlagAdminLinkResponse(Schema):
|
||||
token: str
|
||||
links: list[FlagAdminLink]
|
||||
|
||||
|
||||
class FlagPublishResponse(Schema):
|
||||
slug: str
|
||||
# Hosts the flag file was pushed to live; empty when nothing was published.
|
||||
published: list[str]
|
||||
detail: Optional[str] = None
|
||||
flags: list[FeatureFlag]
|
||||
|
||||
|
||||
# ── templates ─────────────────────────────────────────────────────────────────
|
||||
class TemplateSummary(Schema):
|
||||
name: str
|
||||
|
||||
@@ -2372,6 +2372,178 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/project-flags": {
|
||||
"get": {
|
||||
"summary": "Project Flags",
|
||||
"description": "The project's declared feature flags and their for-everyone defaults.",
|
||||
"operationId": "project_flags_api_project_flags_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Slug"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectFlagsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Project Flags Save",
|
||||
"description": "Flip the for-everyone default of flags the project already declares.\n\nDeliberately toggle-only: a flag with no code behind it is dead weight, so\ncreating and deleting them stays with whoever ships the feature (by editing\nthe file). Unknown keys are rejected rather than silently created.",
|
||||
"operationId": "project_flags_save_api_project_flags_put",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FlagsSaveBody"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectFlagsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/project-flags/admin-link": {
|
||||
"post": {
|
||||
"summary": "Project Flags Admin Link",
|
||||
"description": "Mint the one-time link that unlocks the admin panel on the deployed site.\n\nReachable only from behind Authelia (this whole API is), which is what makes\nthe token a credential worth trusting. Opening the link once stores it in\nthat browser; `?ff-admin=` with no value signs out again.",
|
||||
"operationId": "project_flags_admin_link_api_project_flags_admin_link_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FlagsSlugBody"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FlagAdminLinkResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/project-flags/publish": {
|
||||
"post": {
|
||||
"summary": "Project Flags Publish",
|
||||
"description": "Publish the committed flag file to the live sites.",
|
||||
"operationId": "project_flags_publish_api_project_flags_publish_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FlagsSlugBody"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FlagPublishResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/flags/publish": {
|
||||
"post": {
|
||||
"summary": "Flags Publish",
|
||||
"description": "Cross-origin flag flip from the admin panel on a deployed site.\n\nReached without any lab session, so the bearer here is the minted token and\nits HMAC is checked before anything is written. The panel sends `text/plain`\nso the browser treats this as a CORS *simple request* \u2014 no preflight, which\nmatters because the forward-auth in front of this API answers an\nunauthenticated `OPTIONS` with a redirect the browser would reject.",
|
||||
"operationId": "flags_publish_api_flags_publish_post",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/project-asset": {
|
||||
"get": {
|
||||
"summary": "Project Asset",
|
||||
@@ -6472,6 +6644,45 @@
|
||||
],
|
||||
"title": "EnvSetItem"
|
||||
},
|
||||
"FeatureFlag": {
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"title": "Key"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"title": "Label"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"title": "Description"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled"
|
||||
},
|
||||
"since": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Since"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"label",
|
||||
"description",
|
||||
"enabled"
|
||||
],
|
||||
"title": "FeatureFlag"
|
||||
},
|
||||
"FeedNotification": {
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -6737,6 +6948,137 @@
|
||||
],
|
||||
"title": "FileEntry"
|
||||
},
|
||||
"FlagAdminLink": {
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"title": "Host"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"title": "Url"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"host",
|
||||
"url"
|
||||
],
|
||||
"title": "FlagAdminLink"
|
||||
},
|
||||
"FlagAdminLinkResponse": {
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"title": "Token"
|
||||
},
|
||||
"links": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FlagAdminLink"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Links"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"token",
|
||||
"links"
|
||||
],
|
||||
"title": "FlagAdminLinkResponse"
|
||||
},
|
||||
"FlagPublishResponse": {
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"title": "Slug"
|
||||
},
|
||||
"published": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Published"
|
||||
},
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Detail"
|
||||
},
|
||||
"flags": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FeatureFlag"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Flags"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"slug",
|
||||
"published",
|
||||
"flags"
|
||||
],
|
||||
"title": "FlagPublishResponse"
|
||||
},
|
||||
"FlagSetItem": {
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"title": "Key"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"enabled"
|
||||
],
|
||||
"title": "FlagSetItem"
|
||||
},
|
||||
"FlagsSaveBody": {
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"title": "Slug"
|
||||
},
|
||||
"set": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FlagSetItem"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Set",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"slug"
|
||||
],
|
||||
"title": "FlagsSaveBody"
|
||||
},
|
||||
"FlagsSlugBody": {
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"title": "Slug"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"slug"
|
||||
],
|
||||
"title": "FlagsSlugBody"
|
||||
},
|
||||
"ForkBody": {
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -10004,6 +10346,62 @@
|
||||
],
|
||||
"title": "ProjectEnvVar"
|
||||
},
|
||||
"ProjectFlagsResponse": {
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"title": "Slug"
|
||||
},
|
||||
"path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Path"
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean",
|
||||
"title": "Exists"
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Error"
|
||||
},
|
||||
"flags": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FeatureFlag"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Flags"
|
||||
},
|
||||
"hosts": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Hosts"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"slug",
|
||||
"exists",
|
||||
"flags",
|
||||
"hosts"
|
||||
],
|
||||
"title": "ProjectFlagsResponse"
|
||||
},
|
||||
"ProjectSummary": {
|
||||
"properties": {
|
||||
"dir": {
|
||||
|
||||
@@ -23,10 +23,13 @@ import type {
|
||||
NotifyLogEntry,
|
||||
NotifyResult,
|
||||
EnvSaveBody,
|
||||
FlagAdminLinkResponse,
|
||||
FlagPublishResponse,
|
||||
PlanDetail,
|
||||
PlanSummary,
|
||||
ProjectDetail,
|
||||
ProjectEnvResponse,
|
||||
ProjectFlagsResponse,
|
||||
ProjectSummary,
|
||||
SearchConv,
|
||||
ServiceDetail,
|
||||
@@ -619,6 +622,50 @@ export async function saveProjectEnv(body: EnvSaveBody): Promise<ProjectEnvRespo
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchProjectFlags(slug: string): Promise<ProjectFlagsResponse> {
|
||||
const r = await apiFetch(`/api/project-flags?slug=${encodeURIComponent(slug)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!r.ok) throw new Error(`project-flags: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** Flip the "for everyone" default of flags the project already declares. */
|
||||
export async function saveProjectFlags(body: {
|
||||
slug: string;
|
||||
set: { key: string; enabled: boolean }[];
|
||||
}): Promise<ProjectFlagsResponse> {
|
||||
const r = await apiFetch("/api/project-flags", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`project-flags save: ${r.status} ${await r.text().catch(() => "")}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** Mint the `?ff-admin=…` link that unlocks the admin panel on the live site. */
|
||||
export async function mintFlagsAdminLink(slug: string): Promise<FlagAdminLinkResponse> {
|
||||
const r = await apiFetch("/api/project-flags/admin-link", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`admin-link: ${r.status} ${await r.text().catch(() => "")}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** Push the committed flag file to the live sites (no rebuild). */
|
||||
export async function publishProjectFlags(slug: string): Promise<FlagPublishResponse> {
|
||||
const r = await apiFetch("/api/project-flags/publish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`publish: ${r.status} ${await r.text().catch(() => "")}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchConversationCommits(id: string): Promise<ConversationCommits> {
|
||||
const r = await apiFetch(`/api/conversation-commits?id=${encodeURIComponent(id)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
|
||||
215
frontend/src/business/projects/components/FlagsEditor.tsx
Normal file
215
frontend/src/business/projects/components/FlagsEditor.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Flag, KeyRound, Loader2, Rocket } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QK, useProjectFlags } from "@/lib/queries";
|
||||
import { mintFlagsAdminLink, publishProjectFlags, saveProjectFlags } from "@/api";
|
||||
import type { FlagPublishResponse, ProjectFlagsResponse } from "@/types";
|
||||
|
||||
/**
|
||||
* Project page → feature flags.
|
||||
*
|
||||
* Flags are **declared in code** — `public/feature-flags.json`, written by
|
||||
* whoever ships the feature. This card only flips each one's *for everyone*
|
||||
* default, hands out the admin link that unlocks the panel on the live site,
|
||||
* and pushes the file to that site so a flip takes effect without a rebuild.
|
||||
*
|
||||
* Saving is immediate per row (one toggle = one write): there is a single
|
||||
* boolean per flag, so a draft/save/discard cycle would be ceremony around a
|
||||
* switch.
|
||||
*/
|
||||
export function FlagsEditor({ slug }: { slug: string }) {
|
||||
const { data, isLoading } = useProjectFlags(slug);
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const applied = (saved: ProjectFlagsResponse) => {
|
||||
qc.setQueryData(QK.projectFlags(slug), saved);
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: saveProjectFlags,
|
||||
onSuccess: applied,
|
||||
onSettled: () => setPending(null),
|
||||
});
|
||||
const publish = useMutation({
|
||||
mutationFn: () => publishProjectFlags(slug),
|
||||
onSuccess: (out: FlagPublishResponse) => {
|
||||
if (data) applied({ ...data, flags: out.flags });
|
||||
},
|
||||
});
|
||||
const mint = useMutation({
|
||||
mutationFn: () => mintFlagsAdminLink(slug),
|
||||
onSuccess: (out) => {
|
||||
setLink(out.links[0]?.url ?? null);
|
||||
setCopied(false);
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = (key: string, enabled: boolean) => {
|
||||
setPending(key);
|
||||
save.mutate({ slug, set: [{ key, enabled }] });
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
if (!link) return;
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => setCopied(true),
|
||||
() => setCopied(false),
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-sm text-muted-foreground">Loading feature flags…</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const flags = data?.flags ?? [];
|
||||
const hosts = data?.hosts ?? [];
|
||||
const error = save.error ?? publish.error ?? mint.error;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{data?.error && (
|
||||
<p className="mb-2 text-[12px] text-destructive">
|
||||
<code>{data.path}</code> is unreadable ({data.error}) — fix the file before editing here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{flags.length === 0 && !data?.error && (
|
||||
<p className="mb-2 text-[12px] text-muted-foreground">
|
||||
No flags declared. Add them to{" "}
|
||||
<code>{data?.path ?? "public/feature-flags.json"}</code> when you ship a feature behind{" "}
|
||||
<code>?flag=1</code> — this card toggles them, it doesn't invent them.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{flags.map((f) => (
|
||||
<div
|
||||
key={f.key}
|
||||
className="flex items-start gap-2 rounded-md border border-border px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[12px] font-medium">{f.label}</span>
|
||||
<code className="font-mono text-[10.5px] text-muted-foreground">?{f.key}=1</code>
|
||||
{f.since && (
|
||||
<span className="text-[10px] text-muted-foreground/70">since {f.since}</span>
|
||||
)}
|
||||
</div>
|
||||
{f.description && (
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{f.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex shrink-0 items-center gap-1.5 pt-0.5 text-[11px] text-muted-foreground">
|
||||
{pending === f.key ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.enabled}
|
||||
disabled={save.isPending}
|
||||
onChange={(e) => toggle(f.key, e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
)}
|
||||
everyone
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => mint.mutate()}
|
||||
disabled={mint.isPending || hosts.length === 0}
|
||||
title={
|
||||
hosts.length === 0
|
||||
? "No deploy host in package.json → zipgo.deploy"
|
||||
: "Mint a link that unlocks the admin panel on the live site"
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-[12px] text-muted-foreground hover:text-foreground",
|
||||
(mint.isPending || hosts.length === 0) && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{mint.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<KeyRound className="h-3 w-3" />
|
||||
)}
|
||||
Admin link
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => publish.mutate()}
|
||||
disabled={publish.isPending || flags.length === 0 || hosts.length === 0}
|
||||
title="Push feature-flags.json to the live site — no rebuild"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-[12px] font-medium text-primary-foreground",
|
||||
(publish.isPending || flags.length === 0 || hosts.length === 0) && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-3 w-3" />
|
||||
)}
|
||||
Publish now
|
||||
</button>
|
||||
|
||||
{publish.isSuccess && !publish.isPending && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{publish.data.published.length
|
||||
? `Live on ${publish.data.published.join(", ")}`
|
||||
: "Nothing published — no deploy host."}
|
||||
</span>
|
||||
)}
|
||||
{!publish.isSuccess && flags.length > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/70">
|
||||
Toggles are saved to the repo; publish to make them live now.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded-md border border-dashed border-border px-2 py-1">
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-[10.5px] text-muted-foreground">
|
||||
{link}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
title="Copy — open it once in the browser you want the panel in"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-[11px] text-destructive">{String(error)}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border border-border bg-card p-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Flag className="h-3.5 w-3.5 text-primary" />
|
||||
Feature flags
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { Markdown } from "@/technical/Markdown";
|
||||
import { GoalLinkButton, GoalSection } from "@/business/projects/components/Goal";
|
||||
import { GoalChecklist } from "@/business/projects/components/GoalChecklist";
|
||||
import { EnvEditor } from "@/business/projects/components/EnvEditor";
|
||||
import { FlagsEditor } from "@/business/projects/components/FlagsEditor";
|
||||
import { StateBadge, StatusChecks } from "@/business/conversations/components/ConvMetaBits";
|
||||
import type {
|
||||
ProjectDetail,
|
||||
@@ -273,6 +274,10 @@ export function Project() {
|
||||
edited from here. */}
|
||||
{!project.isRoot && <EnvEditor slug={project.dir} />}
|
||||
|
||||
{/* feature flags — flip a `?flag=1` feature on for everyone, and mint
|
||||
the link that unlocks the admin panel on the deployed site. */}
|
||||
{!project.isRoot && <FlagsEditor slug={project.dir} />}
|
||||
|
||||
{/* recent commits */}
|
||||
{project.commits.length > 0 && (
|
||||
<Section icon={GitCommit} title="Recent commits">
|
||||
|
||||
@@ -46,6 +46,10 @@ import type {
|
||||
DiffResult,
|
||||
EnvSaveBody,
|
||||
FileEntry,
|
||||
FlagAdminLinkResponse,
|
||||
FlagPublishResponse,
|
||||
FlagsSaveBody,
|
||||
FlagsSlugBody,
|
||||
ForkBody,
|
||||
FormCreateBody,
|
||||
FormGetApiFormsFormIdGetParams,
|
||||
@@ -84,6 +88,8 @@ import type {
|
||||
ProjectDetailApiProjectGetParams,
|
||||
ProjectEnvApiProjectEnvGetParams,
|
||||
ProjectEnvResponse,
|
||||
ProjectFlagsApiProjectFlagsGetParams,
|
||||
ProjectFlagsResponse,
|
||||
ProjectsResponse,
|
||||
ResumeBody,
|
||||
SaveBody,
|
||||
@@ -3475,6 +3481,275 @@ export const projectEnvSaveApiProjectEnvPut = async (envSaveBody: EnvSaveBody, o
|
||||
|
||||
|
||||
|
||||
export type projectFlagsApiProjectFlagsGetResponse200 = {
|
||||
data: ProjectFlagsResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type projectFlagsApiProjectFlagsGetResponse422 = {
|
||||
data: HTTPValidationError
|
||||
status: 422
|
||||
}
|
||||
|
||||
export type projectFlagsApiProjectFlagsGetResponseSuccess = (projectFlagsApiProjectFlagsGetResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type projectFlagsApiProjectFlagsGetResponseError = (projectFlagsApiProjectFlagsGetResponse422) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type projectFlagsApiProjectFlagsGetResponse = (projectFlagsApiProjectFlagsGetResponseSuccess | projectFlagsApiProjectFlagsGetResponseError)
|
||||
|
||||
export const getProjectFlagsApiProjectFlagsGetUrl = (params: ProjectFlagsApiProjectFlagsGetParams,) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? 'null' : String(value))
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0 ? `/api/project-flags?${stringifiedParams}` : `/api/project-flags`
|
||||
}
|
||||
|
||||
/**
|
||||
* The project's declared feature flags and their for-everyone defaults.
|
||||
* @summary Project Flags
|
||||
*/
|
||||
export const projectFlagsApiProjectFlagsGet = async (params: ProjectFlagsApiProjectFlagsGetParams, options?: RequestInit): Promise<projectFlagsApiProjectFlagsGetResponse> => {
|
||||
|
||||
const res = await fetch(getProjectFlagsApiProjectFlagsGetUrl(params),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: projectFlagsApiProjectFlagsGetResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as projectFlagsApiProjectFlagsGetResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type projectFlagsSaveApiProjectFlagsPutResponse200 = {
|
||||
data: ProjectFlagsResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type projectFlagsSaveApiProjectFlagsPutResponse422 = {
|
||||
data: HTTPValidationError
|
||||
status: 422
|
||||
}
|
||||
|
||||
export type projectFlagsSaveApiProjectFlagsPutResponseSuccess = (projectFlagsSaveApiProjectFlagsPutResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type projectFlagsSaveApiProjectFlagsPutResponseError = (projectFlagsSaveApiProjectFlagsPutResponse422) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type projectFlagsSaveApiProjectFlagsPutResponse = (projectFlagsSaveApiProjectFlagsPutResponseSuccess | projectFlagsSaveApiProjectFlagsPutResponseError)
|
||||
|
||||
export const getProjectFlagsSaveApiProjectFlagsPutUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/project-flags`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the for-everyone default of flags the project already declares.
|
||||
*
|
||||
* Deliberately toggle-only: a flag with no code behind it is dead weight, so
|
||||
* creating and deleting them stays with whoever ships the feature (by editing
|
||||
* the file). Unknown keys are rejected rather than silently created.
|
||||
* @summary Project Flags Save
|
||||
*/
|
||||
export const projectFlagsSaveApiProjectFlagsPut = async (flagsSaveBody: FlagsSaveBody, options?: RequestInit): Promise<projectFlagsSaveApiProjectFlagsPutResponse> => {
|
||||
|
||||
const res = await fetch(getProjectFlagsSaveApiProjectFlagsPutUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(flagsSaveBody)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: projectFlagsSaveApiProjectFlagsPutResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as projectFlagsSaveApiProjectFlagsPutResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse200 = {
|
||||
data: FlagAdminLinkResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse422 = {
|
||||
data: HTTPValidationError
|
||||
status: 422
|
||||
}
|
||||
|
||||
export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseSuccess = (projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseError = (projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse422) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse = (projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseSuccess | projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponseError)
|
||||
|
||||
export const getProjectFlagsAdminLinkApiProjectFlagsAdminLinkPostUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/project-flags/admin-link`
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the one-time link that unlocks the admin panel on the deployed site.
|
||||
*
|
||||
* Reachable only from behind Authelia (this whole API is), which is what makes
|
||||
* the token a credential worth trusting. Opening the link once stores it in
|
||||
* that browser; `?ff-admin=` with no value signs out again.
|
||||
* @summary Project Flags Admin Link
|
||||
*/
|
||||
export const projectFlagsAdminLinkApiProjectFlagsAdminLinkPost = async (flagsSlugBody: FlagsSlugBody, options?: RequestInit): Promise<projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse> => {
|
||||
|
||||
const res = await fetch(getProjectFlagsAdminLinkApiProjectFlagsAdminLinkPostUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(flagsSlugBody)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as projectFlagsAdminLinkApiProjectFlagsAdminLinkPostResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type projectFlagsPublishApiProjectFlagsPublishPostResponse200 = {
|
||||
data: FlagPublishResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type projectFlagsPublishApiProjectFlagsPublishPostResponse422 = {
|
||||
data: HTTPValidationError
|
||||
status: 422
|
||||
}
|
||||
|
||||
export type projectFlagsPublishApiProjectFlagsPublishPostResponseSuccess = (projectFlagsPublishApiProjectFlagsPublishPostResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type projectFlagsPublishApiProjectFlagsPublishPostResponseError = (projectFlagsPublishApiProjectFlagsPublishPostResponse422) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type projectFlagsPublishApiProjectFlagsPublishPostResponse = (projectFlagsPublishApiProjectFlagsPublishPostResponseSuccess | projectFlagsPublishApiProjectFlagsPublishPostResponseError)
|
||||
|
||||
export const getProjectFlagsPublishApiProjectFlagsPublishPostUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/project-flags/publish`
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the committed flag file to the live sites.
|
||||
* @summary Project Flags Publish
|
||||
*/
|
||||
export const projectFlagsPublishApiProjectFlagsPublishPost = async (flagsSlugBody: FlagsSlugBody, options?: RequestInit): Promise<projectFlagsPublishApiProjectFlagsPublishPostResponse> => {
|
||||
|
||||
const res = await fetch(getProjectFlagsPublishApiProjectFlagsPublishPostUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(flagsSlugBody)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: projectFlagsPublishApiProjectFlagsPublishPostResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as projectFlagsPublishApiProjectFlagsPublishPostResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type flagsPublishApiFlagsPublishPostResponse200 = {
|
||||
data: unknown
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type flagsPublishApiFlagsPublishPostResponseSuccess = (flagsPublishApiFlagsPublishPostResponse200) & {
|
||||
headers: Headers;
|
||||
};
|
||||
;
|
||||
|
||||
export type flagsPublishApiFlagsPublishPostResponse = (flagsPublishApiFlagsPublishPostResponseSuccess)
|
||||
|
||||
export const getFlagsPublishApiFlagsPublishPostUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/flags/publish`
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-origin flag flip from the admin panel on a deployed site.
|
||||
*
|
||||
* Reached without any lab session, so the bearer here is the minted token and
|
||||
* its HMAC is checked before anything is written. The panel sends `text/plain`
|
||||
* so the browser treats this as a CORS *simple request* — no preflight, which
|
||||
* matters because the forward-auth in front of this API answers an
|
||||
* unauthenticated `OPTIONS` with a redirect the browser would reject.
|
||||
* @summary Flags Publish
|
||||
*/
|
||||
export const flagsPublishApiFlagsPublishPost = async ( options?: RequestInit): Promise<flagsPublishApiFlagsPublishPostResponse> => {
|
||||
|
||||
const res = await fetch(getFlagsPublishApiFlagsPublishPostUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST'
|
||||
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: flagsPublishApiFlagsPublishPostResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as flagsPublishApiFlagsPublishPostResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type projectAssetApiProjectAssetGetResponse200 = {
|
||||
data: unknown
|
||||
status: 200
|
||||
|
||||
14
frontend/src/generated/model/featureFlag.ts
Normal file
14
frontend/src/generated/model/featureFlag.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
since?: string | null;
|
||||
}
|
||||
11
frontend/src/generated/model/flagAdminLink.ts
Normal file
11
frontend/src/generated/model/flagAdminLink.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface FlagAdminLink {
|
||||
host: string;
|
||||
url: string;
|
||||
}
|
||||
12
frontend/src/generated/model/flagAdminLinkResponse.ts
Normal file
12
frontend/src/generated/model/flagAdminLinkResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { FlagAdminLink } from './flagAdminLink.ts';
|
||||
|
||||
export interface FlagAdminLinkResponse {
|
||||
token: string;
|
||||
links: FlagAdminLink[];
|
||||
}
|
||||
14
frontend/src/generated/model/flagPublishResponse.ts
Normal file
14
frontend/src/generated/model/flagPublishResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { FeatureFlag } from './featureFlag.ts';
|
||||
|
||||
export interface FlagPublishResponse {
|
||||
slug: string;
|
||||
published: string[];
|
||||
detail?: string | null;
|
||||
flags: FeatureFlag[];
|
||||
}
|
||||
11
frontend/src/generated/model/flagSetItem.ts
Normal file
11
frontend/src/generated/model/flagSetItem.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface FlagSetItem {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
12
frontend/src/generated/model/flagsSaveBody.ts
Normal file
12
frontend/src/generated/model/flagsSaveBody.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { FlagSetItem } from './flagSetItem.ts';
|
||||
|
||||
export interface FlagsSaveBody {
|
||||
slug: string;
|
||||
set?: FlagSetItem[];
|
||||
}
|
||||
10
frontend/src/generated/model/flagsSlugBody.ts
Normal file
10
frontend/src/generated/model/flagsSlugBody.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface FlagsSlugBody {
|
||||
slug: string;
|
||||
}
|
||||
@@ -80,11 +80,18 @@ export * from './diffResult.ts';
|
||||
export * from './diffResultSource.ts';
|
||||
export * from './envSaveBody.ts';
|
||||
export * from './envSetItem.ts';
|
||||
export * from './featureFlag.ts';
|
||||
export * from './feedNotification.ts';
|
||||
export * from './fileDiff.ts';
|
||||
export * from './fileDiffStatus.ts';
|
||||
export * from './fileEntry.ts';
|
||||
export * from './fileEntryKind.ts';
|
||||
export * from './flagAdminLink.ts';
|
||||
export * from './flagAdminLinkResponse.ts';
|
||||
export * from './flagPublishResponse.ts';
|
||||
export * from './flagSetItem.ts';
|
||||
export * from './flagsSaveBody.ts';
|
||||
export * from './flagsSlugBody.ts';
|
||||
export * from './forkBody.ts';
|
||||
export * from './forkedFrom.ts';
|
||||
export * from './formCreateBody.ts';
|
||||
@@ -170,6 +177,8 @@ export * from './projectDetailApiProjectGetParams.ts';
|
||||
export * from './projectEnvApiProjectEnvGetParams.ts';
|
||||
export * from './projectEnvResponse.ts';
|
||||
export * from './projectEnvVar.ts';
|
||||
export * from './projectFlagsApiProjectFlagsGetParams.ts';
|
||||
export * from './projectFlagsResponse.ts';
|
||||
export * from './projectsResponse.ts';
|
||||
export * from './projectSummary.ts';
|
||||
export * from './resumeBody.ts';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ProjectFlagsApiProjectFlagsGetParams = {
|
||||
slug: string;
|
||||
};
|
||||
16
frontend/src/generated/model/projectFlagsResponse.ts
Normal file
16
frontend/src/generated/model/projectFlagsResponse.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v8.20.0 🍺
|
||||
* Do not edit manually.
|
||||
* ai-agent
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { FeatureFlag } from './featureFlag.ts';
|
||||
|
||||
export interface ProjectFlagsResponse {
|
||||
slug: string;
|
||||
path?: string | null;
|
||||
exists: boolean;
|
||||
error?: string | null;
|
||||
flags: FeatureFlag[];
|
||||
hosts: string[];
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fetchNotifyLog,
|
||||
fetchPlans,
|
||||
fetchProjectEnv,
|
||||
fetchProjectFlags,
|
||||
fetchProjects,
|
||||
fetchServices,
|
||||
fetchSkills,
|
||||
@@ -77,6 +78,7 @@ export const QK = {
|
||||
commitDiff: (repo: string, sha: string) => ["commit-diff", repo, sha] as const,
|
||||
projects: ["projects"] as const,
|
||||
projectEnv: (slug: string) => ["project-env", slug] as const,
|
||||
projectFlags: (slug: string) => ["project-flags", slug] as const,
|
||||
services: ["services"] as const,
|
||||
skills: ["skills"] as const,
|
||||
// Shared "agents" prefix: the catalog and every detail page are all derived
|
||||
@@ -405,6 +407,15 @@ export function useProjectEnv(slug: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** A project's declared feature flags and their for-everyone defaults. */
|
||||
export function useProjectFlags(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: QK.projectFlags(slug),
|
||||
queryFn: () => fetchProjectFlags(slug),
|
||||
enabled: !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServices() {
|
||||
return useQuery({ queryKey: QK.services, queryFn: fetchServices });
|
||||
}
|
||||
|
||||
@@ -39,8 +39,10 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid as uuidlib
|
||||
|
||||
@@ -775,3 +777,83 @@ def interrupt(body: InterruptBody,
|
||||
|
||||
pid_path.unlink(missing_ok=True)
|
||||
return {"sessionId": sid, "pid": pid, "signal": "SIGINT", "ok": True}
|
||||
|
||||
|
||||
# ---- publish a project's feature flags -------------------------------------
|
||||
# Flipping a flag "for everyone" rewrites `<project>/public/feature-flags.json`
|
||||
# in the repo, but the live site keeps serving its deployed copy until the next
|
||||
# build. Pushing that one file makes the flip take effect immediately.
|
||||
#
|
||||
# The backend can't do it itself: the container has neither zipgo nor the deploy
|
||||
# key. So it asks here — and this stays a *fixed* operation (one named file, to
|
||||
# hosts the project's own package.json declares) rather than an exec endpoint,
|
||||
# because "run this command on the host" is not something the bearer token
|
||||
# should ever buy.
|
||||
|
||||
PROJECTS_DIR = pathlib.Path(
|
||||
os.environ.get("SIDECAR_PROJECTS_DIR", pathlib.Path.home() / "projects"))
|
||||
ZIPGO_BIN = os.environ.get("ZIPGO_BIN", "zipgo")
|
||||
# Same target every project's scripts/deploy.sh uses (raspy2 over Tailscale).
|
||||
ZIPGO_SSH = os.environ.get(
|
||||
"ZIPGO_SSH", "gabrielvidal@100.74.118.12:/home/gabrielvidal/services/domains")
|
||||
FLAGS_FILENAME = "feature-flags.json"
|
||||
HOST_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$")
|
||||
|
||||
|
||||
class PublishFlagsBody(BaseModel):
|
||||
project: str # ~/projects/<project>
|
||||
path: str # repo-relative, must end in feature-flags.json
|
||||
hosts: list[str] # zipgo hosts from the project's package.json
|
||||
|
||||
|
||||
@app.post("/publish-flags")
|
||||
def publish_flags(body: PublishFlagsBody,
|
||||
authorization: str | None = Header(default=None)) -> dict:
|
||||
_auth(authorization)
|
||||
|
||||
name = pathlib.Path(body.project).name
|
||||
if not name or name.startswith("."):
|
||||
raise HTTPException(400, "invalid project")
|
||||
root = (PROJECTS_DIR / name).resolve()
|
||||
try:
|
||||
root.relative_to(PROJECTS_DIR.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid project")
|
||||
|
||||
rel = pathlib.PurePosixPath(body.path)
|
||||
if rel.is_absolute() or ".." in rel.parts or rel.name != FLAGS_FILENAME:
|
||||
raise HTTPException(400, f"path must be a relative {FLAGS_FILENAME}")
|
||||
src = (root / rel).resolve()
|
||||
try:
|
||||
src.relative_to(root)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "path escapes the project")
|
||||
if not src.is_file():
|
||||
raise HTTPException(404, f"{body.path} not found in {name}")
|
||||
|
||||
hosts = [h for h in body.hosts if HOST_RE.match(h or "")]
|
||||
if not hosts:
|
||||
raise HTTPException(400, "no valid hosts")
|
||||
|
||||
# zipgo syncs a *directory* into the remote site folder, so stage the single
|
||||
# file in one of its own. `--no-delete` is what keeps this from wiping the
|
||||
# deployed site down to just this file.
|
||||
published, errors = [], []
|
||||
with tempfile.TemporaryDirectory(prefix="ff-publish-") as staging:
|
||||
shutil.copyfile(src, pathlib.Path(staging) / FLAGS_FILENAME)
|
||||
for host in hosts:
|
||||
cmd = [ZIPGO_BIN, "deploy", staging + "/", "-d", host,
|
||||
"--no-delete", "--ssh", ZIPGO_SSH]
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
errors.append(f"{host}: {e}")
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
published.append(host)
|
||||
else:
|
||||
errors.append(f"{host}: {(proc.stderr or proc.stdout).strip()[:200]}")
|
||||
|
||||
if not published:
|
||||
raise HTTPException(502, "publish failed — " + "; ".join(errors))
|
||||
return {"project": name, "published": published, "errors": errors}
|
||||
|
||||
Reference in New Issue
Block a user