Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 138 additions & 59 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,53 @@ Public images from Docker Hub (e.g., `postgres:16-alpine`, `redis:7-alpine`, `ng

Upstream pre-built images from other registries (e.g., `ghcr.io/org/app:stable`) are also fine — see "Pre-built Upstream Images" below.

## Authentication (single token, HTTPS-only)

**You only need ONE credential: a N0 Personal Access Token (PAT).** Everything —
n0 API calls, workspace lookup, Gitea repo creation, git push, Supabase — is done
over **HTTPS** with this single token. This is the path that works inside the
Claude Desktop / Claude Code sandbox (outbound HTTPS only, no SSH, no interactive
web login, no server SSH).
Comment on lines +73 to +77

The user provides the token via two environment variables (generated by the
"Build an app with Claude" button in N0 → Developer Settings → API Tokens):

```bash
export N0_API_BASE="https://app.nzero.pro/api/v1" # or the workspace's platform host
export N0_API_TOKEN="clovr_pat_..." # scopes: apps, gitea:write, workspaces:read, supabase
```

Use it as a Bearer token on every n0 API call:

```bash
curl -s -H "Authorization: Bearer $N0_API_TOKEN" "$N0_API_BASE/workspaces/"
```

**Getting the Gitea token (HTTPS, no web UI, no SSH):**
Mint a short-lived Gitea access token from the n0 API using the PAT. This is the
ONLY approved way to get Gitea credentials in the sandbox:

```bash
# Requires the PAT to include the gitea:write scope
GITEA=$(curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/gitea/token/" \
-H "Authorization: Bearer $N0_API_TOKEN")
GITEA_TOKEN=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])")
GITEA_USER=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['username'])")
GITEA_URL=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['gitea_url'])")
GITEA_HOST=$(echo "$GITEA_URL" | sed -e 's#^https\?://##' -e 's#/$##')
```

The response is `{"success": true, "data": {"token": "...", "username": "...", "gitea_url": "https://gitea-<slug>.apps.<domain>"}}`.
The Gitea token is scoped to `write:repository,write:package,write:organization`
and returned once (not stored) — use it for repo creation and git push, then discard.

**RULES (do not violate — SSH and interactive logins break in the sandbox):**
- ✅ Do all git operations over **HTTPS** with the Gitea token embedded in the remote URL:
`https://<username>:<gitea-token>@<gitea-host>/<org>/<repo>.git`
- ❌ **NEVER** use an SSH git remote (`git@...`, `ssh://...`) — no SSH keys exist in the sandbox.
- ❌ **NEVER** use email/password `/auth/login` — use the PAT Bearer token instead.
- ❌ **NEVER** create Gitea tokens via the web UI or via `gitea admin ... generate-access-token` over SSH — use the `gitea/token/` endpoint above.

## Workflow

Always follow this order:
Expand Down Expand Up @@ -580,6 +627,10 @@ jobs:
docker push ${{ env.IMAGE }}:latest
docker push ${{ env.IMAGE }}:${{ github.sha }}

# Only include this step if the runner has the k3s binary (host-mode runners).
# Containerized runners fail here with exit 127 "k3s: command not found" —
# in that case drop this step and pin the manifest image to the commit-SHA
# tag instead (see "Redeploy" below).
- name: Import into k3s
run: |
docker save ${{ env.IMAGE }}:latest | k3s ctr images import --all-platforms -
Expand All @@ -596,7 +647,7 @@ jobs:
- Both workflows use `docker login ${{ vars.REGISTRY }}` with org-level secrets `REGISTRY_USER` and `REGISTRY_PASSWORD` to authenticate with this workspace's Gitea container registry. The `REGISTRY` **variable** plus these two **secrets** are auto-provisioned at the org level for every workspace, so any new repo inherits them automatically — never hardcode the registry hostname
- Workflow B uses `actions/checkout@v4` for cloning — do NOT use manual `git clone` with hardcoded runner-internal URLs
- Two tags are pushed: `latest` (for the manifest) and either the commit SHA (for rollback) or the upstream tag (for version tracking)
- **Workflow B must include `docker save | k3s ctr images import`** — this imports the image directly into k3s containerd, ensuring it's available for pod scheduling even before skopeo mirroring runs
- **Workflow B should include `docker save | k3s ctr images import` when the runner supports it** — this imports the image directly into k3s containerd, ensuring it's available for pod scheduling even before skopeo mirroring runs. **Not all workspaces have host-mode runners**: on containerized runners the step fails with exit 127 (`k3s: command not found`). Check the runner first, or just rely on commit-SHA image pinning in the manifest (see the Redeploy section) which works everywhere
- **Always detect the repo's default branch** — do not hardcode `main`. Common alternatives: `master`, `canary`, `develop`
- The available runner labels are: `self-hosted` (host mode, has Docker), `ubuntu-latest` (containerized via `node:20-bookworm`), `ubuntu-22.04` (containerized). Use `self-hosted` for any workflow that needs Docker

Expand Down Expand Up @@ -1505,7 +1556,7 @@ Before finalizing, verify:
- [ ] **Upstream images use the full registry path** (e.g., `ghcr.io/org/app:stable`) — NOT just the image name
- [ ] `.gitignore` excludes `node_modules/`, `.env`, `dist/`, etc.
- [ ] `.gitea/workflows/build-and-push.yml` exists (build from source OR mirror upstream)
- [ ] **Workflow includes `docker save | k3s ctr images import` step** (ensures image is available in k3s containerd)
- [ ] **Workflow includes `docker save | k3s ctr images import` step if the runner has k3s** (containerized runners don't — omit the step and pin the manifest image to the commit-SHA tag instead)
- [ ] **Workflow uses a hardcoded lowercase `IMAGE` env var** — NEVER use `${{ github.repository }}` in Docker tags (it preserves uppercase and Docker rejects it)
- [ ] **Workflow branch trigger matches the repo's actual default branch** (not hardcoded `main`)
- [ ] Database services have `volumes` for data persistence
Expand Down Expand Up @@ -1587,7 +1638,7 @@ After generating the manifest and pushing code to Gitea, the app must be **impor

### Prerequisites

1. **Workspace member JWT token or Personal Access Token** — any workspace member can import and deploy apps
1. **N0 Personal Access Token (PAT)** — supplied via `$N0_API_TOKEN` (see "Authentication" above). Any workspace member's PAT can import and deploy apps.
2. **Code + n0-app.json pushed to Gitea** — the repo must exist in the workspace's Gitea instance
3. **Gitea Actions build completed** — the Docker image must be in the registry before deploy (not needed for Path C zero-build apps)

Expand All @@ -1597,19 +1648,14 @@ After generating the manifest and pushing code to Gitea, the app must be **impor

### Step 1: Import App Definition

Register the app in the workspace catalog by pointing at the Gitea repo:
Register the app in the workspace catalog by pointing at the Gitea repo. Use the
PAT as a Bearer token (no email/password login):

```bash
# Login to get JWT token
TOKEN=$(curl -s -X POST https://app.privateprompt.tech/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}' | \
python3 -c "import json,sys; print(json.load(sys.stdin)['data']['tokens']['access_token'])")

# Import app definition from Gitea repo
# NOTE: trailing slash is required on this endpoint
curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/definitions/" \
-H "Authorization: Bearer $TOKEN" \
curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/definitions/" \
-H "Authorization: Bearer $N0_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"repo_url": "clovrlabs/my-app"}'
```
Expand All @@ -1618,11 +1664,18 @@ This fetches `n0-app.json` from the repo, validates it, and creates an `AppDefin
- Short form: `org/repo` (uses workspace's own Gitea)
- Full URL: `https://gitea-clovrlabs.apps.privateprompt.tech/org/repo`

**Prefer the full URL.** On some deployments (observed on prod `app.nzero.pro`) the
short form fails with a misleading `No n0-app.json found in org/repo` error even when
the manifest is present on the default branch; the full-URL form works immediately.

Re-run this same call after changing `n0-app.json` (e.g. bumping a pinned image tag) —
it updates the existing AppDefinition in place (`"created": false` in the response).

### Step 2: Deploy App Instance

```bash
curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/" \
-H "Authorization: Bearer $TOKEN" \
curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/" \
-H "Authorization: Bearer $N0_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"app_type": "my-app-slug", "subdomain": "my-app"}'
```
Expand All @@ -1645,15 +1698,32 @@ For zero-build (config_files) apps, **re-import the definition first** so the pl

```bash
# 1. Re-import (refreshes AppDefinition.manifest + source commit)
curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/definitions/" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/definitions/" \
-H "Authorization: Bearer $N0_API_TOKEN" -H "Content-Type: application/json" \
-d '{"repo_url": "clovrlabs/my-app"}'

# 2. Redeploy the running instance
curl -s -X POST "https://app.privateprompt.tech/api/v1/workspaces/${WS_ID}/apps/${APP_ID}/redeploy" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -s -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/redeploy" \
-H "Authorization: Bearer $N0_API_TOKEN" -H "Content-Type: application/json" -d '{}'
```

**WARNING — redeploy does NOT pick up a new `:latest` build by itself.** k3s containerd
caches the image by tag: if the pod's image reference is unchanged (`...:latest`), the
node reuses the cached image and the app keeps serving the old build. A `{"tag": "..."}`
body on the redeploy call is accepted but ignored. Reliable update flow when the CI
workflow cannot import into k3s directly:

1. Push code → CI builds and pushes both `:latest` and `:{commit-sha}` tags
2. Pin the image in `n0-app.json` to the full commit-SHA tag
(`localhost:5000/org/repo:<sha>`) and push
3. Re-import the definition (`POST .../apps/definitions/` with the full repo URL)
4. Redeploy — the changed image reference forces a fresh pull

Verify what's actually deployed by temporarily setting the app public
(`PUT .../apps/{APP_ID}/access` with `{"access_level": "public"}`), curling a content
marker from the site, then reverting to `workspace`. Note: access-level changes only
take effect at the Caddy layer after a redeploy.

### Deploy Versioning & Rollback

**Every deploy gets a unique version tag** — never rely on `latest` as a version identifier:
Expand All @@ -1669,16 +1739,16 @@ Practical rule for zero-build apps: since the "version" lives in the manifest (C

```bash
# Stop app
curl -X POST ".../apps/${APP_ID}/stop" -H "Authorization: Bearer $TOKEN"
curl -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/stop" -H "Authorization: Bearer $N0_API_TOKEN"

# Start app
curl -X POST ".../apps/${APP_ID}/start" -H "Authorization: Bearer $TOKEN"
curl -X POST "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/start" -H "Authorization: Bearer $N0_API_TOKEN"

# Delete/uninstall app
curl -X DELETE ".../apps/${APP_ID}" -H "Authorization: Bearer $TOKEN"
curl -X DELETE "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}" -H "Authorization: Bearer $N0_API_TOKEN"

# Pre-deploy validation
curl ".../apps/${APP_ID}/validate" -H "Authorization: Bearer $TOKEN"
curl "$N0_API_BASE/workspaces/${WS_ID}/apps/${APP_ID}/validate" -H "Authorization: Bearer $N0_API_TOKEN"
```

### Image Mirroring (How K8s Pulls Images)
Expand All @@ -1704,9 +1774,17 @@ Both the loopback address (`127.0.0.1:{port}`) and external hostname (`gitea-{sl
| `Unknown app type` | The `app_type` doesn't match any built-in or AppDefinition slug | Check the slug in n0-app.json |
| Image pull failure | Image not in registry or wrong tag | Check Gitea Actions build succeeded; verify `REGISTRY_USER`/`REGISTRY_PASSWORD` org secrets |
| Build push failure | Stale or missing registry credentials | Re-provision org-level Actions secrets (see below) |
| `No n0-app.json found` on import despite manifest in repo root | Short-form `org/repo` repo_url not resolved on this deployment | Use the full Gitea URL as `repo_url` |
| App serves old version after redeploy | k3s containerd cached the unchanged `:latest` tag | Pin the manifest image to the commit-SHA tag, re-import definition, redeploy |
| Actions run stuck "in_progress" then marked "failure", but image was pushed | Runner's final status report to Gitea timed out — bookkeeping only | Trust the job log (`/actions/runs/{run}/jobs` → `/actions/jobs/{id}/logs`): if it ends with "Job succeeded" and shows push digests, the build is fine |

### Troubleshooting Registry Credentials

> **Admin-only fallback — not part of the normal flow.** This requires server SSH
> and is NOT available in the Claude Desktop / Claude Code sandbox. Skip it unless
> you are a platform operator debugging stale org secrets. Normal app deploys never
> need this; use the `gitea/token/` endpoint (see "Authentication") for credentials.

If Gitea Actions builds succeed but `docker push` fails, the org-level Actions secrets may be stale:

```bash
Expand All @@ -1732,9 +1810,16 @@ Here's the full workflow for developing an app locally and deploying it to N0:

### 1. Create a Gitea Repo

First mint a Gitea token from the n0 API using your PAT (see "Authentication"
above — HTTPS-only, sandbox-safe). `GITEA_TOKEN`, `GITEA_USER`, and `GITEA_HOST`
come from `POST $N0_API_BASE/workspaces/$WS_ID/gitea/token/`:

```bash
GITEA_TOKEN="<your-gitea-personal-access-token>"
GITEA_HOST="gitea-clovrlabs.apps.privateprompt.tech"
GITEA=$(curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/gitea/token/" \
-H "Authorization: Bearer $N0_API_TOKEN")
GITEA_TOKEN=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])")
GITEA_USER=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['username'])")
GITEA_HOST=$(echo "$GITEA" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['gitea_url'])" | sed -e 's#^https\?://##' -e 's#/$##')

curl -s -X POST "https://${GITEA_HOST}/api/v1/orgs/clovrlabs/repos" \
-H "Authorization: token $GITEA_TOKEN" \
Expand Down Expand Up @@ -1771,8 +1856,9 @@ Use this value in `n0-app.json` image fields. In the workflow YAML, always use `
### 4. Push to Gitea

```bash
# HTTPS remote with the Gitea token embedded — NEVER use an SSH remote (git@...)
git init && git add -A && git commit -m "Initial commit"
git remote add origin "https://<username>:${GITEA_TOKEN}@${GITEA_HOST}/clovrlabs/my-app.git"
git remote add origin "https://${GITEA_USER}:${GITEA_TOKEN}@${GITEA_HOST}/clovrlabs/my-app.git"
git push -u origin main --force
Comment on lines 1860 to 1862
```

Expand All @@ -1788,19 +1874,18 @@ curl -s -H "Authorization: token $GITEA_TOKEN" \
### 6. Import + Deploy via API

```bash
N0_TOKEN="<jwt-token>"
WS_ID="<workspace-uuid>"
API="https://app.privateprompt.tech/api/v1"
# N0_API_BASE and N0_API_TOKEN come from the environment (see "Authentication")
# WS_ID is the workspace UUID (look it up via GET $N0_API_BASE/workspaces/)

# Import app definition (trailing slash required)
curl -s -X POST "$API/workspaces/$WS_ID/apps/definitions/" \
-H "Authorization: Bearer $N0_TOKEN" \
curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/apps/definitions/" \
-H "Authorization: Bearer $N0_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"repo_url": "clovrlabs/my-app"}'

# Deploy (trailing slash required)
curl -s -X POST "$API/workspaces/$WS_ID/apps/" \
-H "Authorization: Bearer $N0_TOKEN" \
curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/apps/" \
-H "Authorization: Bearer $N0_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"app_type": "my-app", "subdomain": "my-app"}'
```
Expand All @@ -1809,57 +1894,51 @@ curl -s -X POST "$API/workspaces/$WS_ID/apps/" \

```bash
# Check app status
curl -s "$API/workspaces/$WS_ID/apps/" \
-H "Authorization: Bearer $N0_TOKEN" | \
curl -s "$N0_API_BASE/workspaces/$WS_ID/apps/" \
-H "Authorization: Bearer $N0_API_TOKEN" | \
python3 -c "import json,sys; apps=json.load(sys.stdin)['data']; [print(f'{a[\"subdomain\"]}: {a[\"status\"]}') for a in apps if a['subdomain']=='my-app']"

# Visit the app
# Visit the app (host matches your platform domain, e.g. apps.nzero.pro)
open "https://my-app.apps.privateprompt.tech"
Comment on lines +1901 to 1902
```

### Getting Credentials for Local Development

**N0 API JWT token** (for deploying):
```bash
# NOTE: no trailing slash on /auth/login
curl -s -X POST "$API/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}' | \
python3 -c "import json,sys; print(json.load(sys.stdin)['data']['tokens']['access_token'])"
```
All credentials come from the single N0 PAT — no email/password, no web UI, no SSH.
See "Authentication (single token, HTTPS-only)" near the top.

**N0 API token**: use `$N0_API_TOKEN` (a `clovr_pat_...` PAT) as `Authorization: Bearer $N0_API_TOKEN` on every call. Do NOT use `/auth/login`.

**Workspace ID** (needed for deploy API calls):
```bash
curl -s "$API/workspaces/" \
-H "Authorization: Bearer $N0_TOKEN" | \
curl -s "$N0_API_BASE/workspaces/" \
-H "Authorization: Bearer $N0_API_TOKEN" | \
python3 -c "import json,sys; ws=json.load(sys.stdin)['data']; [print(f'{w[\"slug\"]}: {w[\"id\"]}') for w in ws]"
```

**Gitea personal access token** (for git push):
- Visit `https://gitea-{workspace-slug}.apps.{platform-domain}/-/user/settings/applications`
- Click "Generate New Token" → select `write:repository, write:package` scopes → copy token
- Or via Gitea API (if you already have a token with `write:user` scope):
```bash
curl -s -X POST "https://${GITEA_HOST}/api/v1/users/{username}/tokens" \
-H "Authorization: token $EXISTING_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "deploy-token", "scopes": ["write:repository", "write:package"]}'
```
**Gitea token** (for repo creation + git push): mint it from the n0 API with the
PAT (requires `gitea:write` scope) — HTTPS-only, sandbox-safe:
```bash
curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/gitea/token/" \
-H "Authorization: Bearer $N0_API_TOKEN" | \
python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(f'token={d[\"token\"]}\nuser={d[\"username\"]}\nurl={d[\"gitea_url\"]}')"
```
Do NOT create Gitea tokens via the web UI or via `gitea admin ... generate-access-token` (SSH). Use an HTTPS git remote, never SSH.

**Supabase credentials** (for apps using the workspace Supabase):
- **Via API** (recommended):
```bash
curl -s "$API/workspaces/$WS_ID/supabase/credentials/" \
-H "Authorization: Bearer $N0_TOKEN" | \
curl -s "$N0_API_BASE/workspaces/$WS_ID/supabase/credentials/" \
-H "Authorization: Bearer $N0_API_TOKEN" | \
python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(f'URL: {d[\"url\"]}\nAnon key: {d[\"anon_key\"]}')"
```
- **URL pattern**: `https://supabase-api-{workspace-slug}.apps.{platform-domain}`
- These are set as `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` in your local `.env` file (gitignored)

**Running Supabase migrations** (for creating tables, RLS policies, etc.):
```bash
curl -s -X POST "$API/workspaces/$WS_ID/supabase/sql/" \
-H "Authorization: Bearer $N0_TOKEN" \
curl -s -X POST "$N0_API_BASE/workspaces/$WS_ID/supabase/sql/" \
-H "Authorization: Bearer $N0_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$(python3 -c "import json; print(json.dumps({'sql': open('migration.sql').read()}))")"
```
Expand Down