diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..2a8125d --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "virtuals", + "owner": { + "name": "Virtuals Protocol" + }, + "metadata": { + "description": "Virtuals' Claude Code plugins" + }, + "plugins": [ + { + "name": "virtuals-platform", + "source": "./plugin", + "description": "How to build internal apps that host on the Virtuals platform (Google Cloud Run, through projects-autodeploy): rules, templates and a checker" + } + ] +} diff --git a/.github/workflows/plugin-in-sync.yml b/.github/workflows/plugin-in-sync.yml new file mode 100644 index 0000000..564895f --- /dev/null +++ b/.github/workflows/plugin-in-sync.yml @@ -0,0 +1,32 @@ +# The Claude plugin (plugin/) carries copies of the checker and the app +# templates, because a plugin can't reach files outside its own folder. This +# fails a change that updates one copy and not the other. +name: Plugin in sync + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: The plugin's checker and templates match the platform's + run: | + status=0 + check() { + if ! diff -q "$1" "$2" >/dev/null; then + echo "::error file=$2::$2 differs from $1. Copy $1 over it." + status=1 + fi + } + check actions/check/check.mjs plugin/skills/internal-app/scripts/check.mjs + for f in platform.yaml Dockerfile dockerignore deploy.yml; do + check "templates/app/$f" "plugin/skills/internal-app/templates/$f" + done + exit $status diff --git a/README.md b/README.md index 337fa48..189845e 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ Hosts Virtuals' internal apps on Google Cloud Run, so anyone who builds a tool ## How an app goes live -1. **Build it to the rules below.** Claude's `virtuals-platform` plugin does this, - and adds the files from [`templates/app/`](templates/app). +1. **Build it to the rules below.** Claude's `virtuals-platform` plugin + ([`plugin/`](plugin), see "The Claude plugin") does this, and adds the files from + [`templates/app/`](templates/app). The bizops portal's **Tech requests → Build an + app** page walks a builder through it. 2. **Host it.** Use the **Host an app** form in the bizops portal. It runs [`host-app.yml`](.github/workflows/host-app.yml), which checks the request and creates the app's Cloud Run service, its own service account and, if asked @@ -62,6 +64,41 @@ Not available yet, so ask the platform team: Postgres, Redis, scheduled jobs, custom domains, anything bigger than `medium`. Public (no sign-in) apps need the platform team to add them to [`public-apps.txt`](public-apps.txt). +## The Claude plugin + +Builders work with Claude Code and the `virtuals-platform` plugin in [`plugin/`](plugin). +It teaches Claude the rules above, carries the templates and the checker, and loads by +itself when someone builds or hosts an app. Its guide is +[`plugin/skills/internal-app/SKILL.md`](plugin/skills/internal-app/SKILL.md). + +- **Builders need read access to this repo** to install it (the organization's default + repository permission is none). +- **For everyone at once:** an Owner of the claude.ai organization adds this under + Organization settings → Claude Code → Managed settings: + + ```json + { + "extraKnownMarketplaces": { + "virtuals": { + "source": { "source": "github", "repo": "Virtual-Protocol/projects-autodeploy" }, + "autoUpdate": true + } + }, + "enabledPlugins": { "virtuals-platform@virtuals": true } + } + ``` + +- **One person:** `claude plugin marketplace add Virtual-Protocol/projects-autodeploy`, then + `claude plugin install virtuals-platform@virtuals`. +- **Per app repo:** the plugin commits `.claude/settings.json`, so anyone who opens the repo + in Claude Code is offered the plugin. + +To change the rules, edit `SKILL.md` and bump `version` in +`plugin/.claude-plugin/plugin.json`. The checker and templates live in +[`actions/check/`](actions/check) and [`templates/app/`](templates/app); the plugin keeps +copies, and the [Plugin in sync](.github/workflows/plugin-in-sync.yml) check fails a change +that updates only one. + ## Security - **No keys anywhere.** GitHub Actions gets short-lived Google credentials through diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..9b9fc78 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "virtuals-platform", + "description": "How to build internal apps that host on the Virtuals platform (Google Cloud Run, through projects-autodeploy): rules, templates and a checker", + "version": "1.0.0", + "author": { "name": "Virtuals Protocol" }, + "repository": "https://github.com/Virtual-Protocol/projects-autodeploy" +} diff --git a/plugin/skills/internal-app/SKILL.md b/plugin/skills/internal-app/SKILL.md new file mode 100644 index 0000000..a806ac7 --- /dev/null +++ b/plugin/skills/internal-app/SKILL.md @@ -0,0 +1,197 @@ +--- +name: internal-app +description: The rules and setup for building Virtuals internal web apps (tools, dashboards, portals, admin panels) so they host on the company platform (Google Cloud Run, through projects-autodeploy). Use when starting a new internal app, when the user wants to host, deploy, publish, launch, "put it online" or share an app with the team, when moving an app off Railway or Vercel, and whenever work in such an app touches settings or secrets, the database, file uploads, sign-in, background work, the Dockerfile or GitHub Actions. +allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/check.mjs *) +--- + +# Virtuals internal apps + +Every internal app at Virtuals is hosted the same way: one container on Google Cloud Run, +built and deployed by `Virtual-Protocol/projects-autodeploy` every time its repo's `main` +branch changes. Your job is to keep the app in that shape, so it hosts without anyone +touching Google Cloud. + +The person you're working with is often **not an engineer**. Explain things in plain +language, never ask them to change cloud settings, and always finish with the short list of +things only they can do (see "Hand-off"). + +## How an app goes live + +1. **The code** is in a **private** repo in the `Virtual-Protocol` GitHub organization. +2. **You add the platform files** (templates in `${CLAUDE_SKILL_DIR}/templates/`) and keep + the app to the rules below. `platform.yaml` names the person who will host it as `owner`. +3. **The owner hosts it** with **Tech requests → Host an app** in the bizops portal: + https://bizops-912665393140.asia-southeast1.run.app/apps. It checks the repo, creates the + app's address, and lists the secrets to add. +4. **The owner adds the secrets** in the repo on GitHub (Settings → Secrets and variables → + Actions), named as in `.env.example`. +5. **Deploy:** merge to `main`, or run the repo's Deploy workflow (Actions tab). It takes about + 3–5 minutes. After that, every merge to `main` deploys by itself. + +## The rules + +### Shape +- **Node.js, one container.** Builds with `npm ci && npm run build`, starts with `npm start`, + from a clean checkout. Commit `package-lock.json`. +- **Listen on `process.env.PORT`.** `next start` already does. +- **Health check:** `healthcheck` in `platform.yaml` (default `/api/health`) answers 200 fast, + **without login and without calling the database**. If it fails at startup, the deploy + fails. If auth middleware exists, let this path through. +- **Requests finish within 60 seconds.** Nothing slower in a web request. + +### Required files +| File | Template | Purpose | +|---|---|---| +| `platform.yaml` | `platform.yaml` | What the app is and needs. Read every comment in it | +| `Dockerfile` | `Dockerfile` | How to build the container. Adjust only if the build needs it | +| `.dockerignore` | `dockerignore` | Keeps secrets and junk out of the image | +| `.github/workflows/deploy.yml` | `deploy.yml` | Deploys on merge to `main`. Copy it unchanged | +| `.env.example` | (write it) | Every env var the app reads, with a one-line comment and a **fake** value | +| `CLAUDE.md` (section) | `CLAUDE.md` | Points future sessions in the repo at this skill | +| `.claude/settings.json` | `claude-settings.json` | Offers this plugin to anyone who opens the repo. Merge if the file exists | + +### What the platform gives the app +| Variable | When | Notes | +|---|---|---| +| `PORT` | always | `port` from `platform.yaml` (default 3000) | +| `NODE_ENV` | always | `production` (set in the Dockerfile) | +| `APP_URL` | always | The app's own address, for links in emails | +| `GOOGLE_CLOUD_PROJECT` | always | For Google client libraries | +| `STORAGE_BUCKET` | `needs.storage: true` | The app's own private Cloud Storage bucket | + +Google access (Cloud Storage and other Google APIs) comes from the app's own service +account automatically: Google's client libraries find it. **Never** add a service account +key, `GOOGLE_APPLICATION_CREDENTIALS`, or AWS keys. + +### Who can open it, and knowing who's signed in +- **By default, only people signed in with a `@virtuals.io` Google account can open the app.** + Google checks this before any request reaches it. **Don't build a login page, passwords or + an admin password.** +- To know who is using it, read the email from the `x-goog-iap-jwt-assertion` header Google + adds to every request, and **verify that JWT** (ES256, keys at + `https://www.gstatic.com/iap/verify/public_key-jwk`, issuer `https://cloud.google.com/iap`, + audience `/projects/912665393140/locations/asia-southeast1/services/`). + `lib/server/iap.ts` in `Virtual-Protocol/internal-bizops-tool` is a working example. + Roles (who is an admin and so on) are the app's own business: store them in its database, + keyed by that email. +- **Public apps** (customers outside Virtuals, like the support portal) are the exception: + `public: true` in `platform.yaml` only works once the platform team approves the app. Then + anyone can open it, so the app needs its own sign-in for anything private. Flag it in the + hand-off. + +### Settings and secrets +- Every env var the code reads goes in `.env.example`. At each deploy, the platform copies + the repo's **GitHub Actions secrets** with those names into the running app. The owner adds + them in the repo: Settings → Secrets and variables → Actions → New repository secret. +- **GitHub is the one place to change them.** A value changed in Google Cloud is overwritten + by the next deploy. After changing a secret, deploy again (Actions → Deploy → Run workflow). +- **Browser-visible values** (`NEXT_PUBLIC_*`, `VITE_*`) are baked in when the app is built. + They go in the repo's GitHub **Variables** (same page, Variables tab), or in a committed + `.env.production` holding only those keys. None of them may be a secret. +- `.env` and `.env.local` stay git-ignored. If one is tracked by git, stop and tell the user. + +### Data +- **The platform doesn't provide a database yet** (`needs.postgres` is refused). The + approved choice is **Postgres on Supabase**: ask the platform team for a project, created + in the **Singapore** region (the app runs in Singapore; anywhere else makes every query + slow). Connect with its **Session pooler** connection string as the `DATABASE_URL` secret, + and turn its **Data API off** if the app talks to Postgres directly. +- No SQLite, no JSON files used as a database, no in-memory stores for anything that matters. +- Migrations run on start and must be safe to run twice. **Never** put `prisma db push`, + `migrate reset`, `--accept-data-loss` or `--force-reset` in a script that runs on deploy. + A migration that drops or renames a column or table needs an engineer's review. +- Local development uses a local or separate dev database. **Never** point local code at the + production database. +- If the app loads lists with related rows (comments, history), fetch them in one query for + the whole list, not one query per row: the database is over the network. + +### Files +- **The container's disk is wiped on every deploy and restart.** `/tmp` is fine for scratch + work within one request; nothing else on disk survives. +- Files go in Cloud Storage: `needs.storage: true` gives a private bucket in `STORAGE_BUCKET`. + Use `@google-cloud/storage`; serve files through the app (checking access), never publicly. +- An app that already saves files to a folder (moving off a Railway volume, or a tool that + writes its own files) can keep doing so: `needs.storage_folder: /app/uploads` makes the + bucket appear as that folder. If two copies of the app would clash over those files, set + `max_copies: 1`. + +### Background work +- **Cloud Run only gives the app processing power while it's handling a request.** Work + started after the response (emails sent "in the background", `setImmediate`, unawaited + promises) stalls and times out. **Finish the work, then respond.** +- No `setInterval` or `node-cron` for scheduled work: the app sleeps when nobody uses it. A + job that must run on a schedule (daily reminders, syncs) needs the platform team. + +### Size +`size: small` (1 vCPU, 1 GB, up to 3 copies) suits nearly every internal tool. `medium` is +2 vCPU / 2 GB, up to 5. The app sleeps when unused and wakes on the next request, which can +take a few seconds. + +### Outside services +- **Fine:** calling APIs with a key (Google, OpenAI, Anthropic, Slack, Redash…). The key is + a secret. +- **Ask first:** anything new that would *hold Virtuals data* (another database, file host or + auth provider) or *host the app* (Railway, Vercel, Render, Netlify…). + +### Never +- Never create, change or delete anything in Google Cloud, run `gcloud` against the company + project, or ask for Google or AWS keys. The platform does all of it. +- Never edit `.github/workflows/deploy.yml` beyond the template. +- Never change `name` in `platform.yaml` once hosted. It names the app's service and secrets. +- Never host the app somewhere else "for now". +- Never commit `.env` files, keys or tokens. + +## How to work + +### Starting a new app +1. Ask what it's for and who uses it. Pick a `name`: 3–24 lowercase letters, digits and + single dashes, starting with a letter. +2. Default stack, unless the user already has one: **Next.js (App Router) + TypeScript**, + plus Postgres on Supabase if it needs a database. +3. Copy the templates in. Fill in `platform.yaml` (`owner` is the user's `@virtuals.io` + email), add the health route, write `.env.example`, and add the `CLAUDE.md` section and + `.claude/settings.json`. +4. Build the features within the rules above. + +### Getting an existing app ready (including moving off Railway or Vercel) +1. Run the checker (below) and read every error and warning. +2. Common fixes: + - No `Dockerfile`: copy the template. Railway's Nixpacks builds don't exist here. + - Its own login: on an internal app, Google sign-in replaces it. Read the signed-in email + instead (see "Who can open it"). + - Uploads on disk: Cloud Storage, or `needs.storage_folder` to keep the folder. + - Emails or other work after responding: do it before responding. + - Port hardcoded: read `process.env.PORT`. + - Health check missing, or it hits the database: add a plain one. +3. Leave `railway.toml`, `nixpacks.toml` and `vercel.json` until the app is live here, then + delete them. +4. Moving data (a database, uploaded files) needs a plan with the owner and usually the + platform team. Don't copy production data on your own. + +### Checking your work +Run the checker on the repo root. Every deploy runs the same one: + +```bash +node ${CLAUDE_SKILL_DIR}/scripts/check.mjs . +``` + +Fix every ✗. Fix each ! too, or tell the user why it doesn't apply. Then build and start the +app the way the Dockerfile does, and confirm the health check answers. + +## Hand-off + +Always end with a short **"What you need to do"** list in plain language, with only what +applies: + +1. **Merge** the changes into `main` on GitHub. +2. **First time only, host it:** in the bizops portal, open **Tech requests → Host an app** + (https://bizops-912665393140.asia-southeast1.run.app/apps) and enter this repo. It takes + 2–3 minutes and gives the app's address. +3. **Secrets:** "In the repo on GitHub: Settings → Secrets and variables → Actions → New + repository secret, add: `FOO` (what it is and where to get it), …". Never show real values. + The app's address lists the same names until its first deploy. +4. **Deploy:** "Merge to `main`, or open Actions → Deploy → Run workflow. It's live about + 5 minutes later." After the first time, every merge deploys by itself. +5. **Needs the platform team:** a database, a public app, a schedule, a custom domain, or a + migration that drops or renames data. +6. **Problems found:** a tracked `.env` file, secrets in code, or anything you couldn't fix. diff --git a/plugin/skills/internal-app/scripts/check.mjs b/plugin/skills/internal-app/scripts/check.mjs new file mode 100755 index 0000000..ad6ae2d --- /dev/null +++ b/plugin/skills/internal-app/scripts/check.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +// Checks a repo against the projects-autodeploy rules (README.md, "The rules"). +// Usage: node check.mjs [repo-dir] Exit code 1 when there are errors. +// No dependencies; Node 18+. Claude's virtuals-platform plugin runs a copy of +// this file; keep the two in step. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(process.argv[2] || "."); +const inActions = process.env.GITHUB_ACTIONS === "true"; +const errors = []; +const warnings = []; +const passes = []; +const err = (msg) => errors.push(msg); +const warn = (msg) => warnings.push(msg); +const ok = (msg) => passes.push(msg); + +const exists = (p) => fs.existsSync(path.join(root, p)); +const read = (p) => (exists(p) ? fs.readFileSync(path.join(root, p), "utf8") : null); +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// ---------- files in the repo ---------- + +const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "coverage", ".data", ".turbo", ".vercel"]); +const CODE_EXT = /\.(c|m)?(j|t)sx?$/; + +function git(args) { + try { + return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +function listFiles() { + const out = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"]); + if (out !== null) return out.split("\0").filter(Boolean); + const files = []; + const walk = (dir) => { + for (const e of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) { + if (SKIP_DIRS.has(e.name)) continue; + const rel = dir ? `${dir}/${e.name}` : e.name; + if (e.isDirectory()) walk(rel); + else files.push(rel); + } + }; + walk(""); + return files; +} + +const allFiles = listFiles(); +const trackedOut = git(["ls-files", "-z"]); +const tracked = trackedOut === null ? null : trackedOut.split("\0").filter(Boolean); + +const isAppCode = (f) => + CODE_EXT.test(f) && + !f.split("/").some((part) => SKIP_DIRS.has(part)) && + !/(^|\/)(scripts|tests?|__tests__|e2e|prisma\/seed)(\/|\.)|\.(test|spec)\.|(^|\/)test-[^/]*$|\.config\.|\.d\.ts$/.test(f); +const codeFiles = allFiles.filter(isAppCode); +const code = new Map(); +for (const f of codeFiles) { + try { + const full = path.join(root, f); + if (fs.statSync(full).size < 1_000_000) code.set(f, fs.readFileSync(full, "utf8")); + } catch {} +} + +function findInCode(re, { limit = 5 } = {}) { + const hits = []; + for (const [f, text] of code) { + const lines = text.split("\n"); + for (let i = 0; i < lines.length && hits.length < limit; i++) { + if (re.test(lines[i])) hits.push(`${f}:${i + 1}`); + } + if (hits.length >= limit) break; + } + return hits; +} + +// ---------- platform.yaml ---------- + +function parseYaml(text) { + const out = {}; + let section = null; + for (const raw of text.split("\n")) { + const line = raw.replace(/(^|\s)#.*$/, "").trimEnd(); + if (!line.trim()) continue; + const m = line.match(/^(\s*)([A-Za-z_][\w-]*):\s*(.*)$/); + if (!m) throw new Error(`can't read this line: "${raw.trim()}"`); + const [, indent, key, rawVal] = m; + let val = rawVal.trim().replace(/^(['"])(.*)\1$/, "$2"); + if (val === "true") val = true; + else if (val === "false") val = false; + else if (/^\d+$/.test(val)) val = Number(val); + if (!indent) { + if (val === "") (out[key] = {}), (section = key); + else (out[key] = val), (section = null); + } else { + if (!section) throw new Error(`unexpected indented line: "${raw.trim()}"`); + out[section][key] = val; + } + } + return out; +} + +let cfg = null; +const yamlText = read("platform.yaml"); +if (!yamlText) { + err("platform.yaml is missing. Copy templates/app/platform.yaml from projects-autodeploy and fill it in."); +} else { + try { + cfg = parseYaml(yamlText); + } catch (e) { + err(`platform.yaml: ${e.message}`); + } +} + +if (cfg) { + const known = new Set(["name", "owner", "port", "healthcheck", "size", "max_copies", "public", "needs"]); + for (const k of Object.keys(cfg)) { + if (k === "audience") warn("platform.yaml: audience isn't used. Apps are behind Google sign-in unless public: true, which needs the platform team's approval"); + else if (!known.has(k)) warn(`platform.yaml: unknown key "${k}" (ignored)`); + } + + const name = String(cfg.name ?? ""); + if (!/^[a-z][a-z0-9-]{1,22}[a-z0-9]$/.test(name) || name.includes("--")) + err(`platform.yaml: name "${name}" must be 3-24 lowercase letters, digits and single dashes, starting with a letter`); + else if (name === "my-app") err('platform.yaml: name is still the template value "my-app"'); + + const owner = String(cfg.owner ?? ""); + if (!/^[^@\s]+@virtuals\.io$/.test(owner)) err(`platform.yaml: owner "${owner}" must be a @virtuals.io email`); + else if (owner === "someone@virtuals.io") err("platform.yaml: owner is still the template value"); + + cfg.port ??= 3000; + if (!Number.isInteger(cfg.port) || cfg.port < 1024 || cfg.port > 65535) err("platform.yaml: port must be a number between 1024 and 65535"); + + cfg.healthcheck ??= "/api/health"; + if (typeof cfg.healthcheck !== "string" || !cfg.healthcheck.startsWith("/")) err("platform.yaml: healthcheck must be a path starting with /"); + + cfg.size ??= "small"; + if (!["small", "medium"].includes(cfg.size)) err(`platform.yaml: size must be small or medium, not "${cfg.size}"`); + + if (cfg.max_copies !== undefined) { + const most = cfg.size === "medium" ? 5 : 3; + if (!Number.isInteger(cfg.max_copies) || cfg.max_copies < 1 || cfg.max_copies > most) + err(`platform.yaml: max_copies must be a number from 1 to ${most} for size ${cfg.size}`); + } + + cfg.public ??= false; + if (typeof cfg.public !== "boolean") err("platform.yaml: public must be true or false"); + else if (cfg.public) warn("platform.yaml: public: true opens the app to anyone, with no sign-in. Hosting refuses it unless the platform team has approved the app in public-apps.txt"); + + cfg.needs ??= {}; + if (typeof cfg.needs !== "object") err("platform.yaml: needs must be a section (storage: true/false)"); + else + for (const [k, v] of Object.entries(cfg.needs)) { + if (k === "storage_folder") { + if (typeof v !== "string" || !/^\/[A-Za-z0-9._/-]+$/.test(v) || v.includes("..")) + err("platform.yaml: needs.storage_folder must be a folder path like /app/uploads"); + else if (cfg.needs.storage !== true) err("platform.yaml: needs.storage_folder needs storage: true"); + } else if (typeof v !== "boolean") err(`platform.yaml: needs.${k} must be true or false`); + else if (k === "postgres" || k === "redis") { + if (v) err(`platform.yaml: needs.${k} isn't available yet. Ask the platform team`); + } else if (k !== "storage") err(`platform.yaml: needs.${k} isn't something the platform provides`); + } + if (!errors.some((e) => e.startsWith("platform.yaml"))) ok("platform.yaml is valid"); +} +const needs = cfg?.needs && typeof cfg.needs === "object" ? cfg.needs : {}; +const healthPath = typeof cfg?.healthcheck === "string" ? cfg.healthcheck : "/api/health"; + +// ---------- required files ---------- + +const pkgText = read("package.json"); +let pkg = {}; +if (!pkgText) err("package.json is missing at the repo root"); +else { + try { + pkg = JSON.parse(pkgText); + } catch { + err("package.json isn't valid JSON"); + } +} +const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; +const scripts = pkg.scripts || {}; + +if (pkgText && !scripts.start) err('package.json has no "start" script. The container runs `npm start`'); + +if (exists("yarn.lock") || exists("pnpm-lock.yaml") || exists("bun.lockb") || exists("bun.lock")) + err("Use npm: remove yarn/pnpm/bun lockfiles and commit package-lock.json"); +else if (!exists("package-lock.json")) err("package-lock.json is missing. Run `npm install` and commit it"); +else ok("package-lock.json present"); + +const dockerfile = read("Dockerfile"); +if (!dockerfile) err("Dockerfile is missing. Copy templates/app/Dockerfile"); +else { + ok("Dockerfile present"); + if (/^\s*COPY\s+[^\n]*\.env(\s|$)/m.test(dockerfile)) err("Dockerfile copies a .env file into the image"); + if (/^\s*FROM\s+node:(1[0-9]|20)\b/im.test(dockerfile)) warn("Dockerfile uses an old Node version. The template uses node:24-slim"); +} + +const dockerignore = read(".dockerignore"); +if (!dockerignore) err(".dockerignore is missing. Copy templates/app/dockerignore"); +else if (!/node_modules/.test(dockerignore) || !/\.env/.test(dockerignore)) err(".dockerignore must exclude node_modules and .env files"); +else ok(".dockerignore present"); + +const deployYml = read(".github/workflows/deploy.yml"); +if (!deployYml) err(".github/workflows/deploy.yml is missing. Copy templates/app/deploy.yml"); +else if (!deployYml.includes("Virtual-Protocol/projects-autodeploy/.github/workflows/deploy-app.yml")) + err(".github/workflows/deploy.yml doesn't use the platform's deploy workflow. Copy templates/app/deploy.yml unchanged"); +else ok("deploy workflow present"); + +for (const f of ["railway.toml", "railway.json", "nixpacks.toml", "vercel.json", "Procfile", "render.yaml", "netlify.toml"]) + if (exists(f)) warn(`${f} is for another host. Delete it once the app is live on Cloud Run`); + +// ---------- env files & secrets ---------- + +const SECRETISH = /(SECRET|PRIVATE|SERVICE_ROLE|PASSWORD|PASSWD|TOKEN|CREDENTIAL)/; +const PUBLIC_PREFIX = /^(NEXT_PUBLIC_|VITE_)/; +const ALLOWED_ENV_FILES = new Set([".env.example", ".env.sample", ".env.template", ".env.production"]); +const envKeys = (text) => + (text || "") + .split("\n") + .map((l) => l.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1]) + .filter(Boolean); + +if (tracked) { + const badEnv = tracked.filter((f) => { + const b = path.basename(f); + return (b === ".env" || b.startsWith(".env.") || b.endsWith(".env")) && !ALLOWED_ENV_FILES.has(b); + }); + for (const f of badEnv) { + const full = path.join(root, f); + const empty = fs.existsSync(full) && fs.statSync(full).size === 0; + if (empty) warn(`${f} is committed to git (it's empty, so just delete it)`); + else err(`${f} is committed to git. It may contain secrets: remove it, and tell the platform team so they can be rotated`); + } + const keyFiles = tracked.filter((f) => /\.json$/.test(f) && !/(^|\/)(package|package-lock|tsconfig|jsconfig)\.json$/.test(f)).filter((f) => { + const text = read(f) || ""; + return text.includes('"service_account"') && text.includes('"private_key"'); + }); + for (const f of keyFiles) err(`${f} is a Google service account key. Remove it: the app gets Google access from its own account automatically`); + if (!badEnv.length && !keyFiles.length) ok("no secrets committed"); +} else warn("not a git repo, so committed-secrets checks were skipped"); + +const example = read(".env.example"); +const exampleKeys = envKeys(example); +if (example === null) err(".env.example is missing. List every env var the app reads, with fake values"); +else { + for (const k of exampleKeys) { + if (PUBLIC_PREFIX.test(k) && SECRETISH.test(k)) err(`.env.example: ${k} looks secret but its prefix sends it to every browser`); + else if (PUBLIC_PREFIX.test(k) && /API_KEY/.test(k)) warn(`.env.example: ${k} goes to every browser. Make sure it's a browser-safe key`); + if (k === "GOOGLE_APPLICATION_CREDENTIALS" || /^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY)$/.test(k)) + err(`.env.example: ${k}. Don't use cloud keys; the app gets Google access from its own account automatically`); + } +} + +const prodEnv = read(".env.production"); +if (prodEnv !== null) { + for (const k of envKeys(prodEnv)) { + if (!PUBLIC_PREFIX.test(k)) err(`.env.production: ${k} isn't NEXT_PUBLIC_/VITE_. Only public build-time values belong in this committed file`); + else if (SECRETISH.test(k)) err(`.env.production: ${k} looks secret but its prefix sends it to every browser`); + } +} + +const PLATFORM_VARS = new Set(["PORT", "NODE_ENV", "APP_URL", "STORAGE_BUCKET", "GOOGLE_CLOUD_PROJECT", "HOSTNAME", "NEXT_RUNTIME", "CI", "TZ", "K_SERVICE", "K_REVISION", "K_CONFIGURATION"]); +const readVars = new Set(); +for (const text of code.values()) { + for (const m of text.matchAll(/(?:process\.env|import\.meta\.env)(?:\.([A-Z_][A-Z0-9_]*)|\[\s*['"]([A-Z_][A-Z0-9_]*)['"]\s*\])/g)) readVars.add(m[1] || m[2]); +} +const undocumented = [...readVars].filter((v) => !PLATFORM_VARS.has(v) && !exampleKeys.includes(v) && !v.startsWith("VERCEL") && !v.startsWith("RAILWAY")).sort(); +if (example !== null && undocumented.length) + warn(`code reads env vars missing from .env.example: ${undocumented.slice(0, 12).join(", ")}${undocumented.length > 12 ? ", …" : ""}`); +const hostVars = [...readVars].filter((v) => v.startsWith("VERCEL") || v.startsWith("RAILWAY")); +if (hostVars.length) warn(`code reads another host's env vars: ${hostVars.join(", ")}. Use APP_URL / platform vars instead`); + +// ---------- port & health ---------- + +const usesNextStart = /\bnext start\b/.test(scripts.start || ""); +const hardPort = findInCode(/\.listen\(\s*\d{2,5}\s*[,)]/); +if (hardPort.length) err(`hardcoded port in .listen() (${hardPort.join(", ")}). Use process.env.PORT`); +if (!usesNextStart && !readVars.has("PORT")) err("couldn't find the app reading process.env.PORT. It must listen on PORT"); +else ok(usesNextStart ? "uses `next start` (listens on PORT)" : "reads process.env.PORT"); + +const segs = escapeRe(healthPath.replace(/^\/|\/$/g, "")); +const nextRouteRe = new RegExp(`(^|/)(src/)?app/${segs}/route\\.(t|j)s$`); +const nextPagesRe = new RegExp(`(^|/)(src/)?pages/${segs}(/index)?\\.(t|j)sx?$`); +const healthFile = codeFiles.find((f) => nextRouteRe.test(f) || nextPagesRe.test(f)); +const healthLiteral = findInCode(new RegExp(`['"\`]${escapeRe(healthPath)}['"\`]`), { limit: 1 }); +if (!healthFile && !healthLiteral.length) err(`no route found for the health check ${healthPath}. Add one that returns 200 without login or database calls`); +else { + ok(`health route found (${healthFile || healthLiteral[0]})`); + const body = healthFile ? code.get(healthFile).replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "") : null; + if (body && /prisma|\$queryRaw|\.query\(|supabase|redis|fetch\(/i.test(body)) + warn(`${healthFile} calls the database or another service. Keep the health check shallow, or a database blip restarts the app`); +} + +for (const mw of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) { + const text = read(mw); + if (text && !text.includes(healthPath) && !/health/i.test(text)) + warn(`${mw} doesn't mention ${healthPath}. Make sure it doesn't redirect the health check to a login page`); +} + +// Browser code ("use client") can poll on a timer; only server timers matter. +const timers = findInCode(/\bsetInterval\s*\(/).filter((hit) => !/^\s*["']use client["']/.test(code.get(hit.split(":")[0]))); +if (deps["node-cron"] || deps.cron || timers.length) + warn(`scheduled work found (${deps["node-cron"] ? "node-cron" : deps.cron ? "cron" : timers.join(", ")}). Cloud Run pauses idle apps, so timers don't run reliably; ask the platform team for a schedule`); + +// ---------- data & files ---------- + +if (deps["better-sqlite3"] || deps.sqlite3 || deps.sqlite || deps.lowdb || deps["node-json-db"]) err("SQLite / file database dependency found. Data must live in a real database"); +const prismaSchema = read("prisma/schema.prisma"); +if (prismaSchema && /provider\s*=\s*"sqlite"/.test(prismaSchema)) err("prisma/schema.prisma uses sqlite. Use postgresql"); + +for (const s of ["start", "prestart", "build", "postinstall"]) { + if (/db push|migrate reset|accept-data-loss|force-reset/.test(scripts[s] || "")) err(`package.json "${s}" runs a destructive database command: ${scripts[s]}`); +} + +const storageFolder = needs.storage === true && typeof needs.storage_folder === "string" ? needs.storage_folder : null; +const diskUploads = findInCode(/multer\.diskStorage|multer\(\s*\{\s*dest\s*:|express\.static\(\s*['"`][^'"`]*upload/i); +if (diskUploads.length && storageFolder) + warn(`files are saved to disk (${diskUploads.join(", ")}). Make sure they go under ${storageFolder}, the storage folder: everything else is wiped on every deploy`); +else if (diskUploads.length) err(`files are saved to local disk (${diskUploads.join(", ")}). The disk is wiped on every deploy: use needs.storage (Cloud Storage)`); +const diskWrites = findInCode(/\bfs\.(writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|mkdirSync)\s*\(/).filter((h) => { + const [f, line] = h.split(":"); + return !/tmpdir|['"`]\/tmp/.test(code.get(f).split("\n")[Number(line) - 1]); +}); +if (diskWrites.length) + warn(`writes to local disk (${diskWrites.join(", ")}). Anything that must survive a restart belongs in a database or Cloud Storage${storageFolder ? ` (${storageFolder})` : ""}`); +if (deps["@aws-sdk/client-s3"]) warn("uses S3. On this platform, files go to Cloud Storage: set needs.storage and use @google-cloud/storage"); +if (deps["@google-cloud/storage"] && !needs.storage && cfg) warn("uses Cloud Storage but needs.storage isn't true"); +if (deps.bullmq || deps.ioredis || deps.redis) warn("uses Redis. Redis isn't available on the platform yet: ask the platform team"); + +const outside = ["@supabase/supabase-js", "@supabase/ssr", "firebase", "firebase-admin", "@vercel/kv", "@vercel/postgres", "@vercel/blob", "@neondatabase/serverless", "@upstash/redis", "@clerk/nextjs", "@auth0/nextjs-auth0", "cloudinary"].filter((d) => deps[d]); +if (outside.length) warn(`outside services that hold data need approval: ${outside.join(", ")}`); + +// ---------- report ---------- + +if (inActions) { + for (const e of errors) console.log(`::error::${e}`); + for (const w of warnings) console.log(`::warning::${w}`); +} +console.log(`\nprojects-autodeploy check: ${root}\n`); +for (const e of errors) console.log(` ✗ ${e}`); +for (const w of warnings) console.log(` ! ${w}`); +for (const p of passes) console.log(` ✓ ${p}`); +console.log(`\n${errors.length} error(s), ${warnings.length} warning(s), ${passes.length} passed\n`); +process.exit(errors.length ? 1 : 0); diff --git a/plugin/skills/internal-app/templates/CLAUDE.md b/plugin/skills/internal-app/templates/CLAUDE.md new file mode 100644 index 0000000..27aa45d --- /dev/null +++ b/plugin/skills/internal-app/templates/CLAUDE.md @@ -0,0 +1,8 @@ + +## Hosting + +This app is hosted on the Virtuals internal app platform (Google Cloud Run, through +Virtual-Protocol/projects-autodeploy) and deploys automatically when `main` changes. For +anything that touches hosting, settings and secrets, the database, files, sign-in, background +work, the Dockerfile or GitHub Actions, follow the `virtuals-platform:internal-app` skill, and +run its checker before saying the app is ready. diff --git a/plugin/skills/internal-app/templates/Dockerfile b/plugin/skills/internal-app/templates/Dockerfile new file mode 100644 index 0000000..3c6f22c --- /dev/null +++ b/plugin/skills/internal-app/templates/Dockerfile @@ -0,0 +1,28 @@ +# projects-autodeploy: standard Dockerfile. +# Works for Next.js, Express and Vite + Express apps as long as +# `npm ci`, `npm run build` and `npm start` work from a clean checkout. +FROM node:24-slim + +# openssl: needed by Prisma. tini: passes stop signals to Node so deploys shut down cleanly. +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +RUN chown node:node /app +USER node + +# Dev dependencies stay installed: build tools need them. +COPY --chown=node:node package*.json ./ +RUN npm ci + +COPY --chown=node:node . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build --if-present + +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["npm", "start"] diff --git a/plugin/skills/internal-app/templates/claude-settings.json b/plugin/skills/internal-app/templates/claude-settings.json new file mode 100644 index 0000000..1313f99 --- /dev/null +++ b/plugin/skills/internal-app/templates/claude-settings.json @@ -0,0 +1,10 @@ +{ + "extraKnownMarketplaces": { + "virtuals": { + "source": { "source": "github", "repo": "Virtual-Protocol/projects-autodeploy" } + } + }, + "enabledPlugins": { + "virtuals-platform@virtuals": true + } +} diff --git a/plugin/skills/internal-app/templates/deploy.yml b/plugin/skills/internal-app/templates/deploy.yml new file mode 100644 index 0000000..3128017 --- /dev/null +++ b/plugin/skills/internal-app/templates/deploy.yml @@ -0,0 +1,21 @@ +# Copy to .github/workflows/deploy.yml unchanged. +# Builds this repo and deploys it to Cloud Run every time main changes. +# The app must be hosted first: "Host an app" in the bizops portal. +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + deploy: + permissions: + id-token: write # lets GitHub prove to Google who it is; no keys stored + contents: read + uses: Virtual-Protocol/projects-autodeploy/.github/workflows/deploy-app.yml@main + secrets: inherit # the deploy copies the secrets named in .env.example diff --git a/plugin/skills/internal-app/templates/dockerignore b/plugin/skills/internal-app/templates/dockerignore new file mode 100644 index 0000000..42cdda0 --- /dev/null +++ b/plugin/skills/internal-app/templates/dockerignore @@ -0,0 +1,18 @@ +# Copy to .dockerignore at the repo root. +**/node_modules +**/.next +**/dist +**/build +**/.data +**/uploads +.git +.github +*.log +.DS_Store + +# Secrets never go into the image. .env.production (public values only) is allowed. +**/.env +**/.env.local +**/.env.*.local +**/.env.development +!.env.production diff --git a/plugin/skills/internal-app/templates/platform.yaml b/plugin/skills/internal-app/templates/platform.yaml new file mode 100644 index 0000000..a80f53b --- /dev/null +++ b/plugin/skills/internal-app/templates/platform.yaml @@ -0,0 +1,17 @@ +# Read by projects-autodeploy. Keep it accurate. + +name: my-app # 3-24 lowercase letters, digits and single dashes. It names the + # Cloud Run service and its secrets. Never change it once hosted. +owner: someone@virtuals.io # who owns the app; only this person can host it +port: 3000 # the port the app listens on (it also receives this as PORT) +healthcheck: /api/health # returns 200 fast, without login or database calls +size: small # small = 1 vCPU / 1 GB, up to 3 copies | medium = 2 vCPU / 2 GB, up to 5 +# max_copies: 1 # optional: fewer copies than the size allows, e.g. 1 for an app + # whose copies would clash over files in its storage folder +# public: true # optional: anyone on the internet can open it, with no sign-in. + # Only for apps the platform team approves (public-apps.txt). + +needs: + storage: false # true -> a private Cloud Storage bucket, its name in STORAGE_BUCKET + # storage_folder: /app/uploads # optional, with storage: true -> the bucket also appears + # as this folder, for apps that save files to disk