From 7b9c040d69fa3265a8abf633d4b24442f994388d Mon Sep 17 00:00:00 2001 From: Florian-mfr Date: Sat, 10 Oct 2026 13:17:33 +0200 Subject: [PATCH 1/9] fix(install): Traefik 3.7, and a warning below 4 GiB - Traefik 3.5 no longer gets security fixes: TRAEFIK_IMAGE moves to traefik:v3.7, pinned by the digest of its index (3.7.14). Its entry points drop the request headers whose name holds a character other than a letter, a digit or a dash (aliasheadersstrategy=delete): PHP or CGI read X_Spawner_Preview or X.Forwarded.For as the dashed name, which Traefik's middlewares remove or set. The development stack runs the same option. - Below 4 GiB of memory, the default settings fit no environment: the installer says so, at the check and in its summary, and names SPAWNER_ENV_MEMORY and MIN_REQUIRED_FREE_MEMORY_GB. - An /opt/spawner holding only .env and dns.env is an installation of this installer, as on a new server restored from a backup: the marker is read from .env, and --upgrade stops when compose.yaml is missing. - Messages: links go to spawner.run; the Let's Encrypt e-mail is the account's contact (no expiry e-mails since 2025); the missing Compose hint names docker-compose-v2 for Ubuntu's Docker; --purge says it deletes the secrets too. --- docker-compose.yml | 5 ++++- install.sh | 40 +++++++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index cbf6f70..0195d77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: retries: 10 traefik: - image: traefik:v3.5 + image: traefik:v3.7 container_name: spawner-traefik restart: unless-stopped cap_drop: [ALL] @@ -46,6 +46,9 @@ services: - "--providers.file.directory=/etc/traefik/dynamic" - "--providers.file.watch=true" - "--entrypoints.web.address=:80" + # As install.sh writes it: request headers whose name aliases another + # one (X_Spawner_Preview for X-Spawner-Preview) are dropped. + - "--entrypoints.web.http.aliasheadersstrategy=delete" - "--log.level=INFO" ports: - "${SPAWNER_HTTP_BIND:-127.0.0.1}:${SPAWNER_HTTP_PORT:-80}:80" diff --git a/install.sh b/install.sh index 3f979ef..ad80ed2 100755 --- a/install.sh +++ b/install.sh @@ -52,7 +52,7 @@ IMAGE_REPOSITORY="ghcr.io/flosk6/spawner" # Postgres and Traefik, pinned by digest: bumped by hand before a release # (scripts/README.md). POSTGRES_IMAGE="postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24" -TRAEFIK_IMAGE="traefik:v3.5@sha256:16acb89c6db341182970d6fdafece31303b0a380a8ed7aa51682e225229bf1d2" +TRAEFIK_IMAGE="traefik:v3.7@sha256:575fa15b135078fe5e50aa847987d96dbddd7b093c172429618404df73f3fa7c" RELEASES_API="https://api.github.com/repos/Flosk6/Spawner/releases/latest" INSTALL_DIR="${SPAWNER_INSTALL_DIR:-/opt/spawner}" DATA_DIR="${SPAWNER_DATA_DIR:-}" @@ -118,7 +118,7 @@ Spawner installer: sets up a server dedicated to preview environments. install.sh --uninstall [--purge] stop Spawner (--purge: delete everything) Without an option, the installer asks what it needs. Documentation: -https://github.com/Flosk6/Spawner/blob/master/docs/install.md +https://spawner.run/docs/install/ EOF exit "${1:-0}" } @@ -215,14 +215,15 @@ parse_args() { fi } -# Values of a previous installation, kept unless an option changes them. +# Values of a previous installation, kept unless an option changes them. The +# .env alone is enough, as on a new server restored from a backup. load_previous() { if [ ! -f "$ENV_FILE" ]; then DATA_DIR=${DATA_DIR:-/var/lib/spawner} check_directory SPAWNER_DATA_DIR "$DATA_DIR" return 0 fi - head -1 "$COMPOSE_FILE" 2>/dev/null | grep -q "^$MARKER" || + head -1 "$ENV_FILE" | grep -q "^$MARKER" || die "$INSTALL_DIR holds an installation this installer did not make (an older Spawner?): back it up and remove it first" local line key value previous_data_dir="" while IFS= read -r line || [ -n "$line" ]; do @@ -293,8 +294,10 @@ check_system() { esac MEMORY_GB=$(awk '/^MemTotal:/ { printf "%d", $2 / 1024 / 1024 + 0.5 }' /proc/meminfo) - [ "$MEMORY_GB" -ge 2 ] || die "${MEMORY_GB} GiB of memory: 2 GiB at least, 8 advised" - if [ "$MEMORY_GB" -lt 8 ]; then + [ "$MEMORY_GB" -ge 2 ] || die "${MEMORY_GB} GiB of memory: 4 GiB needed, 8 advised" + if [ "$MEMORY_GB" -lt 4 ]; then + warn "$(small_memory)" + elif [ "$MEMORY_GB" -lt 8 ]; then warn "${MEMORY_GB} GiB of memory: it runs, 8 GiB are advised for several environments at once" else ok "${MEMORY_GB} GiB of memory" @@ -317,6 +320,13 @@ check_system() { ok "ports $ports free" } +# Below 4 GiB, the defaults leave no memory for a single environment: what to +# lower instead. +small_memory() { + printf '%s GiB of memory: with the default settings, no environment starts below 4 GiB. Lower the memory of an environment (SPAWNER_ENV_MEMORY, 2g by default) and the memory a build waits for (MIN_REQUIRED_FREE_MEMORY_GB, 2 by default), on the Settings page of the dashboard or in %s. 8 GiB are advised' \ + "$MEMORY_GB" "$SETTINGS_FILE" +} + # --- Questions ---------------------------------------------------------------- ask_questions() { @@ -334,7 +344,7 @@ ask_questions() { ask DOMAIN "Domain of the previews, such as preview.example.com" fi [[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || die "\"$DOMAIN\" is not a domain name" - [ -n "$EMAIL" ] || ask EMAIL "E-mail for Let's Encrypt (expiry notices)" + [ -n "$EMAIL" ] || ask EMAIL "E-mail of the Let's Encrypt account" [[ "$EMAIL" == *@*.* ]] || die "\"$EMAIL\" is not an e-mail address" if [ -z "$DNS_PROVIDER" ]; then @@ -446,7 +456,7 @@ install_docker() { curl -fsSL https://get.docker.com | sh >/dev/null DOCKER_INSTALLED_NOW=true fi - docker compose version >/dev/null 2>&1 || die "the Docker Compose plugin is missing: apt-get install docker-compose-plugin" + docker compose version >/dev/null 2>&1 || die "the Docker Compose plugin is missing: apt-get install docker-compose-plugin with Docker's packages, or docker-compose-v2 with Ubuntu's docker.io" ok "$(docker --version), $(docker compose version --short 2>/dev/null || echo compose)" configure_docker } @@ -504,7 +514,7 @@ PY configure_zram() { [ "$MEMORY_GB" -lt 8 ] || return 0 if [ -z "$ZRAM" ]; then - if confirm "Below 8 GiB of memory, compressed swap in memory (zram) gives room for idle pages. Enable it?" y; then ZRAM=yes; else ZRAM=no; fi + if confirm "Below 8 GiB of memory, compressed swap in memory (zram) keeps idle pages compressed. Enable it?" y; then ZRAM=yes; else ZRAM=no; fi fi [ "$ZRAM" = yes ] || return 0 command -v apt-get >/dev/null 2>&1 || { warn "zram needs apt-get: skipped"; return 0; } @@ -606,6 +616,10 @@ SETTINGS umask 022 chmod 600 "$ENV_FILE" "$DNS_ENV_FILE" "$SETTINGS_FILE" + # Every entry point drops the request headers whose name has a character + # other than a letter, a digit or a dash (aliasheadersstrategy=delete): + # PHP or CGI read X_Spawner_Preview or X.Forwarded.For as the dashed name, + # which Traefik's middlewares remove or set. local scheme=https ports=' - "80:80" - "443:443"' traefik_tls="" if [ "$TLS" = off ]; then @@ -616,6 +630,7 @@ SETTINGS traefik_tls=' - --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.scheme=https - --entrypoints.websecure.address=:443 + - --entrypoints.websecure.http.aliasheadersstrategy=delete - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' if [ "$wildcard" = true ]; then @@ -672,6 +687,7 @@ services: - --providers.file.directory=/etc/traefik/dynamic - --providers.file.watch=true - --entrypoints.web.address=:80 + - --entrypoints.web.http.aliasheadersstrategy=delete $traefik_tls - --ping=true - --global.checknewversion=false @@ -860,6 +876,7 @@ summary() { say " spawner login $scheme://spawner.$DOMAIN" say "" say "Files in $INSTALL_DIR, data in $DATA_DIR. Upgrade: rerun this installer with --upgrade." + [ "${MEMORY_GB:-8}" -ge 4 ] || warn "$(small_memory)" } # --- Upgrade and removal ------------------------------------------------------ @@ -891,6 +908,7 @@ backup_database() { upgrade() { [ -f "$ENV_FILE" ] || die "Spawner is not installed in $INSTALL_DIR: run the installer without --upgrade" + [ -f "$COMPOSE_FILE" ] || die "$COMPOSE_FILE is missing: run the installer without --upgrade, which writes it again" check_system false resolve_version step "Upgrading from ${PREVIOUS_VERSION:-an unknown version} to $VERSION" @@ -925,7 +943,7 @@ roll_back() { gunzip -c "$BACKUP_FILE" | docker exec -i spawner-postgres psql -q -U spawner -d spawner >/dev/null; then ok "database restored from $BACKUP_FILE" else - warn "the database could not be restored: restore $BACKUP_FILE by hand (docs/operations.md)" + warn "the database could not be restored: restore $BACKUP_FILE by hand (https://spawner.run/docs/operations/#restoring)" fi fi compose up -d --remove-orphans >/dev/null 2>&1 || true @@ -939,7 +957,7 @@ roll_back() { uninstall() { [ "$(id -u)" -eq 0 ] || die "run it as root" if [ "$PURGE" = true ]; then - say "This deletes Spawner, every environment it runs with their data, its database, $DATA_DIR, and $INSTALL_DIR with the backups of the database." + say "This deletes Spawner, every environment it runs with their data, its database, $DATA_DIR, and $INSTALL_DIR with the backups of the database and the secrets." if [ "$ASSUME_YES" != true ]; then local answer="" [ -r /dev/tty ] || die "confirm with --yes" From c199a1b9808e7c2cf0d85af0f21e2ce27dbd5f8a Mon Sep 17 00:00:00 2001 From: Florian-mfr Date: Sat, 10 Oct 2026 13:17:33 +0200 Subject: [PATCH 2/9] fix(cli): links to the documentation site, and what agents do on exit code 6 - The spawner.yaml that init writes links to https://spawner.run/docs/manifest/ instead of the v1 branch of the repository; init ends with the address of the quickstart, and --help with the CLI reference. - The instructions init writes for coding agents say what to do on exit code 6, and call what `url --with-token` gives the preview token header, as the docs do. The help says 6 is quota or capacity. - spawner_up's path says it defaults to the first root the client shares. - A 90-day token expires "in 90d", not "in 89d 24h": rounded hours carry into a day. --- apps/cli/src/cli.ts | 8 +++++--- apps/cli/src/format.spec.ts | 19 +++++++++++++++++++ apps/cli/src/format.ts | 5 +++-- apps/cli/src/mcp.ts | 2 +- apps/cli/src/ops/init.spec.ts | 12 +++++++++++- apps/cli/src/ops/init.ts | 14 +++++++++++--- 6 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/format.spec.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index d4aedda..b931f24 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -240,6 +240,7 @@ export function buildProgram(io: Io, setExit: (code: number) => void): Command { output.print(""); output.print(`Next: an admin registers the project ${output.out.bold(result.project)} in the dashboard (Projects), then:`); output.print(" spawner up --wait"); + output.print(output.out.dim("Step by step: https://spawner.run/docs/quickstart/")); }), ); @@ -552,7 +553,7 @@ export function buildProgram(io: Io, setExit: (code: number) => void): Command { program .command("url") - .description("print the URL of an environment; --with-token adds the header that opens it") + .description("print the URL of an environment; --with-token adds the preview token header that opens it") .argument("[env]", "environment (default: from the branch)") .argument("[exposure]", "exposure of spawner.yaml (default: the entrypoint)") .addOption(projectOption()) @@ -750,8 +751,9 @@ export function buildProgram(io: Io, setExit: (code: number) => void): Command { "after", ` Exit codes: 0 ok, 1 error, 2 usage, 3 authentication, 4 environment failed, -5 timeout, 6 capacity, 7 spawner.yaml or compose file refused. -Environment: SPAWNER_URL and SPAWNER_TOKEN replace spawner login; SPAWNER_PROJECT names the project.`, +5 timeout, 6 quota or capacity, 7 spawner.yaml or compose file refused. +Environment: SPAWNER_URL and SPAWNER_TOKEN replace spawner login; SPAWNER_PROJECT names the project. +Documentation: https://spawner.run/docs/cli/`, ); return program; } diff --git a/apps/cli/src/format.spec.ts b/apps/cli/src/format.spec.ts new file mode 100644 index 0000000..d4672f4 --- /dev/null +++ b/apps/cli/src/format.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { formatDuration } from "./format"; + +describe("formatDuration", () => { + it("gives days and hours from three days on", () => { + expect(formatDuration(4 * 86400 + 5 * 3600)).toBe("4d 5h"); + expect(formatDuration(5 * 86400)).toBe("5d"); + }); + + it("carries rounded hours into a day", () => { + expect(formatDuration(90 * 86400 - 60)).toBe("90d"); + expect(formatDuration(89 * 86400 + 23 * 3600 + 40 * 60)).toBe("90d"); + }); + + it("keeps hours and minutes below three days", () => { + expect(formatDuration(3600 + 120)).toBe("1h 2m"); + expect(formatDuration(71 * 3600)).toBe("71h"); + }); +}); diff --git a/apps/cli/src/format.ts b/apps/cli/src/format.ts index 505ea3b..174fea5 100644 --- a/apps/cli/src/format.ts +++ b/apps/cli/src/format.ts @@ -27,8 +27,9 @@ export function formatDuration(seconds: number): string { const minutes = Math.floor((s % 3600) / 60); return minutes === 0 || s >= 10 * 3600 ? `${Math.round(s / 3600)}h` : `${Math.floor(s / 3600)}h ${minutes}m`; } - const hours = Math.round((s % 86400) / 3600); - return hours === 0 ? `${Math.floor(s / 86400)}d` : `${Math.floor(s / 86400)}d ${hours}h`; + const totalHours = Math.round(s / 3600); + const hours = totalHours % 24; + return hours === 0 ? `${Math.floor(totalHours / 24)}d` : `${Math.floor(totalHours / 24)}d ${hours}h`; } /** diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 0a58494..e77769a 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -23,7 +23,7 @@ const INSTRUCTIONS = `Spawner runs preview environments of this project on a ser const target = { env: z.string().optional().describe("Environment name; by default, the branch of the worktree (feat/login gives feat-login)"), project: z.string().optional().describe("Project slug; by default, the project of .spawner/spawner.yaml"), - path: z.string().optional().describe("Absolute path of the worktree; by default, the directory the MCP server runs in"), + path: z.string().optional().describe("Absolute path of the worktree; by default, the first root the client shares, else the directory the MCP server runs in"), }; type Json = Record | unknown[]; diff --git a/apps/cli/src/ops/init.spec.ts b/apps/cli/src/ops/init.spec.ts index 9db6187..1c65dfb 100644 --- a/apps/cli/src/ops/init.spec.ts +++ b/apps/cli/src/ops/init.spec.ts @@ -6,7 +6,7 @@ import { checkProject } from "../check"; import { INFO } from "../testing/fake-api"; import { repo, tempDir, write } from "../testing/repo"; import { loadWorkspace } from "../workspace"; -import { AGENTS_HEADING, detectDatabase, detectPort, init, projectSlug } from "./init"; +import { AGENTS_HEADING, MANIFEST_DOCS, detectDatabase, detectPort, init, projectSlug } from "./init"; describe("init", () => { it.each(["postgres", "mysql", "none"] as const)("writes files the server accepts (database: %s)", async (db) => { @@ -19,6 +19,15 @@ describe("init", () => { expect(check.services).toEqual(db === "none" ? ["app"] : ["app", "db"]); }); + it("points spawner.yaml to the documentation site, not to a branch of the repository", async () => { + const dir = repo({ Dockerfile: "FROM node:22\n" }); + await init({ dir, project: "shop", db: "none", agentsFile: null }); + const manifest = fs.readFileSync(path.join(dir, ".spawner/spawner.yaml"), "utf8"); + expect(MANIFEST_DOCS).toBe("https://spawner.run/docs/manifest/"); + expect(manifest).toContain(`# Reference: ${MANIFEST_DOCS}\n`); + expect(manifest).not.toMatch(/github\.com/); + }); + it("adds the agent instructions once, after the existing content", async () => { const dir = repo({ "CLAUDE.md": "# Project\n", Dockerfile: "FROM x" }); expect((await init({ dir, project: "shop", db: "postgres", agentsFile: "CLAUDE.md" })).agentsFile).toBe("CLAUDE.md"); @@ -28,6 +37,7 @@ describe("init", () => { expect(content.startsWith("# Project\n\n## Preview environments (Spawner)")).toBe(true); expect(content.split(AGENTS_HEADING)).toHaveLength(2); expect(content).toContain("spawner exec db -- "); + expect(content).toContain("Code 6 means your quota or the server's capacity is reached"); }); it("keeps existing files unless forced, and warns without a Dockerfile", async () => { diff --git a/apps/cli/src/ops/init.ts b/apps/cli/src/ops/init.ts index b4f0e14..98ec68d 100644 --- a/apps/cli/src/ops/init.ts +++ b/apps/cli/src/ops/init.ts @@ -28,6 +28,13 @@ export interface InitResult { /** Heading of the instructions for coding agents; present means already added. */ export const AGENTS_HEADING = "## Preview environments (Spawner)"; +/** + * The reference of spawner.yaml, written into every manifest `init` creates: + * the documentation site, which follows the latest release, rather than a + * branch of the repository that may fall behind or disappear. + */ +export const MANIFEST_DOCS = "https://spawner.run/docs/manifest/"; + /** * The instructions `spawner init` adds to CLAUDE.md or AGENTS.md, so that a * coding agent knows how to test its work on a preview environment. @@ -41,13 +48,14 @@ This project runs preview environments on Spawner: one per branch, with its own - To test end to end, run \`spawner up --wait --json\` from the worktree root. It creates or updates the environment of the current branch and prints its name and URLs. Add \`--source =\` to send another repository of spawner.yaml from a local worktree. -- Use the returned URLs. For curl or Playwright, get the auth header with +- Use the returned URLs. For curl or Playwright, get the preview token header with \`spawner url --with-token --json\`. - Commands in ${database}: \`spawner exec ${db === "none" ? "" : "db"} -- \` (\`-i\` sends stdin, such as a SQL file). - Debugging: \`spawner logs --errors\`, and \`spawner status --json\` for restarts and out-of-memory kills. - If \`spawner up\` exits with code 4, read the log tail it printed, fix the cause, then retry. Code 7 means - .spawner/ was refused: fix what it lists. + .spawner/ was refused: fix what it lists. Code 6 means your quota or the server's capacity is reached: + \`spawner ls --all --mine --json\` lists your environments; delete those whose work is done, or ask a person. - To show the result to someone without an account: \`spawner share \`. - When the task is done and validated, run \`spawner down \`. `; @@ -157,7 +165,7 @@ const DATABASE_SERVICES: Record, { service: string; ur function manifestTemplate(project: string, port: number): string { return `# Spawner runs one preview environment of this project per branch. -# Reference: https://github.com/Flosk6/Spawner/blob/v1/docs/manifest.md +# Reference: ${MANIFEST_DOCS} version: 1 project: ${project} # slug of the project on Spawner compose: compose.yaml # relative to .spawner/ From 6fac6670aad3cd58b0c2db6720b726a089230eb9 Mon Sep 17 00:00:00 2001 From: Florian-mfr Date: Sat, 10 Oct 2026 13:17:33 +0200 Subject: [PATCH 3/9] fix(web): the Docs button opens the documentation site It opened the docs/ directory of the running version's tag on GitHub; it opens https://spawner.run/docs/, which describes the latest release. --- apps/web/src/components/AppTopbar.vue | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/AppTopbar.vue b/apps/web/src/components/AppTopbar.vue index b587600..d5d16fb 100644 --- a/apps/web/src/components/AppTopbar.vue +++ b/apps/web/src/components/AppTopbar.vue @@ -24,21 +24,18 @@ From 70ef59ee6528a92c497ff92f3d92283d96826140 Mon Sep 17 00:00:00 2001 From: Florian-mfr Date: Sat, 10 Oct 2026 13:17:33 +0200 Subject: [PATCH 4/9] ci(release): the npm page, the MCP Registry, and notes that say what Spawner is - The published package.json: homepage https://spawner.run, the description of Spawner, the keywords people search with, and mcpName io.github.Flosk6/spawner. - A last job publishes server.json to the official MCP Registry through GitHub OIDC, for stable releases, once npm has the version: it cannot undo or block the publish. - The notes open with what Spawner is and links to the docs and the changelog, give the install and upgrade commands apart, list the files, and show how to verify them, with the attestations or SHA256SUMS. - Image labels carry the same description, and the url and documentation of spawner.run, in the Dockerfile as in the workflow. - The Documentation link of the issue chooser opens the site. --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/release.yml | 114 +++++++++++++++++++++++++++--- Dockerfile | 4 +- server.json | 47 ++++++++++++ 4 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 server.json diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index cfe3282..8ab42de 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: https://github.com/Flosk6/Spawner/security/advisories/new about: Report it privately, never in an issue - name: Documentation - url: https://github.com/Flosk6/Spawner#documentation + url: https://spawner.run/docs/ about: Installing, the manifest, the CLI, agents, security, operations diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb3128e..9e806d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,12 +10,18 @@ name: Release # of its image, so that it installs what this workflow built. # scripts/release.sh prepares the versions and the tag. # +# Once npm has a stable version, its server.json goes to the official MCP +# Registry, which trusts this repository's OIDC token for the names +# io.github.Flosk6/* (the owner's case: the registry compares it as is). That +# job comes last and may fail without undoing or holding back anything else. +# # The jobs that publish (image, npm, release) run in the "release" # environment, whose reviewers approve twice per tag: first the image and npm # together (both wait for the CLI build), then the GitHub release, which waits # for the image. Nothing here reads a cache that another workflow could have # written. Actions are pinned by commit (updated by Dependabot); the binfmt -# and BuildKit images by digest (updated by hand). +# and BuildKit images by digest, mcp-publisher by version and checksum +# (updated by hand). # # GitHub runs the workflow of the tagged commit: these guards hold for tags # on commits that carry them, so creating v* tags is restricted to admins by @@ -30,6 +36,7 @@ permissions: env: IMAGE: ghcr.io/flosk6/spawner + DESCRIPTION: "Spawner is an open source, self-hosted preview environment manager: a copy of your app for each git branch, on your own server, with its own URLs, database and logs, for teams and their coding agents." jobs: version: @@ -94,10 +101,15 @@ jobs: fs.writeFileSync("release/npm/package.json", JSON.stringify({ name: "spawner-cli", version: process.env.SPAWNER_VERSION, - description: "Spawner CLI and MCP server: preview environments from a worktree, on your own server", - keywords: ["preview", "environments", "docker", "compose", "mcp", "coding agents"], - homepage: "https://github.com/Flosk6/Spawner", + description: "The CLI and MCP server of Spawner, an open source, self-hosted preview environment manager: a copy of your app for each git branch, on your own server, with its own URLs, database and logs.", + keywords: [ + "preview-environments", "ephemeral-environments", "review-apps", "self-hosted", "docker-compose", + "mcp", "mcp-server", "coding-agents", "claude-code", "codex", "cursor", "devops", "cli", + ], + homepage: "https://spawner.run", repository: { type: "git", url: "git+https://github.com/Flosk6/Spawner.git", directory: "apps/cli" }, + bugs: { url: "https://github.com/Flosk6/Spawner/issues" }, + mcpName: require("./server.json").name, license: "Apache-2.0", bin: { spawner: "spawner.cjs" }, files: ["spawner.cjs", "README.md", "LICENSE", "NOTICE"], @@ -143,14 +155,29 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # The labels and the annotations replace those of the Dockerfile and + # of the repository's settings; the annotations of the index are what + # GHCR shows for an image of several platforms. - id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index with: images: ${{ env.IMAGE }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} + labels: | + org.opencontainers.image.title=Spawner + org.opencontainers.image.description=${{ env.DESCRIPTION }} + org.opencontainers.image.url=https://spawner.run + org.opencontainers.image.documentation=https://spawner.run/docs/ + annotations: | + org.opencontainers.image.title=Spawner + org.opencontainers.image.description=${{ env.DESCRIPTION }} + org.opencontainers.image.url=https://spawner.run + org.opencontainers.image.documentation=https://spawner.run/docs/ - name: Build and push linux/amd64 and linux/arm64 id: build uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 @@ -160,6 +187,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} build-args: SPAWNER_VERSION=${{ needs.version.outputs.version }} - name: Attest the provenance of the images uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 @@ -239,17 +267,39 @@ jobs: awk -v version="$VERSION" ' $0 ~ "^## " { if (found) exit; found = ($2 == version) ; next } found { print } - ' CHANGELOG.md > release/notes.md - [ -s release/notes.md ] || echo "Spawner $VERSION." > release/notes.md - cat >> release/notes.md < release/changes.md + [ -s release/changes.md ] || echo "Spawner $VERSION." > release/changes.md + download="https://github.com/Flosk6/Spawner/releases/download/v$VERSION" + { + echo "$DESCRIPTION [Documentation](https://spawner.run/docs/), [changelog](https://spawner.run/changelog/)." + echo + cat release/changes.md + cat < release/notes.md - name: Attest the provenance of install.sh and the CLI bundle uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: @@ -271,3 +324,44 @@ jobs: gh release create "v$VERSION" release/install.sh release/spawner release/SHA256SUMS \ --title "Spawner $VERSION" --notes-file release/notes.md \ ${{ needs.version.outputs.prerelease == 'true' && '--prerelease' || '--latest' }} + + mcp-registry: + name: MCP server on the MCP Registry + needs: [version, npm] + if: needs.version.outputs.prerelease == 'false' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + VERSION: ${{ needs.version.outputs.version }} + MCP_PUBLISHER_VERSION: 1.8.1 + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install mcp-publisher, checked against its checksum + run: | + curl -fsSL -o mcp-publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/v$MCP_PUBLISHER_VERSION/mcp-publisher_linux_amd64.tar.gz" + echo "$MCP_PUBLISHER_SHA256 mcp-publisher.tar.gz" | sha256sum -c - + tar -xzf mcp-publisher.tar.gz mcp-publisher + - name: Wait until npm serves spawner-cli@${{ needs.version.outputs.version }} with its mcpName + run: | + name="$(jq -r .name server.json)" + for _ in $(seq 1 30); do + [ "$(curl -fsS "https://registry.npmjs.org/spawner-cli/$VERSION" 2>/dev/null | jq -r .mcpName)" = "$name" ] && exit 0 + sleep 10 + done + echo "::error::spawner-cli@$VERSION on npm has no mcpName $name: the MCP Registry would refuse it" + exit 1 + - name: Publish server.json at version ${{ needs.version.outputs.version }} + run: | + jq --arg version "$VERSION" '.version = $version | .packages[].version = $version' server.json > release-server.json + name="$(jq -r .name release-server.json)" + if curl -fsS -o /dev/null "https://registry.modelcontextprotocol.io/v0.1/servers/$(jq -rn --arg name "$name" '$name | @uri')/versions/$VERSION"; then + echo "::notice::$name $VERSION is already on the MCP Registry" + exit 0 + fi + ./mcp-publisher login github-oidc + ./mcp-publisher publish release-server.json diff --git a/Dockerfile b/Dockerfile index 96853e8..5ada393 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,9 @@ RUN pnpm --filter @spawner/api --prod deploy /out \ FROM base AS runtime ARG SPAWNER_VERSION= LABEL org.opencontainers.image.title="Spawner" \ - org.opencontainers.image.description="Preview environments for every branch, on your own server" \ + org.opencontainers.image.description="Spawner is an open source, self-hosted preview environment manager: a copy of your app for each git branch, on your own server, with its own URLs, database and logs, for teams and their coding agents." \ + org.opencontainers.image.url="https://spawner.run" \ + org.opencontainers.image.documentation="https://spawner.run/docs/" \ org.opencontainers.image.source="https://github.com/Flosk6/Spawner" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.version="${SPAWNER_VERSION}" diff --git a/server.json b/server.json new file mode 100644 index 0000000..8430540 --- /dev/null +++ b/server.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.Flosk6/spawner", + "title": "Spawner", + "description": "Preview environments on your self-hosted Spawner server: a copy of your app for each git branch", + "repository": { + "url": "https://github.com/Flosk6/Spawner", + "source": "github", + "id": "1105425637", + "subfolder": "apps/cli" + }, + "websiteUrl": "https://spawner.run/docs/agents/", + "version": "2.1.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "spawner-cli", + "version": "2.1.0", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + } + ], + "environmentVariables": [ + { + "name": "SPAWNER_URL", + "description": "Address of your Spawner server's dashboard, such as https://spawner.preview.example.com. Without it, the server of spawner login", + "isRequired": false, + "isSecret": false, + "format": "string" + }, + { + "name": "SPAWNER_TOKEN", + "description": "Personal API token (spn_...), from spawner token create or the dashboard. Without it, the token of spawner login", + "isRequired": false, + "isSecret": true, + "format": "string" + } + ] + } + ] +} From 2fdbe404b2592255925ed41878a0d5707736bafd Mon Sep 17 00:00:00 2001 From: Florian-mfr Date: Sat, 10 Oct 2026 13:17:50 +0200 Subject: [PATCH 5/9] docs: a quickstart, previews for pull requests, the examples and troubleshooting - quickstart.md: from a fresh server to a first environment, with the steps no page gave: an admin registers the project (each field of the dialog, deploy keys for private repositories, source repositories), then spawner init and spawner up. - ci.md: a preview per pull request from GitHub Actions and per merge request from GitLab CI, with a CI account and a token restricted to the project, the quota, forks, and pull_request_target never. - examples.md: what the two example projects run and show, and how to try one. - troubleshooting.md: the message of each usual failure and what to do. --- docs/ci.md | 229 ++++++++++++++++++++++++++++++++++++++++ docs/examples.md | 83 +++++++++++++++ docs/quickstart.md | 152 ++++++++++++++++++++++++++ docs/troubleshooting.md | 184 ++++++++++++++++++++++++++++++++ 4 files changed, 648 insertions(+) create mode 100644 docs/ci.md create mode 100644 docs/examples.md create mode 100644 docs/quickstart.md create mode 100644 docs/troubleshooting.md diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..9d7b03e --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,229 @@ +# Previews for pull requests + +A CI job can give each pull request a Spawner preview environment: `spawner up pr-` at each push, its URL in a comment, `spawner down` when it closes. This page has a GitHub Actions workflow that does it, and a GitLab CI equivalent for merge requests. + +Spawner does not do it by itself yet: nothing watches the git host, so no environment starts when a pull request opens unless a job starts it. + +## How it works + +At each push to a pull request, the job checks the code out and runs `spawner up` from the checkout, as a developer does from a worktree: + +```bash +spawner up pr-42 --wait --json +``` + +- The checkout is sent as an archive, the files git sees ([what is sent](cli.md#up)), and the server builds it: the CI runner needs Node.js and the CLI, not Docker. +- The environment is named explicitly: a CI checkout has a detached HEAD, with no branch to name it after. +- `--wait` returns once the environment is ready, after 30 minutes at most (`--timeout`). `--json` prints the result: `environment.url` is the entrypoint's URL, `environment.urls` every URL by exposure. +- The exit code says how it went: 0 ready, 4 failed, 5 still building, 6 quota or capacity reached, 7 `.spawner/` refused ([exit codes](cli.md#exit-codes)). + +When the pull request is closed or merged, `spawner down pr-42` deletes the environment and its data. + +## A CI account and its token + +CI uses a personal API token, as an agent does. Give it an account of its own, so that its previews do not use up a person's quota, and show as "ci via github-actions" in the dashboard: + +1. **Team**, **Invite**: who it is for ("CI"), role **Member**, **Create the link**. Open the link in a private window, name the account `ci` and create a passkey. Nobody needs to keep that passkey: an admin can send the account a link for a new one later (**Team**, the menu of the account, **Link for a new passkey**). +2. Still logged in as `ci`: **Account and tokens**, **API tokens**, **New token**. Name it after the CI (`github-actions`), keep only the scopes `envs:read` and `envs:write`, choose the project, and set an expiry, 365 days at most. +3. Copy the token, shown once, into a secret of the CI named `SPAWNER_TOKEN`. + +The token reaches that project only, and without `envs:exec` it cannot run commands in environments. Add `preview` only if a later step calls the protected URLs, with `spawner url --with-token`. When the token expires, `spawner up` exits with code 3 (`unauthorized`): create the next one before, and replace the secret. + +`spawner token create --scopes envs:read,envs:write --project blog`, from a login of `ci`, makes the same token, but it expires with that login at the latest, and `spawner logout` revokes it: for CI, use the dashboard. + +### Quota, capacity and lifetime + +- **Quota**: the CI account's environments count against its quota, 5 per person by default, sleeping ones included, across every project. With the default, a sixth open pull request gets no preview: `spawner up` exits with code 6 (`quota`), and the jobs below say so. Closing pull requests frees places, and an admin can raise **Environments per person** on the **Settings** page, for every account (0 for no limit). +- **Capacity**: a new preview also needs the memory and disk of one more environment of the project on the server (`spawner capacity`), or `spawner up` exits with code 6 (`capacity`). +- **Queued deploys**: an account may also have 5 deploys of uploaded code waiting to start. When builds queue on the server and more pull requests push at once, the next `spawner up` exits with code 6 (`quota`) until one starts: run the job again. +- **Lifetime**: a preview expires 72 hours after its last deploy by default (`ttl` in `spawner.yaml`, 14 days at most by default). A pull request without a push for longer loses its preview, and the next push creates a new one, with a new database. `--ttl 7d` on the `spawner up` line keeps it longer. +- **Sleep**: after 2 hours without activity, a preview sleeps; the next visit to one of its protected URLs wakes it up within seconds. + +Previews belong to the CI account: teammates open them and read their logs and status, but only admins stop them, delete them, share them or run commands in them ([roles](concepts.md#accounts-and-roles)). Their URLs are protected like any other: a teammate logged in to the dashboard opens them, and for someone without an account, an admin creates a link on the environment's page (**Share**, **Create a link**). + +## GitHub Actions + +`.github/workflows/preview.yml`: + +```yaml +name: Preview + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + +env: + SPAWNER_URL: https://spawner.preview.example.com + SPAWNER_PROJECT: blog + ENV_NAME: pr-${{ github.event.pull_request.number }} + +jobs: + deploy: + if: >- + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + - run: npm install -g spawner-cli@2 + + - name: Deploy the preview + id: up + env: + SPAWNER_TOKEN: ${{ secrets.SPAWNER_TOKEN }} + run: | + code=0 + spawner up "$ENV_NAME" --wait --json > up.json || code=$? + echo "code=$code" >> "$GITHUB_OUTPUT" + + - name: Comment on the pull request + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + CODE: ${{ steps.up.outputs.code }} + run: | + marker='' + log="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + if [ "$CODE" = 0 ]; then + text="ready."$'\n\n'$(jq -r '.environment.urls + | to_entries[] | "- \(.key): \(.value)"' up.json) + else + text="not ready, exit code $CODE ([log]($log))."$'\n\n'$(jq -r ' + (.error.message // .job.error // "still running on the server"), + (.error.hint // empty)' up.json) + fi + body="$marker"$'\n'"Preview of ${SHA::7}: $text" + comments="repos/$GITHUB_REPOSITORY/issues/$PR/comments" + id=$(gh api --paginate "$comments" | jq -r --arg m "$marker" '.[] + | select(.user.login == "github-actions[bot]") + | select(.body | startswith($m)) | .id' | head -n 1) + if [ -n "$id" ]; then + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" \ + -f body="$body" > /dev/null + else + gh api "$comments" -f body="$body" > /dev/null + fi + exit "$CODE" + + cleanup: + if: >- + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/setup-node@v7 + with: + node-version: 22 + - run: npm install -g spawner-cli@2 + + - name: Delete the preview + env: + SPAWNER_TOKEN: ${{ secrets.SPAWNER_TOKEN }} + run: | + code=0 + spawner down "$ENV_NAME" --json > down.json || code=$? + error=$(jq -r '.error.code // empty' down.json) + if [ "$code" -ne 0 ] && [ "$error" != not_found ]; then + cat down.json + exit "$code" + fi +``` + +Set `SPAWNER_URL` to your dashboard's URL, and `SPAWNER_PROJECT` to the `project` of `spawner.yaml`: the cleanup job does not check the code out, so it names the project. Add the token as a repository secret (**Settings**, **Secrets and variables**, **Actions**). Pin the actions by commit SHA if you pin your others. + +- **The comment**: the job keeps one comment per pull request, found by its hidden marker, and edits it at each push: the URLs when the preview is ready, the error and a link to the log of the run when it is not. The job then fails with `spawner`'s exit code, as a check of the pull request. +- **One run at a time** per pull request: a push while the preview builds waits for that run to end, and GitHub keeps only the latest waiting run. A deploy is never cut in the middle, and the preview ends up on the last commit, or deleted once the pull request is closed. +- **What is deployed**: `actions/checkout` checks out the pull request merged into its base branch (`refs/pull//merge`), so the preview shows what merging would give. With `ref: ${{ github.event.pull_request.head.sha }}` under its `with:`, it deploys the branch alone. +- **A monorepo**: run `spawner up` in the directory holding `.spawner/` (`working-directory: apps/blog` on the step), which is the project's **Directory** in the dashboard. +- **Other repositories** of `spawner.yaml` (`sources`) come from git at their `default_ref`; `--ref front=` on the `spawner up` line picks another branch. +- **The CLI's version**: `spawner-cli@2` installs the latest 2.x. To match the server exactly, give its version (`spawner-cli@2.1.0`), or download the server's own copy from `$SPAWNER_URL/api/v1/cli/spawner` ([versions](cli.md#versions-and-updates)). `npx --yes spawner-cli@2 up ...` works without the install step. + +### Pull requests from forks + +The workflow runs on `pull_request`, and only for the branches of the repository itself: + +- GitHub gives no secrets to the workflows of pull requests from forks, so they would have no token: the `if:` skips their jobs rather than failing them. The runs Dependabot triggers get only Dependabot secrets, and are skipped the same way. +- Never switch to `pull_request_target` to get the secrets with a checkout of the pull request's code: that runs a stranger's code with your secrets and write access to the repository. A preview also runs the code of its branch on your server, with the project's variables, and the outbound traffic of environments is not filtered ([security](security.md#what-spawner-does-not-do-yet)): the code of a fork could send them anywhere. +- A maintainer who has read the change can start its preview by hand: `gh pr checkout 42`, then `spawner up pr-42 --wait`. That preview belongs to the maintainer: delete it with `spawner down pr-42` when the pull request closes, or let it expire. + +## GitLab CI + +`.gitlab-ci.yml`, for merge request pipelines: + +```yaml +stages: [preview] + +variables: + SPAWNER_URL: https://spawner.preview.example.com + SPAWNER_PROJECT: blog + ENV_NAME: mr-$CI_MERGE_REQUEST_IID + +.spawner: + stage: preview + image: node:22 + resource_group: preview-mr-$CI_MERGE_REQUEST_IID + before_script: + - npm install -g spawner-cli@2 + +preview: + extends: .spawner + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $SPAWNER_TOKEN + script: + - | + code=0 + spawner up "$ENV_NAME" --wait --json > up.json || code=$? + node -p 'require("./up.json").environment?.url ?? ""' | + sed 's/^/PREVIEW_URL=/' > deploy.env + exit "$code" + artifacts: + reports: + dotenv: deploy.env + environment: + name: review/mr-$CI_MERGE_REQUEST_IID + url: $PREVIEW_URL + on_stop: preview-stop + +preview-stop: + extends: .spawner + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $SPAWNER_TOKEN + when: manual + allow_failure: true + variables: + GIT_STRATEGY: none + script: + - | + code=0 + spawner down "$ENV_NAME" --json > down.json || code=$? + if [ "$code" -ne 0 ] && ! grep -q '"not_found"' down.json; then + cat down.json + exit "$code" + fi + environment: + name: review/mr-$CI_MERGE_REQUEST_IID + action: stop +``` + +- **The URL**: the `deploy.env` report gives the environment `review/mr-` its URL, which the merge request shows. The job fails with `spawner`'s exit code when the preview does. +- **The end**: when the merge request is merged or closed, GitLab runs `preview-stop`, which deletes the preview. The environment's stop button runs it too. +- **One job at a time** per merge request: `resource_group` makes a stop wait for a running deploy, and a deploy for the one before. +- **The token**: a CI/CD variable `SPAWNER_TOKEN` (**Settings**, **CI/CD**, **Variables**), with the visibility **Masked and hidden**. Leave **Protect variable** unchecked, unless your project lets merge request pipelines read protected variables: these pipelines run on branches that are not protected. Whoever can push a branch can then use the token in a pipeline, which is why it reaches one project, without `envs:exec`. +- **Forks**: the pipeline of a merge request from a fork runs in the fork, without the project's variables: `$SPAWNER_TOKEN` is empty, and the rules leave the preview out. A member of the project who has read the change can run the pipeline in the project instead (the merge request's **Pipelines** tab, **Run pipeline**, after GitLab's warning): it then gets a preview like any other. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..788926f --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,83 @@ +# Examples + +Two projects ready to deploy sit in the [`examples/`](../examples/) directory of the repository: Node.js with PostgreSQL, and Laravel, Next.js and MySQL. Each holds its application and its `.spawner/` directory, and runs as it is on a Spawner server. The tests check both against the [compose policy](security.md#the-compose-policy), and the end-to-end test of the installer deploys both on every pull request of Spawner. + +## Node.js and PostgreSQL + +[`examples/node-postgres`](../examples/node-postgres/) is the smallest useful project: one service of its own, one database, one URL. + +- **`app`**: a Node.js 22 HTTP server (`server.js`, with the `pg` client). Its page gives the environment's name, its URL and the number of users; `/users` lists them as JSON; `/health` answers once the database does. +- **`db`**: PostgreSQL 18, with the settings of [databases for previews](manifest.md#databases-for-previews). + +What it shows: + +- **One exposure**, `web` on port 3000, which is the entrypoint. The compose file passes `${SPAWNER_URL}` and `${SPAWNER_ENV}` to the app. +- **A seed**: `node seed.js` runs in `app` on the first deploy, creates the `users` table and adds one user. +- **Healthchecks** on both services, every 2 seconds: the app starts once the database accepts connections (`condition: service_healthy`), and the environment is ready once the app answers on `/health`. +- **A Dockerfile that shares its layers**: it installs the dependencies before copying the code. +- **A shorter lifetime**: `ttl: 24h`. + +```yaml +# examples/node-postgres/.spawner/spawner.yaml +version: 1 +project: example +exposures: + - name: web + service: app + port: 3000 +seed: + - service: app + run: [node, seed.js] +ttl: 24h +``` + +## Laravel, Next.js and MySQL + +[`examples/laravel-next-mysql`](../examples/laravel-next-mysql/) is what a team typically previews: a front, an API and a database, in one repository. + +- **`web`**: Next.js 16, one page that lists the posts, read from the API on the server side. +- **`api`**: Laravel 13 on PHP 8.4, `GET` and `POST /posts`, served by `php artisan serve`. +- **`db`**: MySQL 8.4, with the settings of [databases for previews](manifest.md#databases-for-previews). + +What it shows: + +- **Two exposures**: `web` is the entrypoint (`--blog.`), `api` gets `api----blog.`. The compose file passes them to the services as `${SPAWNER_URL_WEB}` and `${SPAWNER_URL_API}`. +- **Calls between services inside the environment**: the front reads the posts from `http://api:8000`, without a token or TLS; the public API URL is only a link for the browser. +- **A seed**: `php artisan migrate --seed --force` runs in `api` on the first deploy, creates the table and adds two posts. +- **Healthchecks in a chain**: `db` answers `mysqladmin ping`, then `api` starts and answers on Laravel's `/up`, then `web` starts. +- **Shared layers**: both Dockerfiles install the dependencies (`composer.lock`, `package-lock.json`) before copying the code, so environments with the same lock files share the `vendor` and `node_modules` layers. +- **An application behind the proxy**: both servers listen on `0.0.0.0`, the API trusts the proxy so that the URLs it builds keep `https`, and Laravel logs to stderr (`LOG_CHANNEL: stderr`), where `spawner logs --errors` finds its errors. See [your application behind Spawner](manifest.md#your-application-behind-spawner). + +Once seeded, an environment of this example runs in about 320 MiB of memory. Each new environment adds about 4 MiB of images of its own: the 830 MiB of base images and dependencies are shared by all of them ([making environments cheap](manifest.md#making-environments-cheap)). + +Its [README](../examples/laravel-next-mysql/README.md) says more, and how to split it over two repositories with a source. + +## Trying one + +On a Spawner server ([the quickstart](quickstart.md) sets one up), an admin registers the example as a project: **Projects**, **New project**, with any name and these fields. + +| Field | Node.js and PostgreSQL | Laravel, Next.js and MySQL | +|---|---|---| +| Slug | `example` | `blog` | +| Repository | `https://github.com/Flosk6/Spawner.git` | `https://github.com/Flosk6/Spawner.git` | +| Default branch | `master` | `master` | +| Directory | `examples/node-postgres` | `examples/laravel-next-mysql` | + +The slug is the `project` of the example's `spawner.yaml`. The repository is public, so it needs no deploy key. + +Then, with the CLI logged in to the server, from the example's directory in a clone of the repository: + +```bash +git clone https://github.com/Flosk6/Spawner.git +cd Spawner/examples/node-postgres +spawner up demo --wait # without a name: the branch, master +spawner url demo # https://demo--example. +spawner exec demo db -- psql -U app -d app -c "select * from users" +spawner down demo +``` + +`spawner up` sends the example's directory from your clone, with your changes: edit `server.js`, run it again, and the environment shows the change. The page of the environment says `Hello from Spawner (demo) at https://demo--example.: 1 user(s)`. + +For the Laravel example, from `Spawner/examples/laravel-next-mysql`, the database is MySQL: `spawner exec demo db -- mysql -uapp -papp app -e "select title from posts"`. + +Without the CLI, an environment of the example also comes from git: **New environment** in the dashboard, the project, the branch `master`. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..bcd79e9 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,152 @@ +# Quickstart + +From a fresh Linux server to a first preview environment of your app, with its own URLs and database: install Spawner, log in, add a project, run `spawner up`. + +Each step links the page that says more. The commands use `preview.example.com` as the domain of the previews: put yours. + +## What you need + +- **A Linux server dedicated to previews**: Ubuntu 22.04 or 24.04, or Debian 12, amd64 or arm64, with 4 GiB of memory at least (8 GiB or more advised), 20 GiB of free disk, and ports 80 and 443 open. Environments run code from branches nobody has reviewed yet: put nothing else on it ([install](install.md#before-you-start)). +- **A domain for the previews**, with a wildcard DNS record pointing to the server: + + ```text + *.preview.example.com. A 203.0.113.10 + ``` + + `dig +short anything.preview.example.com` must give the server's address. +- **Optionally, an API token of your DNS provider** (Cloudflare, OVH, Hetzner, Route 53 and others), for one wildcard certificate: every new environment then gets HTTPS at once. Without it, each new URL gets its own certificate, within Let's Encrypt's limit of about 50 a week ([install](install.md#dns-providers)). +- **On your machine**: Node.js 20 or later, git, and the repository of an application that builds with a Dockerfile. No application at hand: deploy [the example](#add-a-project) of Spawner's repository. + +## Install the server + +On the server, as root: + +```bash +curl -fsSL https://github.com/Flosk6/Spawner/releases/latest/download/install.sh | sudo bash +``` + +The installer asks for the domain of the previews, an e-mail for the Let's Encrypt account and your DNS provider with its token (or none). It installs Docker if needed, starts Spawner, Postgres and Traefik, waits for the certificate, and ends with: + +```text +== Spawner 2.1.0 is running + Dashboard https://spawner.preview.example.com + First admin https://spawner.preview.example.com/invite/... +``` + +followed by the commands that install the CLI. [Install](install.md) describes each step, the options for an unattended install, and what the installer changes on the server. + +## Create the first admin account + +Open the **First admin** link within the hour, type your name and a name for the passkey, then **Create my passkey**: your browser or your phone keeps it. Accounts have no password. You are the first admin: you register projects and invite the team. + +The link expired: `docker exec -u node spawner node dist/admin.js invite --role admin` prints a new one. + +## Install the CLI and log in + +On your machine: + +```bash +npm install -g spawner-cli +spawner login https://spawner.preview.example.com +``` + +```text +Open https://spawner.preview.example.com/device +and enter the code BCDF-GHJK (it expires in 10 minutes). +Waiting for approval... +Logged in to https://spawner.preview.example.com as Ada (admin). +``` + +The browser opens the dashboard's device page: type the code the CLI shows, **Continue**, then **Approve**. The CLI receives a personal token valid 90 days and keeps it in its credentials file (`~/.config/spawner/credentials.json` on Linux and macOS). [The CLI](cli.md#log-in) has the details, and other ways to install it. + +## Add a project + +A project is a repository with a `.spawner/` directory: `spawner.yaml`, the manifest, names the project and its URLs; a Docker Compose file describes its services. + +**Your application**: in its repository, run `spawner init`. + +```text +$ spawner init +Create AGENTS.md with the instructions for coding agents? [Y/n] +Created .spawner/spawner.yaml +Created .spawner/compose.yaml +Added the instructions for coding agents to AGENTS.md + +Next: an admin registers the project blog in the dashboard (Projects), then: + spawner up --wait +Step by step: https://spawner.run/docs/quickstart/ +``` + +- The **slug** of the project (`blog` here) comes from the repository's directory name; `--project ` chooses another. It is part of every URL: 20 characters at most. +- `compose.yaml` gets a service `app`, built from the `Dockerfile` next to `.spawner/`, and a PostgreSQL or MySQL service when `package.json`, `requirements.txt` or `pyproject.toml` names a driver (`--db postgres`, `mysql` or `none` chooses). +- `spawner.yaml` exposes the port of the Dockerfile's first `EXPOSE` (3000 without one, `--port` chooses) as the URL `web`. + +Read both files: they are a start, to adjust. The application must listen on `0.0.0.0` (all interfaces) at that port, not on `127.0.0.1`: Traefik reaches it from another container. [The manifest](manifest.md) lists every key and the rules of the compose file. In a monorepo, run `spawner init` in the application's directory. + +**Or the example**, a Node.js application with PostgreSQL, seeded with one user. Its `.spawner/` names the project `example`: + +```bash +git clone https://github.com/Flosk6/Spawner.git +cd Spawner/examples/node-postgres +``` + +## Register the project + +Spawner only runs projects an admin registered. In the dashboard: **Projects**, **New project**: + +| Field | Your application | The example | +|---|---|---| +| Name | Any name, such as `Blog` | `Example` | +| Slug | The slug `spawner init` printed: `project` in `spawner.yaml` | `example` | +| Repository | `git@github.com:acme/blog.git`, or its HTTPS URL; it ends with `.git` | `https://github.com/Flosk6/Spawner.git` | +| Default branch | `main`: deployed when an environment names no branch | `master` | +| Directory | `.`, or the directory of `.spawner/` in a monorepo | `examples/node-postgres` | +| Source repositories | Empty, unless `sources` in `spawner.yaml` names other repositories: one per line, written as there | empty | + +Leave **Allow public URLs** and **Allow environments that never sleep** off: the defaults keep every URL behind a login and let idle environments sleep. Then **Create project**. + +**A private repository** is read over SSH, with a read-only deploy key: give its SSH URL (`git@...`), then: + +1. **Git keys**, in the Admin part of the sidebar, lists the repositories of the projects: **Generate key** on yours, then **View key**. +2. Add the public key to the repository, read-only: on GitHub, Settings, Deploy keys, Add deploy key; on GitLab, Settings, Repository, Deploy keys. +3. Back on the project, **Edit**, then **Test** next to the repository: it says "Connection successful". + +`spawner up` sends the code of your directory, so the first environment works before the key is in place. Environments started from the dashboard, or from a branch with `--ref`, need it, and so does every repository that `sources` names in `spawner.yaml`, which an admin also lists under **Source repositories**. They also read `.spawner/` from the branch: commit and push it. + +## Start an environment + +In the project's directory (the example's, or yours): + +```bash +spawner up --wait +``` + +`up` checks `.spawner/` against the server's rules, sends the files git sees (uncommitted changes included, ignored files not), and follows the job through its phases: preparing, validating, building, seeding, routing. It ends with the URLs: + +```text +master (example) is ready + web https://master--example.preview.example.com +Expires in 24h. The URLs need a login; for curl or tests: spawner url master --with-token +``` + +The environment is named after the branch (`master` in a clone of Spawner, `feat-login` for `feat/login`). The first build of a project downloads its base images and installs its dependencies; the next environments reuse the layers that did not change. + +Open the URL in the browser where you are logged in to the dashboard: Spawner lets you through. The example answers `Hello from Spawner (master) at https://master--example.preview.example.com: 1 user(s)`. The environment also has a page in the dashboard (**Environments**), with its services, logs, resources and jobs. + +The environment sleeps after 2 hours without visits and wakes up at the next one; it is deleted after its lifetime (72 hours by default, 24 for the example), unless someone deploys it again or extends it. `spawner down` deletes it now. + +Something failed: the CLI prints the end of the job log, and [troubleshooting](troubleshooting.md) lists the usual causes. + +## Invite the team + +**Team**, **Invite**: who it is for, the role (**Member** manages their own environments, **Admin** everything), how long the link stays valid (24 hours by default), then **Create the link**. Spawner sends no e-mail: copy the link and send it yourself. The person opens it, chooses a name and creates a passkey, then installs the CLI and runs `spawner login` like you did. + +A team that lives in a GitHub organization can log in with GitHub instead ([operations](operations.md#github-login)). + +## Next + +- [Previews for pull requests](ci.md): an environment per pull request from GitHub Actions or GitLab CI, with its URL in a comment. +- [Coding agents](agents.md): Claude Code, Codex, Cursor and others start environments, run commands in them and read their logs. +- [The examples](examples.md): Node.js with PostgreSQL, and Laravel, Next.js and MySQL with two repositories. +- [The manifest](manifest.md): every key of `.spawner/`, and what makes environments cheap. +- [Concepts](concepts.md): projects, sources, environments, jobs, access. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..bee30b5 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,184 @@ +# Troubleshooting + +When a Spawner preview environment does not start, does not answer or does not wake up: the message you see, what it means, and what to do. + +Problems with the installation itself are in [install](install.md#when-something-goes-wrong). For everything else, three commands show most causes: + +```bash +spawner status # each service, its restarts, the last events +spawner logs --job # the log of the last job: build, start, seed +spawner logs --errors +``` + +In the dashboard, the page of the environment has the same: its services, the **Jobs** tab, the **Logs** tab. The CLI's exit codes are in [the CLI](cli.md#exit-codes). + +## The project is not registered + +```text +error: project "blog" is not registered on https://spawner.preview.example.com +an admin creates it in the dashboard (Projects), with the repository and the directory of .spawner/ +``` + +Spawner only runs the projects an admin registered, under the slug that `project` gives in `.spawner/spawner.yaml`. An admin adds it (**Projects**, **New project**), or fixes a slug that differs by a letter ([quickstart](quickstart.md#register-the-project)). + +In a monorepo, the project also says where `.spawner/` is: + +```text +error: spawner.yaml is in apps/blog, but project blog expects it in the root +an admin sets the project's root directory in the dashboard (Projects) +``` + +The project's **Directory** (Projects, the project, **Edit**) must be the directory that holds `.spawner/`, relative to the root of the repository. + +## Not logged in (exit code 3) + +```text +error: invalid, expired or revoked token +the token is invalid, expired or revoked: run spawner login https://spawner.preview.example.com +``` + +A login lasts 90 days, and the tokens of a deactivated account stop working. Without any login on the machine, the message is `not logged in`. Run `spawner login` again; `spawner whoami` shows the server, the account and the token in use. In CI, `SPAWNER_URL` and `SPAWNER_TOKEN` win over the stored login. + +## Quota or capacity (exit code 6) + +Every person may own 5 live environments by default, sleeping ones included: + +```text +error: You have 5 environments, the most a person may have (sleeping ones included) +delete one you no longer need (spawner ls, then spawner down ), or ask an admin to raise the limit +``` + +The server refuses a new environment, a start or a wake-up when it lacks the memory (or, for a new one, the disk) that an environment of the project typically uses: + +```text +error: Not enough memory on the server: 2.6 GiB available, about 2.0 GiB needed with 1.0 GiB kept free +put an environment to sleep or delete one (spawner ls), or try again later +``` + +A build also waits up to two minutes for 2 GiB of memory available, then fails with the same exit code: + +```text +master failed during preparing: Insufficient memory: 1.40GB available, 2.00GB required. Please wait for other builds to complete or free up memory. +``` + +Uploads queue too: a person may have 5 deploys of uploaded code waiting to start, since each keeps its archive on the server until it runs. + +```text +error: You have 5 deploys of uploaded code waiting to start, the most a person may have +wait for one of them to start (spawner status shows its job), then deploy again +``` + +`spawner capacity` says how many more environments of each project fit, and what limits them. To make some capacity: `spawner sleep ` or `spawner down ` on environments nobody uses, a smaller `limits.memory` in `spawner.yaml` ([the manifest](manifest.md#memory-limits)), or a bigger server. Until a project's environments have run, Spawner counts their declared memory, 2 GiB by default: on a server below 4 GiB, an admin lowers **Memory of an environment** and **Memory available before a build** in Settings ([install](install.md#before-you-start)). + +## The compose file is refused (exit code 7) + +```text +error: refused before upload: + services.app.ports: ports is not allowed (host ports are never published; declare an exposure in spawner.yaml) +fix these in .spawner/, then run spawner up again +``` + +`spawner up` checks `.spawner/` before sending anything, with the server's rules. Each line gives where (a path in the compose file, or `spawner.yaml: ...`), what, and in parentheses how to fix it. The compose policy is an allowlist: a key it does not know is refused too ([security](security.md#the-compose-policy), [the manifest](manifest.md#rules)). With `--json`, each issue also has a `code`: [the manifest](manifest.md#when-spawner-is-refused) lists them and what each one means. + +A source taken from a repository the project does not list is refused the same way, before anything is sent: an admin adds the repository under **Source repositories**, in the project's settings. + +```text + spawner.yaml: sources.front.repo: source "front" comes from git@github.com:acme/blog-front.git, which is not among the source repositories of this project (ask an admin to add it to the source repositories in the project settings) +``` + +The server checks again, during validating: an environment started from the dashboard, or by a CLI of another version, fails there, with the same list in the job log (and `spawner up --wait` exits with code 7). + +## The build fails (exit code 4) + +```text +--- end of the job log (spawner logs master --job) --- +... +master failed during building: docker exited with code 1 +... +Fix the cause, then run spawner up again. +``` + +The CLI prints the end of the job log; `spawner logs --job` gives all of it. During building, it is the output of `docker compose up --build`: usually a step of a Dockerfile, which fails the same way on your machine (`docker build .`). During preparing, it is the checkout of a repository (see the deploy key in [the quickstart](quickstart.md#register-the-project)) or the upload. + +During seeding, a step of `seed` failed: + +- `seed step "npm run seed" exited with code 1`: its output is in the job log; +- `service "app" is not running`: the service the step runs in stopped; +- `seed step timed out after 10 minutes`. + +## The environment never gets ready + +The job fails during building, and its log ends with one of: + +```text +container spn-blog--master-app-1 is unhealthy +container spn-blog--master-app-1 exited (1) +application not healthy after 5m0s +``` + +Spawner waits until every service runs and passes its healthcheck, 5 minutes at most. `spawner logs app --errors` shows why a service exits: a missing variable, a database it cannot reach. A healthcheck runs inside the container: check its command, its interval and its `retries`, so that a slow start still fits in the 5 minutes (`SPAWNER_START_TIMEOUT_SECONDS`, [configuration](configuration.md#engine)). + +## The URL answers Bad Gateway + +Traefik reaches each exposed service from another container, at `http://:`. "Bad Gateway" means nothing answers there, though the environment may say `ready`, since a healthcheck reaches `127.0.0.1` from inside: + +- the application listens on `127.0.0.1` or `localhost`, as many development servers do by default: make it listen on `0.0.0.0` (`--host 0.0.0.0` for Vite, `--hostname 0.0.0.0` for `next dev`, `-b 0.0.0.0` for Rails, `--host=0.0.0.0` for `php artisan serve` and Flask); +- it listens on another port than the exposure's `port` in `spawner.yaml`. + +A Vite development server that answers `Blocked request. This host (...) is not allowed` needs the preview domain in `server.allowedHosts`, such as `['.preview.example.com']`. [Your application behind Spawner](manifest.md#your-application-behind-spawner) lists what else an application needs. + +## A service cannot reach the server or the cloud + +The containers of an environment reach the server itself on DNS, HTTP and HTTPS only, and never the metadata service of a cloud (`169.254.169.254`, among others): the code of a branch could otherwise read the server's cloud credentials, or call a service listening on the server ([security](security.md#isolation)). A connection to another port of the server, such as a database installed on it, is refused at once. Run what the application needs (a database, a cache, a mail catcher) as a service of its compose file instead; addresses on the internet stay reachable. + +## A service restarts or runs out of memory + +`spawner status ` calls out a service that crashed three times in ten minutes, with its cause: + +```text +api failed 3 times in 10 minutes, last cause: out of memory (limit 512 MiB) +``` + +A service without `mem_limit` gets an equal share of the memory the others leave in the environment, 512 MiB at most: give a heavy one a `mem_limit` of its own, and the environment more memory with `limits.memory` if needed ([the manifest](manifest.md#memory-limits)). Otherwise `spawner logs --errors` shows why it exits. + +## The preview asks to log in + +Every URL is protected unless its exposure says `auth: none`: + +- **In a browser**, a teammate passes once logged in to the dashboard, in the same browser: the login page sends them back to the preview. If it keeps asking, the dashboard is not a host of the preview domain: Spawner warns about it at startup (`FRONTEND_URL`, [configuration](configuration.md#access)). +- **curl, Playwright, scripts and agents** get a 401, `{"error":"preview_auth_required", ...}`, without the header `X-Spawner-Preview`. `spawner url --with-token` gives it, valid one hour for that environment ([the CLI](cli.md#url)). +- **Someone without an account** opens a share link: `spawner share `, or the **Share** card on the page of the environment, which also revokes it. +- **A webhook or a public page**: `auth: none` on its exposure, once an admin turns on **Allow public URLs** for the project. + +## A sleeping environment does not wake up + +An environment sleeps after 2 hours without visits or actions; the next visit to one of its team URLs wakes it up. The waiting page says why it does not: + +- "its public URLs do not wake it up": visits to `auth: none` URLs never count, so that a bot cannot keep an environment awake. Wake it with `spawner wake ` or **Wake up** on its page; for a webhook receiver, a longer `idle`, or `idle: never` if the project allows it. +- "Not enough memory on the server": waking needs the memory of a typical environment, as a creation does (above). +- "Someone stopped it": `spawner start `. + +Agents and scripts get a 503 with `Retry-After` while it wakes up: retry. An environment whose images someone deleted by hand (`docker system prune -a`) cannot wake up: redeploy it with `spawner up`. + +## The CLI and the server versions differ + +```text +$ spawner whoami +Ada (admin) on https://spawner.preview.example.com (Spawner 2.1.0, CLI 2.0.0) +``` + +The CLI checks `.spawner/` with its own copy of the rules, so a CLI older or newer than the server can refuse what the server accepts, or accept what it refuses. Install the server's version: `npm install -g spawner-cli@2.1.0`, or the copy the server serves ([the CLI](cli.md#versions-and-updates)). + +## Certificates and DNS + +- **A URL does not resolve**: the DNS record is missing or has not spread. `dig +short anything.preview.example.com` must give the server's address. The record covers one level, `*.preview.example.com`, which is where every URL lives (`feat-login--blog.preview.example.com`). +- **The browser warns about the certificate**: Traefik has none for that host yet. `docker logs spawner-traefik` says why: usually the DNS provider's credentials, for a wildcard certificate, or Let's Encrypt's limit of about 50 new certificates a week, with one certificate per URL. Move to a wildcard certificate by running the installer again with `--dns-provider` ([install](install.md#dns-providers)). +- **With Cloudflare**, keep the record "DNS only": Traefik serves the certificates, not Cloudflare's proxy. + +## The disk is full + +```text +master failed during preparing: Insufficient disk: 8.4GB free, 10.0GB required +``` + +A build needs 10 GiB free by default, and a new environment is refused when it would leave less. The System page shows what takes the disk (images, build cache, volumes, sources, logs) and raises an alert before it is full. Delete the environments nobody uses (the **Environments** page lists them all), shorten their **Lifetime** in Settings, remove what the **Cleanup** card lists, or grow the disk. Never run `docker system prune` on a Spawner server: it deletes sleeping environments ([operations](operations.md#disk)). From 903453eede31957b4a6102f7df3f28c75ce92973 Mon Sep 17 00:00:00 2001 From: Florian-mfr Date: Sat, 10 Oct 2026 13:17:50 +0200 Subject: [PATCH 6/9] docs: what the review of the site found false or missing - install: 4 GiB of memory at least, not 2; uninstall and the local install download the installer from the release, as upgrades do, and the local install needs Linux; --purge also deletes /opt/spawner, its backups and the master secret; the ports, the outbound connections and no telemetry; answers given through the environment. - operations: a restore on a new server that the installer accepts; GitHub login set up step by step; admin.js invite links last 24 hours. - manifest: every key of spawner.yaml with its type, default, rules and refusal; the codes of a refused .spawner/; what an application needs behind Spawner; the Dockerfile example builds again; services without mem_limit share what is left; public URLs never wake an environment. - security: the compose policy lists what it allows, refuses and sets, with its limits (build platform is refused); accounts also come from GitHub login for members of its organization. - concepts: what Spawner is, the words readers search with, and what Spawner does not do, moved from the README. - cli and agents: every command and option, the environment variables and files, the error codes, the nine MCP tools; VS Code, Gemini CLI and Devin Desktop; Codex's tool timeout; permission rules for Claude Code. - "capacity" instead of "room", named table headers, and the dashboard's names since 2.1.0. --- docs/agents.md | 156 ++++++++-- docs/architecture.md | 78 +++-- docs/cli.md | 428 ++++++++++++++++++++------ docs/concepts.md | 88 ++++-- docs/configuration.md | 52 ++-- docs/install.md | 155 ++++++++-- docs/manifest.md | 273 ++++++++++++---- docs/operations.md | 94 ++++-- docs/security.md | 95 ++++-- examples/laravel-next-mysql/README.md | 26 +- examples/node-postgres/README.md | 44 ++- 11 files changed, 1130 insertions(+), 359 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index c3de785..62a9ee3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,69 +1,149 @@ -# Coding agents +# Preview environments for coding agents -A coding agent that changes an application needs to run it: open the page it changed, call the API, look at the database, read the error. Spawner gives each agent a real environment of its branch, from its own worktree, with uncommitted changes, and the commands to work with it. Agents use the same `spawner` CLI as people, or its MCP server. +Claude Code, Codex, Cursor and other coding agents can run the app they change in a Spawner preview environment, with its own URLs, database and logs. Spawner builds it on your server from the agent's worktree, uncommitted changes included, and the agent drives it with the `spawner` CLI or through its MCP (Model Context Protocol) server: it opens the page it changed, calls the API, looks at the database, reads the error. + +The agent's machine needs no Docker and runs none of the app: it needs Node.js, the CLI and a token. Each branch gets its own copy of the whole Docker Compose stack, so agents on different branches never compete for ports or share a database, and teammates open the same URLs to check the result. ## What an agent does ```bash -spawner up --wait --json # the environment of the current branch, from this worktree -spawner url --with-token --json # the URL and the header that opens it -curl -H "X-Spawner-Preview: $TOKEN" https://feat-login--blog.preview.example.com/api/users +# the environment of the current branch, from this worktree +spawner up --wait --json + +# the preview token that opens its protected URLs, valid one hour +token=$(spawner url --with-token --json | jq -r .header.value) +curl -H "X-Spawner-Preview: $token" \ + https://feat-login--blog.preview.example.com/api/users + spawner exec feat-login db -- psql -U app -c "select count(*) from users" -spawner logs feat-login api --errors # only the error lines, a stack trace kept whole -spawner status feat-login --json # restarts, out-of-memory kills, failing healthchecks -spawner share feat-login # a link for a reviewer without an account + +# only the error lines, a stack trace kept whole +spawner logs feat-login api --errors + +# restarts, out-of-memory kills, failing healthchecks +spawner status feat-login --json + +# a link for a reviewer without an account +spawner share feat-login + spawner down feat-login ``` -Every command prints JSON with `--json`, and exits with a code the agent can act on: 4 when the environment failed (the end of the build log is on stderr), 6 when the quota or the server's capacity is reached, 7 when `.spawner/` was refused (with the list of issues). See [the CLI](cli.md) for the outputs. +Commands print JSON with `--json`, and exit with a code the agent can act on ([all of them](cli.md#exit-codes)): + +| Code | What the agent does | +|---|---| +| 4 | The environment failed: read the end of the build log on stderr (all of it: `spawner logs --job`), fix the cause, run `spawner up` again | +| 6 | The quota or the server's capacity is reached: `spawner ls --all --mine --json` lists the environments of the token's owner; delete those whose work is done (`spawner down `), or ask a person. `spawner capacity` says how many more fit, and what limits them | +| 7 | `.spawner/` was refused: fix each issue it lists, at its path | +| 5 | The job keeps running on the server: follow it with `spawner status --json` | +| 3 | Not logged in, or not allowed: ask a person. A login needs someone to approve it in the dashboard | ## Setting an agent up -1. **Install the CLI** on the machine the agent runs on (Node.js 20 or later): `npm install -g spawner-cli`, or the copy every server serves at `/api/v1/cli/spawner`. -2. **Log in**: `spawner login https://spawner.preview.example.com`, then type the code it shows in the dashboard and approve the login. The CLI stores a personal token named after the machine. An agent running elsewhere (a container, CI, a cloud agent) gets a token of its own instead, given as environment variables: +1. **Install the CLI** on the machine the agent runs on (Node.js 20 or later): `npm install -g spawner-cli`, or the copy every server serves at `/api/v1/cli/spawner` ([install](cli.md#install)). +2. **Log in**: `spawner login https://spawner.preview.example.com`, then type the code it shows in the dashboard and approve the login. The CLI stores a personal API token named after the machine, and the agents of that machine use it. An agent running elsewhere (a container, a cloud agent) gets a token of its own instead, given as environment variables: ```bash - spawner token create --name claude --project blog --expires 30d # shown once - export SPAWNER_URL=https://spawner.preview.example.com SPAWNER_TOKEN=spn_... + # the token is shown once + spawner token create --name claude --project blog --expires 30d + export SPAWNER_URL=https://spawner.preview.example.com + export SPAWNER_TOKEN=spn_... ``` - Environments it creates show its name ("Ada via claude"). A token restricted to a project cannot touch the others; without `envs:exec` (`--scopes envs:read,envs:write,preview`) it cannot run commands. -3. **Tell it how**: when it creates `.spawner/`, `spawner init` offers to add a short section to `CLAUDE.md` or `AGENTS.md` that tells the agent when and how to use Spawner. Commit it with the rest: every agent working on the repository reads it. For a project set up by hand, copy the section from [the CLI docs](cli.md#instructions-for-coding-agents). -4. **Optionally, the MCP server**, for agents that prefer tools to commands: `spawner mcp`, configured as below. It offers the same operations (`spawner_up`, `spawner_status`, `spawner_logs`, `spawner_exec`, `spawner_url`, `spawner_share`, `spawner_down`...), with the same credentials. + Environments it creates show its name ("Ada via claude"). A token restricted to a project cannot touch the others; without `envs:exec` (`--scopes envs:read,envs:write,preview`) it cannot run commands. A token made with `spawner token create` expires with your login at the latest: create one that must last longer on the dashboard's **Account and tokens** page. +3. **Tell it how**: when it creates `.spawner/`, `spawner init` offers to add a short section to `CLAUDE.md` or `AGENTS.md` that tells the agent when and how to use Spawner ([the section](cli.md#instructions-for-coding-agents)). Commit it with the rest: every agent working on the repository reads it. For a project set up already, copy the section by hand. +4. **Optionally, the MCP server**, for agents that prefer tools to commands: `spawner mcp`, configured as below, with the same credentials as the CLI. It has nine tools for the main operations: `spawner_up`, `spawner_status`, `spawner_list`, `spawner_logs`, `spawner_exec`, `spawner_stats`, `spawner_url`, `spawner_share` and `spawner_down` ([their parameters](cli.md#mcp-server)). For the rest, such as `capacity`, `wake` or `extend`, the agent runs the CLI. + +Claude Code reads `CLAUDE.md`; Codex, Cursor, Copilot in VS Code and Devin Desktop read `AGENTS.md`, and Gemini CLI does once told to (below). A project that keeps its instructions in `AGENTS.md` gives them to Claude Code with a `CLAUDE.md` holding the line `@AGENTS.md`, as Spawner's own repository does. ### Claude Code -The CLI works as is: allow it in `.claude/settings.json` so the agent does not ask each time. +The CLI works as is. For the MCP server, run `claude mcp add spawner -- spawner mcp`, or share it with the team in `.mcp.json` at the root of the project, which VS Code reads too: ```json -{ "permissions": { "allow": ["Bash(spawner *)"] } } +{ + "mcpServers": { + "spawner": { "type": "stdio", "command": "spawner", "args": ["mcp"] } + } +} ``` -For the MCP server, `claude mcp add spawner -- spawner mcp`, or in `.mcp.json` at the root of the project: +Claude Code asks before it first uses the servers of a project's `.mcp.json`. Permission rules in `.claude/settings.json` let the agent work without asking each time, and still ask before deleting an environment or handling tokens: ```json -{ "mcpServers": { "spawner": { "command": "spawner", "args": ["mcp"] } } } +{ + "permissions": { + "allow": ["Bash(spawner *)", "mcp__spawner__*"], + "ask": [ + "Bash(spawner down *)", + "Bash(spawner token *)", + "mcp__spawner__spawner_down" + ] + } +} ``` -On Windows, an agent started in a worktree looks for `spawner` (and the `node` it runs) in that worktree before the `PATH`, where a branch could commit a `spawner.cmd`: give the MCP client absolute paths (`"command": "C:\\Program Files\\nodejs\\node.exe"`, `"args": ["\\node_modules\\spawner-cli\\spawner.cjs", "mcp"]`), and set `NoDefaultCurrentDirectoryInExePath=1` in the user's environment. The CLI itself runs `git` and the browser by their absolute paths. +An `ask` rule wins over an `allow` rule. Remove the two `down` rules to let the agent delete its environments once the work is validated, as its instructions say; add `mcp__spawner__spawner_exec` to confirm each command it runs over MCP. -Instructions go to `CLAUDE.md`. +These rules are a convenience, not a security boundary: a Bash rule matches the command as the agent writes it, so `spawner --json down feat-login` escapes the `ask` rule above. What bounds an agent is its token: its scopes, its project and its expiry ([below](#what-an-agent-cannot-do)). ### Codex -Codex reads `AGENTS.md`, where `spawner init` puts the instructions. The MCP server goes to `~/.codex/config.toml`: +Codex reads `AGENTS.md`. `codex mcp add spawner -- spawner mcp` adds the MCP server to `~/.codex/config.toml`, where its tool timeout needs raising: `spawner_up` waits up to 20 minutes for a build (`timeout_sec`, 1200 seconds by default), and Codex gives up on a tool call after 60 seconds unless told otherwise. ```toml [mcp_servers.spawner] command = "spawner" args = ["mcp"] +tool_timeout_sec = 1500 ``` -In a sandbox without network access, Codex cannot reach the server: allow the network for the commands that call `spawner`. +Codex passes MCP servers only a few environment variables, `HOME` and `PATH` among them. An agent that uses `SPAWNER_URL` and `SPAWNER_TOKEN` rather than a login needs `env_vars = ["SPAWNER_URL", "SPAWNER_TOKEN"]` in that table, plus `SPAWNER_CONFIG_DIR` or `XDG_CONFIG_HOME` if you set them. + +For the CLI, mind the sandbox: in Codex's default sandbox mode, `workspace-write`, the commands it runs have no network, so `spawner` cannot reach the server. Allow it in the same file: + +```toml +[sandbox_workspace_write] +network_access = true +``` ### Cursor -Cursor reads `AGENTS.md` too. The MCP server goes to `.cursor/mcp.json`: +Cursor reads `AGENTS.md`. The MCP server goes to `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project: + +```json +{ + "mcpServers": { + "spawner": { "type": "stdio", "command": "spawner", "args": ["mcp"] } + } +} +``` + +Cursor asks before it uses an MCP tool, unless you allow it. + +### VS Code with GitHub Copilot + +Copilot reads `AGENTS.md` (the `chat.useAgentsMdFile` setting turns it on or off). VS Code reads the MCP servers of `.mcp.json` at the root of the project: the file of the [Claude Code](#claude-code) section serves both. VS Code's own `.vscode/mcp.json` works too, with the same server under a top-level `servers` object instead of `mcpServers`. + +### Gemini CLI + +Gemini CLI reads `GEMINI.md` unless told otherwise, and stops waiting for an MCP tool after 10 minutes, half of what `spawner_up` may wait. In `.gemini/settings.json`: + +```json +{ + "context": { "fileName": ["AGENTS.md", "GEMINI.md"] }, + "mcpServers": { + "spawner": { "command": "spawner", "args": ["mcp"], "timeout": 1500000 } + } +} +``` + +`timeout` is in milliseconds. Gemini CLI keeps the variables whose names look like secrets, such as `SPAWNER_TOKEN`, from the servers it starts: an agent that uses one names it in the server's `env`, as `"env": { "SPAWNER_URL": "$SPAWNER_URL", "SPAWNER_TOKEN": "$SPAWNER_TOKEN" }`. + +### Devin Desktop (formerly Windsurf) + +Devin Desktop reads `AGENTS.md`. Its MCP servers are set for the user, in `~/.config/devin/mcp_config.json` (`%APPDATA%\devin\mcp_config.json` on Windows): ```json { "mcpServers": { "spawner": { "command": "spawner", "args": ["mcp"] } } } @@ -71,14 +151,34 @@ Cursor reads `AGENTS.md` too. The MCP server goes to `.cursor/mcp.json`: ### Other agents -Any agent that runs shell commands can use the CLI; any MCP client can use `spawner mcp` over stdio. The working directory decides the project and the branch, so start the agent in its worktree, or pass `-C ` (CLI) or `path` (MCP). +Any agent that runs shell commands can use the CLI; any MCP client can start `spawner mcp` over stdio. The working directory decides the project and the branch, so start the agent in its worktree, or pass `-C ` to the CLI. Over MCP, tools work in the first root the client shares, else in the directory the server started in: a client that starts servers outside the project, or shares no root, gives each tool the worktree as `path`. + +### Windows + +On Windows, an agent started in a worktree looks for `spawner`, and the `node` it runs, in that worktree before the `PATH`, where a branch could commit a `spawner.cmd`. Give the MCP client absolute paths instead (`npm root -g` prints the directory holding `spawner-cli`): + +```json +{ + "mcpServers": { + "spawner": { + "command": "C:\\Program Files\\nodejs\\node.exe", + "args": [ + "C:\\Users\\ada\\AppData\\Roaming\\npm\\node_modules\\spawner-cli\\spawner.cjs", + "mcp" + ] + } + } +} +``` + +Set `NoDefaultCurrentDirectoryInExePath=1` in the user's environment as well. The CLI itself runs `git` and the browser by their absolute paths. ## Several agents at once Give each agent its own git worktree and branch: each gets its own environment, named after the branch, with its own database. Two agents that deploy at the same time never share a working copy on the server, and their builds queue when the server is busy. When the front lives in another repository, `spawner up --source front=../front-feat-login` sends that worktree too; an admin lists that repository among the project's source repositories first. -The quota (5 environments per person by default) counts the environments of all the agents of a person: `spawner ls --mine` lists them, and the instructions ask agents to delete their environment once the work is validated. Environments nobody uses go to sleep, then expire. +The quota (5 environments per person by default) counts the environments of all the agents of a person, sleeping ones included: `spawner ls --all --mine` lists them, and the instructions ask agents to delete their environment once the work is validated. Environments nobody uses go to sleep, then expire. ## What an agent cannot do -An agent acts with its token's rights, as its owner would: it can create, change and delete its owner's environments, and run commands in them. It cannot reach the server itself: compose files are checked against a [policy](security.md#the-compose-policy) (no host mounts, no privileged containers, no host network), and environments only see their own network. Give agents tokens restricted to a project and with an expiry, and revoke them from the account page when they are no longer needed. +An agent acts with its token's rights, as its owner would: it can create, change and delete its owner's environments, and run commands in them. It cannot reach the server itself: compose files are checked against a [policy](security.md#the-compose-policy) (no host mounts, no privileged containers, no host network), and an environment does not reach the networks of the others. Give agents tokens restricted to a project and with an expiry, and revoke them on the **Account and tokens** page when they are no longer needed. diff --git a/docs/architecture.md b/docs/architecture.md index f624816..e57ba1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,24 +1,29 @@ # Architecture -How Spawner is built, for those who read or change its code. [Concepts](concepts.md) describes what it does from the user's side, and [security](security.md) what it protects and why; this page says where each part lives. +How Spawner is built, for those who read or change its code: the pieces, the API, the engine, the dashboard, the CLI and the releases. + +[Concepts](concepts.md) describes what it does from the user's side, and [security](security.md) what it protects and why; this page says where each part lives. ## The pieces ```text - ports 80 and 443 - | - spawner-traefik file provider, no Docker socket - | | - spawner-core network one network per environment - | | - spawner web, api, db of each environment - API, dashboard, CLI bundle (a Compose project: spn---) - Docker socket, data directory - | - spawner-postgres + ports 80 and 443 + | + spawner-traefik + file provider, no Docker socket + | | + spawner-core network one network per environment + | | + spawner web, api, db of each environment, + API, dashboard, a Compose project: + CLI bundle; spn--- + Docker socket, + data directory + | + spawner-postgres ``` -- **One image** (the root `Dockerfile`) runs the API and serves the dashboard (`WEB_DIST_PATH=/app/web`) and the CLI bundle (`SPAWNER_CLI_PATH=/app/cli/spawner`) on the same origin. It carries git, ssh, the Docker CLI with the compose plugin, and the installer (`/app/install.sh`) for updates. Its entrypoint gives the `node` user access to the Docker socket and the data directory, applies the Prisma migrations, then drops root. +- **One image** (the root `Dockerfile`) runs the API and serves the dashboard (`WEB_DIST_PATH=/app/web`) and the CLI bundle (`SPAWNER_CLI_PATH=/app/cli/spawner`) on the same origin. It carries git, ssh, the Docker CLI with the compose plugin, and the installer (`/app/install.sh`) for updates. Its entrypoint, started as root, gives the `node` user access to the Docker socket and the data directory, then drops root: the Prisma migrations and the API run as `node`. - **Postgres** holds Spawner's state, including the job queue and the sessions. - **Traefik** v3 reads its routes from files Spawner writes into `/traefik/` (file provider, watched). It has no Docker socket: Spawner attaches it to each environment's network. - **The firewall** (`spawner-firewall`, the same image on the host's network with `NET_ADMIN` only) loads `firewall.nft` every minute: the containers of Docker's bridge networks reach neither the metadata services of the clouds nor the host's services but DNS, HTTP and HTTPS. `install.sh` also has Docker load the file before it starts (a drop-in of `docker.service`). The development stack starts it with `--profile firewall` only, since it changes the host's rules. @@ -83,7 +88,7 @@ A pnpm workspace built with Turborepo. `build` depends on the build of the depen ### Routes -Everything is under `/api/v1`, in the modules of `apps/api/src/modules/`. The shapes the routes answer are in `packages/types`, shared by the dashboard and the CLI. +Everything is under `/api/v1`, in the modules of `apps/api/src/modules/`, but one redirect: `/api/auth/github/callback` (`LegacyGithubCallbackController` in `auth/auth.controller.ts`), the GitHub callback of earlier versions, which OAuth apps created for them still call, forwards to `/api/v1/auth/github/callback`. The shapes the routes answer are in `packages/types`, shared by the dashboard and the CLI. | Prefix | Where | |---|---| @@ -160,7 +165,7 @@ An environment is ready once Traefik serves its hosts. - `lifecycle.service.ts`, every minute: an awake environment (`ready` or `degraded`) idle for longer than its idle time gets a `sleep` job (its status is `sleeping` from the start, its URLs lead to the waiting page, then its containers stop); expired environments are deleted, by the actor "Spawner (expired)". A visit to a team URL of a sleeping environment queues a `wake` job when the server has the memory; a wake-up recreates the containers from their images if they are gone. - `reconcile.service.ts`, at startup and every minute: an awake environment with a service exited, restarting or unhealthy is `degraded` with the reason, and `ready` again once all run; one whose containers are gone, or left in a transitional status without a job, fails. - `cleanup.service.ts`: what deleted environments left (containers, volumes, networks, images, routes, directories), images of previous builds that no container runs, and uploads older than a day go automatically. Resources labelled for environments this installation does not know, and unused git mirrors, wait for an admin (`POST /api/v1/system/cleanup`). Spawner never runs a global prune. -- Creating an environment needs the person's quota (409, code `quota`) and room for a typical environment of the project (503, code `capacity`); starting and waking need the memory. +- Creating an environment needs the person's quota (409, code `quota`) and the capacity for a typical environment of the project (503, code `capacity`); starting and waking need the memory. ### Supervision @@ -185,19 +190,24 @@ An environment is ready once Traefik serves its hosts. ```text / - mirrors// bare partial mirror of a repository (--filter=blob:none), shared - envs//src/_primary/ the project repository: git worktree or extracted upload - envs//src// the other sources + mirrors/.git/ bare partial mirror of a repository, shared + envs//src/_primary/ the project repository: git worktree or upload + envs//src// the other sources envs//compose.rendered.yaml - traefik/.yaml routes of an environment; traefik/_spawner.yaml for the dashboard - jobs/.log job logs (the last 5 jobs of each environment) - archives//.jsonl.gz last 1 MiB of each service's output, after a delete (7 days) - terminals/.log terminal recordings (30 days) - uploads/ archives waiting for their job - keys/ deploy keys and known_hosts - secret.key the master secret, when SPAWNER_SECRET is not set + traefik/.yaml routes of an environment + traefik/_spawner.yaml the dashboard and the preview middlewares + jobs/.log job log; the last 5 jobs of each environment + archives//.jsonl.gz + a deleted environment's last logs (7 days) + terminals/.log terminal recordings (30 days) + uploads/ archives waiting for their job + home/ HOME and working directory of git and compose + keys/ deploy keys and known_hosts + secret.key master secret, when SPAWNER_SECRET is unset ``` +Mirrors are partial clones (`--filter=blob:none`); an archive keeps the last 1 MiB of each service's output, compressed. + It is mounted at the same path inside the Spawner container: the compose files Spawner renders use these paths. ## The dashboard (apps/web) @@ -220,18 +230,20 @@ The look comes from one set of tokens, so the light and dark themes cannot drift The shell: `AppSidebar.vue` (navigation, the server's domain and version, the update an admin can install, the theme, the account), `AppTopbar.vue` (breadcrumbs: a route's `meta.crumbs`, or what a page sets with `setBreadcrumbs` once its subject is loaded; the update too where the sidebar is a drawer, both from `useAvailableUpdate`), and `CommandPalette.vue` (Ctrl+K or Cmd+K: environments, projects, pages and a few actions). Sign-in pages and the CLI approval have `meta.layout: 'focus'` and stand alone; the approval names the account the CLI will act as. Shared pieces: `ActionMenu.vue` (a PrimeVue popup menu with Lucide icons), `SegmentedControl.vue`, `EnvironmentStatus.vue`, `SourceLabel.vue`, `UserAvatar.vue`, `Logo.vue` (the mark, drawn in SVG, and the wordmark). Confirmations go through `useNotification`'s `confirmAction`, rendered by the dialog in `App.vue`. -| View | | +Each page, with its name in the sidebar in parentheses: + +| View | What it shows | |---|---| -| `Home.vue` | Live environments by status, the reader's quota, free memory, how many more environments fit, recent environments, projects, the CLI | -| `ProjectList.vue`, `ProjectDetail.vue` | Projects (created and edited by admins in `ProjectDialog.vue`); what a project uses, what one environment costs, how many more fit, its variables | -| `EnvironmentList.vue` | A table of the environments, filtered by name, branch or owner, by owner, project and status, and those deleted in the last 7 days; created in `EnvironmentDialog.vue` (also from the palette, through `?new=1`), which reads `spawner.yaml` to offer a branch per source and shows the URL to come | +| `Home.vue` (Overview) | Live environments by status, the reader's quota, free memory, how many more environments fit, recent environments, projects, the CLI | +| `ProjectList.vue`, `ProjectDetail.vue` (Projects) | Projects (created and edited by admins in `ProjectDialog.vue`); what a project uses, what one environment costs, how many more fit, its variables | +| `EnvironmentList.vue` (Environments) | A table of the environments, filtered by name, branch or owner, by owner, project and status, and those deleted in the last 7 days; created in `EnvironmentDialog.vue` (also from the palette, through `?new=1`), which reads `spawner.yaml` to offer a branch per source and shows the URL to come | | `EnvironmentDetail.vue` | Banners for crash loops, sleep, failures and running jobs above the tabs: overview (services with their memory, URLs, sources, lifecycle, share links, disk), logs (`LogViewer.vue`), resources (`ResourcePanel.vue`, `UsageChart.vue`), timeline (`TimelinePanel.vue`), jobs (`JobsPanel.vue`, `JobLog.vue`, with the phase a deploy is at or failed in), terminal; a deleted environment opens read-only, with its archived logs | | `Login.vue`, `InviteAccept.vue` | Passkey login, GitHub when configured; an invitation link: a name, then a passkey | | `DeviceApproval.vue` | Approves a CLI login: the user types the code, which the page never reads from the URL | -| `Account.vue` | Name, passkeys, linked GitHub, installing the CLI and the MCP server, API tokens | -| `Team.vue` | Members, roles and reactivations (each confirmed first), deactivation, links for a new passkey, invitations | -| `Settings.vue`, `Audit.vue`, `GitSettings.vue` | Limits (`LimitsSettings.vue`), GitHub login; the audit trail and terminal recordings; deploy keys | -| `SystemOverview.vue` | Alerts, the host now and over time, the disk breakdown, capacity per project, every container, updates, cleanup | +| `Account.vue` (Account and tokens, in the user menu) | Name, passkeys, linked GitHub, installing the CLI and the MCP server, API tokens | +| `Team.vue` (Team) | Members, roles and reactivations (each confirmed first), deactivation, links for a new passkey, invitations | +| `Settings.vue`, `Audit.vue`, `GitSettings.vue` (Settings, Audit, Git keys) | Limits (`LimitsSettings.vue`), GitHub login; the audit trail and terminal recordings; deploy keys | +| `SystemOverview.vue` (System) | Alerts, the host now and over time, the disk breakdown, capacity per project, every container, updates, cleanup | ## The CLI (apps/cli) diff --git a/docs/cli.md b/docs/cli.md index 2f30a6b..3073e0a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,8 @@ -# The spawner CLI and MCP server +# The Spawner CLI and MCP server -`spawner` creates, updates and deletes preview environments from a worktree on your machine, runs commands in them and reads their logs. It is built for coding agents as much as for people: every command has a `--json` output and stable exit codes, and `spawner mcp` exposes the same operations as MCP tools. +The Spawner CLI, `spawner`, creates, updates and deletes preview environments from a worktree on your machine, runs commands in them and reads their logs. It is built for coding agents as much as for people: every command but `shell` and `mcp` prints JSON with `--json`, exit codes are stable, and `spawner mcp` is an MCP (Model Context Protocol) server with nine tools for the main operations. + +A **worktree**, on this page, is the directory `spawner` runs in: a clone, a `git worktree` of one, or any directory holding the project. [Coding agents](agents.md) shows how to set agents up, and [previews for pull requests](ci.md) how to run the CLI from CI. ## Install @@ -14,11 +16,24 @@ Every Spawner server also serves the CLI of its own version, for machines withou ```bash mkdir -p ~/.local/bin -curl -fsSL https://spawner.preview.example.com/api/v1/cli/spawner -o ~/.local/bin/spawner +curl -fsSL https://spawner.preview.example.com/api/v1/cli/spawner \ + -o ~/.local/bin/spawner chmod +x ~/.local/bin/spawner ``` -Each GitHub release also has the bundle as `spawner`, which `gh attestation verify spawner -R Flosk6/Spawner` checks. From a clone of this repository: `pnpm install && pnpm build`, then link `apps/cli/dist/spawner.cjs` into your `PATH`. `spawner whoami` shows the version of the CLI and of the server: keep them on the same version. +Each GitHub release also has the bundle as `spawner`, which `gh attestation verify spawner -R Flosk6/Spawner` checks. From a clone of the Spawner repository: `pnpm install && pnpm build`, then link `apps/cli/dist/spawner.cjs` into your `PATH`. + +### Versions and updates + +Keep the CLI on the version of the server. Before it sends anything, the CLI checks `spawner.yaml` and the compose file with its own copy of the server's rules: a CLI of another version can refuse a file the server would accept, or send one the server then refuses. Nothing warns when the versions differ: `spawner whoami` prints both, and `spawner --version` the CLI's. + +To update, install the server's version (`spawner whoami` shows it): + +```bash +npm install -g spawner-cli@2.1.0 +``` + +Or download the server's copy again, as above: it always has the server's version. ## Log in @@ -26,111 +41,319 @@ Each GitHub release also has the bundle as `spawner`, which `gh attestation veri spawner login https://spawner.preview.example.com ``` -The CLI shows a code and opens the dashboard's `/device` page, where you type that code and approve the login (device flow, as `gh auth login` does). The page never takes the code from a link: only enter a code from a login you started yourself. The CLI then receives a personal token named after the machine (`--name` to choose), valid 90 days, with the scopes `envs:read`, `envs:write`, `envs:exec` and `preview`, and stores it in `~/.config/spawner/credentials.json` (mode 0600; `$XDG_CONFIG_HOME` is honored). +The CLI shows a code and opens the dashboard's `/device` page, where you type that code and approve the login (device flow, as `gh auth login` does). The code expires in 10 minutes. The page never takes the code from a link: only enter a code from a login you started yourself. + +The CLI then receives a personal API token named after the machine, valid 90 days, with the scopes `envs:read`, `envs:write`, `envs:exec` and `preview`, never `admin`, even for an admin. It stores the token in its [credentials file](#environment-variables-and-files). + +| Option | Meaning | +|---|---| +| `[url]` | The dashboard's URL; by default `SPAWNER_URL`, else the server of the last login | +| `--name ` | Name of the token, shown on your environments ("Ada via ada-mbp"); the machine's name by default | +| `--no-browser` | Print the page's address without opening a browser | -- `spawner whoami` shows the server, the user and the token in use. -- `spawner logout` revokes the token and forgets it. +- `spawner whoami` shows the server, the user, the token in use and its scopes, and the versions of the server and the CLI. +- `spawner logout` revokes the token of the current server and forgets it. - Without a login, `SPAWNER_URL` and `SPAWNER_TOKEN` give the server and a token: for CI jobs and agents in containers. They win over the stored login. -- For an agent of its own, create a dedicated token, restricted to a project if you like: `spawner token create --name claude --project blog --expires 30d`. Environments it creates show "Ada via claude". A token created this way depends on your login: it expires with it at the latest, and `spawner logout` revokes it too. Create the tokens that must outlive it from the dashboard (Account). +- An agent of its own gets a [token](#token) of its own. + +## Environment variables and files + +| Variable | Meaning | +|---|---| +| `SPAWNER_URL` | The server, instead of the last one logged in to. Without `SPAWNER_TOKEN`, it picks the stored login of that server | +| `SPAWNER_TOKEN` | A personal API token (`spn_...`), used instead of the stored login: for CI jobs and agents in containers | +| `SPAWNER_PROJECT` | The project of the commands that name an environment, when `-p` is not given ([which project](#which-project-which-environment)) | +| `SPAWNER_CONFIG_DIR` | The directory of the credentials file | +| `XDG_CONFIG_HOME` | When set, the credentials file goes to `$XDG_CONFIG_HOME/spawner` | +| `NO_COLOR` | No colors. `FORCE_COLOR` (other than `0`) forces them, and `TERM=dumb` turns them off. Otherwise colors appear on a terminal only, and never on stdout with `--json` | + +The credentials file, `credentials.json`, lives in `SPAWNER_CONFIG_DIR` when it is set, else in `$XDG_CONFIG_HOME/spawner`, else in: + +| System | Directory | +|---|---| +| Linux, macOS | `~/.config/spawner` | +| Windows | `%APPDATA%\spawner` | + +On Linux and macOS, only its owner can read it (mode 0600, in a directory of mode 0700). + +It holds one login per server. `spawner login` to a second server adds it and makes it the current one; `SPAWNER_URL` picks another one for a command; `spawner logout` forgets the current one and switches to another stored server. ## Which project, which environment -Commands run in a project directory: the one holding `.spawner/spawner.yaml`, or any directory below it. The project is the one the manifest names; `-p, --project ` (or `SPAWNER_PROJECT`) overrides it, so that commands work from anywhere. +Commands run in a project directory: the one holding `.spawner/spawner.yaml`, or any directory below it. The project is the one the manifest names. + +The commands that act on one environment (`status`, `logs`, `exec`, `shell`, `stats`, `url`, `share`, `stop`, `start`, `sleep`, `wake`, `down`, `extend`) take `-p, --project `, or read `SPAWNER_PROJECT`, and both win over the manifest. With an environment name and one of them, they work from any directory. The other commands differ: + +- `up` has no `-p`: it deploys the project of the manifest it finds. +- `ls -p` lists the environments of one project; `ls` does not read `SPAWNER_PROJECT`. +- `capacity -p` keeps one project, `init -p` names the project of a new manifest, and `token create -p` restricts a token to a project. -The environment is named after the current git branch: `feat/login` gives `feat-login`, lowercased, and cut with a short hash suffix past 29 characters. Give the name explicitly when HEAD is detached, outside git, or for another environment: `spawner status feat-login`. +The environment is named after the current git branch: `feat/login` gives `feat-login`, lowercased, and cut with a short hash suffix past 29 characters. Give the name explicitly when HEAD is detached (in CI), outside git, or for another environment: `spawner status feat-login`. A name has lowercase letters, digits and single dashes, 29 characters at most. -`-C ` runs a command as if started in another directory. +`-C, --dir ` runs a command as if started in another directory. ## Commands +Global options go before or after the command: `--json` ([JSON output](#json-output)), `-q, --quiet` (no progress messages), `-C, --dir `, `-v, --version` and `-h, --help`. `spawner --help` lists the options of a command. + | Command | What it does | |---|---| -| `login [url]`, `logout`, `whoami` | Authentication | -| `init` | Creates `.spawner/spawner.yaml` and `.spawner/compose.yaml`, and offers to add the instructions for coding agents to `CLAUDE.md` or `AGENTS.md` | -| `up [env]` | Creates or updates the environment of the worktree. `--wait` waits until it is ready | -| `status [env]` | Status, URLs, sources, expiry, last job, each service (state, health, restarts, out-of-memory kills), and the last events of its timeline | -| `ls` | Environments of the current project (all projects outside one, or with `--all`); `--mine` | -| `logs [env] [service]` | Output of the services, merged; or the log of the last job with `--job` | -| `exec -- ` | Runs a command and exits with its exit code | -| `shell ` | Interactive terminal (for people; agents use `exec`) | -| `stats [env]` | CPU, memory, disk, restarts and out-of-memory kills of each service, right now | -| `capacity` | How many more environments of each project fit on the server | -| `url [env] [exposure]` | Prints a URL; `--with-token` adds the header that opens the protected URL | -| `share [env]` | A link that opens the environment without an account, `--ttl 24h` by default | -| `stop`, `start`, `down [env]` | Stops, starts, deletes. They wait for the job unless `--no-wait` | -| `sleep`, `wake [env]` | Puts the environment to sleep now (its data stays, the next visit wakes it up), wakes it up | -| `extend [env] --ttl 3d` | Postpones the expiry: the environment now expires in 3 days | -| `token create`, `token ls`, `token revoke` | Personal tokens | -| `mcp` | The MCP server, on stdio | - -Global options: `--json`, `-q, --quiet` (no progress messages), `-C `. `spawner --help` lists the options of each command. +| [`login [url]`, `logout`, `whoami`](#log-in) | Authentication | +| [`init`](#init) | Creates `.spawner/`, and offers to add the instructions for coding agents | +| [`up [env]`](#up) | Creates or updates the environment of the worktree | +| [`status [env]`](#status) | Status, URLs, services and last events of an environment | +| [`ls`](#ls) | Lists environments | +| [`logs [env] [service]`](#logs) | Output of the services, or the log of a job | +| [`exec -- `](#exec) | Runs a command in a service and exits with its exit code | +| [`shell `](#shell) | Interactive terminal, for people | +| [`stats [env]`](#stats) | CPU, memory, disk, restarts and out-of-memory kills, right now | +| [`capacity`](#capacity) | How many more environments of each project fit on the server | +| [`url [env] [exposure]`](#url) | A URL, and the header that opens it | +| [`share [env]`](#share) | A link that opens the environment without an account | +| [`stop`, `start`, `sleep`, `wake`, `down`](#stop-start-sleep-wake-down) | Stops, starts, puts to sleep, wakes up, deletes | +| [`extend [env]`](#extend) | Postpones the expiry | +| [`token create`, `token ls`, `token revoke`](#token) | Personal API tokens | +| [`mcp`](#mcp-server) | The MCP server, on stdio | + +### init + +```bash +spawner init +``` + +Creates `.spawner/spawner.yaml` and `.spawner/compose.yaml` in the current directory, then says what comes next: an admin registers the project in the dashboard ([quickstart](quickstart.md)). It guesses: + +- **the project's slug**, from the name of the git repository's top directory (`My_App` gives `my-app`), or of the current directory outside git; +- **the port**, from the first `EXPOSE` of a `Dockerfile` in the current directory, else 3000. Without a `Dockerfile`, it warns: the compose file it writes builds the app from that directory (`build: ..`); +- **the database**, from the dependencies: `pg`, `postgres` or `pg-promise` in `package.json`, or `psycopg` in `requirements.txt` or `pyproject.toml`, add a PostgreSQL service; `mysql`, `mysql2`, `mysqlclient`, `PyMySQL` or `mysql-connector` add a MySQL one. Otherwise none. + +A Laravel project (`laravel/framework` in `composer.json`) also gets `APP_URL` and `LOG_CHANNEL: stderr`. The manifest has one exposure, `web`, on the service `app`, a lifetime of 72 hours and a seed in comments; [the manifest](manifest.md) describes the rest. + +| Option | Meaning | +|---|---| +| `-p, --project ` | The project's slug | +| `--port ` | The app's port | +| `--db postgres\|mysql\|none` | The database service | +| `--agents [file]` | Add the [instructions for coding agents](#instructions-for-coding-agents) to this file without asking: `CLAUDE.md` or `AGENTS.md`, whichever the project has, else `AGENTS.md` | +| `--no-agents` | Do not add them | +| `--force` | Replace the files of an existing `.spawner/` | + +On a terminal, `init` asks before adding the instructions; without a terminal or with `--json`, it adds them only with `--agents`. ### up +| Option | Meaning | +|---|---| +| `[env]` | The environment; by default, the branch's | +| `-w, --wait` | Wait until the environment is ready, then print its URLs | +| `--timeout ` | How long `--wait` waits: `30m` by default (`90` means 90 seconds) | +| `--logs` | Stream the build log while waiting (implies `--wait`) | +| `--source =` | Send another source of `spawner.yaml` from a local directory; repeatable | +| `--ref =` | Take a source from git at a branch, tag or commit; repeatable. The project's own source (`app` unless `name` says otherwise) deploys this project from git, without an upload | +| `--fresh` | On an update, drop the data (volumes) first | +| `--reseed` | On an update, replay the seed steps | +| `--ttl ` | Lifetime, such as `24h`: from 10 minutes to the longest lifetime (14 days by default); `ttl` of `spawner.yaml` otherwise | + ```bash -spawner up --wait # the worktree, uncommitted changes included -spawner up --wait --source front=../blog-front # another repository of spawner.yaml, from a local worktree -spawner up --wait --ref front=develop # another repository, from git (the default ref otherwise) -spawner up --wait --ref app=main # this project itself from git, without uploading -spawner up --wait --fresh # drop the data (volumes) first -spawner up --wait --reseed # replay the seed steps -spawner up --wait --ttl 24h # lifetime, instead of spawner.yaml's +spawner up --wait +spawner up --wait --source front=../blog-front +spawner up --wait --ref front=develop +spawner up --wait --ref app=main --ttl 24h +spawner up --wait --fresh ``` -Before sending anything, `up` checks `spawner.yaml` and the compose file with the same code and the same limits as the server: a refused file fails in a second, with every issue and its path (exit code 7). It also checks that `spawner.yaml` sits where the project expects it (its root directory, for a monorepo). +In the dashboard, an update is **Redeploy**; **Redeploy and replay the seed** is `--reseed`, and **Redeploy from scratch** is `--fresh`. + +Before sending anything, `up` checks `spawner.yaml` and the compose file with the same code and the same limits as the server: a refused file fails in a second, with every issue and its path (exit code 7, [the issues](manifest.md#when-spawner-is-refused)). It also stops when the project is not registered on the server (`project "blog" is not registered on https://...`), and when `spawner.yaml` is not where the project expects it, its directory for a monorepo (`spawner.yaml is in apps/blog, but project blog expects it in the root`, exit code 2). + +What is sent: the files git sees in the worktree (`git ls-files --cached --others --exclude-standard`), so uncommitted work is included and ignored files are not. -What is sent: the files git sees in the worktree (`git ls-files --cached --others --exclude-standard`), so uncommitted work is included and ignored files are not. Untracked `node_modules/`, `vendor/` and local secrets stay home: `.env` files (`.env.local`, `.env-staging`...), `.npmrc`, `.yarnrc.yml`, `.pypirc`, `.netrc`, `.git-credentials`, `.pgpass`, private SSH keys (`id_ed25519`...) and `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.jks`; `upload.include` in `spawner.yaml` names ignored files to send anyway (globs: `config/*.local.php`, `**/*.pem`). Outside git, every file but `.git/`, `node_modules/`, `vendor/` and `.env` files. Git submodules are skipped with a warning. Symbolic links stay links; a link pointing outside the repository is refused before upload. Limits come from the server (100 MiB compressed, 50,000 files, 1 GiB extracted by default). +- Untracked `node_modules/` and `vendor/` directories and local secrets are not sent: `.env` files (`.env.local`, `.env-staging`...), `.npmrc`, `.yarnrc.yml`, `.pypirc`, `.netrc`, `.git-credentials`, `.pgpass`, `.dockercfg`, private SSH keys (`id_ed25519`...) and `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.jks`, `*.keystore`. `upload.include` in `spawner.yaml` names ignored files to send anyway (globs: `config/*.local.php`, `**/*.pem`). +- A file the compose file uses, such as an `env_file`, must be sent too: commit it, or add it to `upload.include`. The local check reads the worktree, where the file exists, so only the server notices it is missing. +- Outside git, every file is sent but `.git/`, `node_modules/`, `vendor/` and the local secrets above. +- Git submodules are skipped with a warning. Symbolic links stay links; a link pointing outside the repository is refused before upload. +- For a monorepo, only the project's directory (the one holding `.spawner/`) is sent, at its place in the repository. +- Limits come from the server: 100 MiB compressed, 50,000 files and 1 GiB extracted by default. -For a monorepo, only the project directory (the one holding `.spawner/`) is sent, at its place in the repository. +`--wait` follows the job and prints the URLs. When the job fails, the end of its log goes to stderr, and `spawner logs --job` prints all of it. -`--wait` follows the job (`--timeout 30m` by default) and prints the URLs; `--logs` streams the build log meanwhile. When the job fails, the end of its log goes to stderr, and `spawner logs --job` prints all of it. +### status + +```bash +spawner status feat-login +``` + +The environment's status, URLs, sources, expiry and last job; each service (state, health, restarts, out-of-memory kills); and the last 20 events of its timeline: services that crashed, ran out of memory or turned unhealthy (from Docker events), and the jobs that changed it. A service that crashed three times in ten minutes is called out with its cause: + +```text +api failed 3 times in 10 minutes, last cause: out of memory (limit 512 MiB) +``` + +Reading the status does not count as activity: it does not keep an environment awake. Options: `[env]`, `-p`. + +### ls + +Lists the live environments of the current project, or of every project outside one. Also `spawner list`. + +| Option | Meaning | +|---|---| +| `-p, --project ` | One project | +| `-a, --all` | Every project, even inside one | +| `--mine` | Only yours | ### logs ```bash -spawner logs feat-login # every service, the last 200 lines -spawner logs feat-login api --errors # only error lines, with their stack traces -spawner logs feat-login api,db -f # follow +spawner logs feat-login +spawner logs feat-login api --errors +spawner logs feat-login api,db -f spawner logs feat-login --grep users --since 10m -t -spawner logs feat-login --job # the log of the last job (build, start, seed) +spawner logs feat-login --job ``` -`--errors` keeps the lines that report an error: JSON lines with an error level (pino, bunyan, winston and other structured loggers), other lines containing words such as `error`, `exception`, `fatal`, `panic`, `traceback` or `uncaught`, and the lines that continue them (stack frames, indented lines). The stream does not count: Postgres, Python's logging or nginx write routine messages to stderr. Filters apply on the server, so `--tail 50 --errors` gives the last 50 error lines among the last 5,000 lines of each service; when the tail would cut an error, the output starts at its first line, which usually names the cause. +| Option | Meaning | +|---|---| +| `[service]` | A service, or several separated by commas; all of them by default, merged in time order | +| `-n, --tail ` | The last lines: 200 by default, 5,000 at most | +| `-f, --follow` | Keep printing new lines, until the services stop or Ctrl-C | +| `--since