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/assets/dashboard-dark.png b/.github/assets/dashboard-dark.png new file mode 100644 index 0000000..eb1c7f7 Binary files /dev/null and b/.github/assets/dashboard-dark.png differ diff --git a/.github/assets/dashboard-light.png b/.github/assets/dashboard-light.png new file mode 100644 index 0000000..e631b82 Binary files /dev/null and b/.github/assets/dashboard-light.png differ diff --git a/.github/assets/logo.svg b/.github/assets/logo.svg new file mode 100644 index 0000000..24f955a --- /dev/null +++ b/.github/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + 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/.vscode/extensions.json b/.vscode/extensions.json index 0bc1d28..9ac1527 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,7 +3,6 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "Vue.volar", - "firsttris.vscode-jest-runner", - "ms-vscode.vscode-typescript-next" + "vitest.explorer" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 847dd35..f45cfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ - **Uploads stop at their first limit**: an archive is read as a stream that stops at the first file, byte or decompressed byte over the limits, so that a compression bomb costs no more than a legitimate archive; a person has 5 deploys of uploaded code waiting to start at most. - **Environments no longer reach the cloud's metadata or the server's services**: the code of a branch could read the credentials of the server from the metadata service of its cloud (AWS, GCP, Azure, Hetzner, OVH...), or call a service listening on the server. A new `spawner-firewall` container loads rules for every container of Docker's bridge networks: the metadata addresses are refused, but for DNS, and the server answers on DNS, HTTP and HTTPS only. The installer also has Docker load them before it starts, so that they hold from boot on: after an update from the dashboard, run `install.sh --upgrade` once on the server for that part. - **What you download can be verified**: the images, `install.sh` and the CLI bundle of a release carry build provenance attestations (`gh attestation verify install.sh -R Flosk6/Spawner`). The `install.sh` of a release names the digest of its image, and updates from the dashboard read it there: both run the image the release built, whatever its tag points to later. Postgres and Traefik are pinned by digest. +- **Traefik 3.7**: Traefik 3.5, which no longer gets security fixes, gives way to 3.7; upgrading Spawner pulls it. Traefik 3.7 embeds lego v5, which dropped the DNS providers azure (use azuredns), dnspod, googledomains, cloudxns, brandit, iwantmyname and iij (use iijdpf): a server set up with one of them switches before upgrading. Request headers whose name holds a character other than a letter, a digit or a dash (`X_Spawner_Preview`, `X.Forwarded.For`) no longer reach applications, so an application that reads headers the PHP or CGI way cannot be handed one that Traefik removes or sets. +- **The installer** warns below 4 GiB of memory, where no environment starts with the default settings, and names the settings to lower. It accepts an `/opt/spawner` holding only `.env` and `dns.env`, as when restoring on a new server. +- **The documentation moves to https://spawner.run**, with new pages: a quickstart from a fresh server to a first environment; previews for pull requests from GitHub Actions and GitLab CI; the two example projects; troubleshooting. Also new: every key of `spawner.yaml` and the codes of a refused `.spawner/`, what an application needs to run behind Spawner, every option, variable and error code of the CLI and what each MCP tool takes and returns, VS Code with GitHub Copilot, Gemini CLI and Devin Desktop for agents, and what Spawner does not do. +- **Corrections to the documentation**: the minimum memory is 4 GiB, not 2; the compose policy listed `platform` under `build` as allowed, which Spawner refuses; services without `mem_limit` share what is left of the environment's memory, up to 512 MiB each; public URLs (`auth: none`) never wake a sleeping environment; with GitHub login set to an organization, its members get an account at their first login, without an invitation; `admin.js invite` links last 24 hours; the Dockerfile example of "Dockerfiles that share their layers" builds again; removing Spawner and the local install download the installer from the release, as upgrades do. +- `spawner init` links `spawner.yaml` to https://spawner.run/docs/manifest/ instead of an outdated branch of the repository, and the instructions it writes for coding agents say what to do on exit code 6. The dashboard's Docs button opens https://spawner.run/docs/. `spawner login` says a 90-day token expires in 90d, not 89d 24h. +- The MCP server is listed on the official MCP Registry as `io.github.Flosk6/spawner`, and spawner-cli on npm links to https://spawner.run. Release pages say what Spawner is, give the install and upgrade commands apart, and show how to check the files against `SHA256SUMS`. ## 2.1.0 @@ -37,7 +43,7 @@ A rewrite of Spawner: preview environments for every branch, for teams and the c - **The `spawner` CLI and its MCP server**, for people and coding agents: `up` from a worktree, `exec` in a service, logs with an error filter, URLs with a preview token, share links; `--json` everywhere and stable exit codes. `npm install -g spawner-cli`. - **Access without passwords**: invitation links and passkeys, optional GitHub login, roles, personal tokens with scopes, a device flow for the CLI. Previews are protected: teammates pass with their session, agents with a short token, guests with a share link, and the applications never see what opens the other previews. Everything goes to an audit trail. - **Supervision**: CPU and memory of every container over 30 days, disk per environment, a timeline of crashes, out-of-memory kills and jobs, crash loop alerts, logs kept 7 days after a deletion, terminals with limits and recordings. -- **Lifecycle and density**: environments sleep after 2 hours without activity and wake up on the next visit, expire after 72 hours, and count against a quota per person; Spawner refuses an environment the server has no room for, removes replaced images and the code a build no longer needs, and cleans up what deleted environments leave, nothing else. +- **Lifecycle and density**: environments sleep after 2 hours without activity and wake up on the next visit, expire after 72 hours, and count against a quota per person; Spawner refuses an environment beyond the server's capacity, removes replaced images and the code a build no longer needs, and cleans up what deleted environments leave, nothing else. - **One-command install**: `install.sh` sets up Docker, a wildcard certificate through your DNS provider (or one per host), and the stack from the images on GHCR; it upgrades (with a database backup) and uninstalls too. - **Updates from the dashboard**: Spawner sees new versions and updates itself in one click, backing its database up first and going back to the previous version if the new one does not start. - **Examples and documentation**: Node.js with PostgreSQL, and Laravel, Next.js and MySQL; guides for installing, agents, security, operations and density. 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/README.md b/README.md index afaa790..e0f1f9f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,24 @@ + + # Spawner -> Preview environments for every branch, on your own server, for your team and its coding agents. +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. + +[Website](https://spawner.run) · [Documentation](https://spawner.run/docs/) · [Compare](https://spawner.run/compare/) · [Changelog](https://spawner.run/changelog/) · [Releases](https://github.com/Flosk6/Spawner/releases) · [npm](https://www.npmjs.com/package/spawner-cli) [![CI](https://github.com/Flosk6/Spawner/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Flosk6/Spawner/actions/workflows/ci.yml) -[![Release](https://img.shields.io/github/v/release/Flosk6/Spawner?include_prereleases&sort=semver)](https://github.com/Flosk6/Spawner/releases) +[![Release](https://img.shields.io/github/v/release/Flosk6/Spawner?sort=semver)](https://github.com/Flosk6/Spawner/releases) [![npm](https://img.shields.io/npm/v/spawner-cli)](https://www.npmjs.com/package/spawner-cli) [![License](https://img.shields.io/github/license/Flosk6/Spawner)](LICENSE) -**Status: beta.** Spawner 2.0 is a rewrite: the manifest, the API and the CLI may still change before 2.1. +Spawner builds preview environments (also called review apps or ephemeral environments) from your project's Docker Compose file, without Kubernetes. Developers, reviewers and coding agents start one from a branch, or from a worktree with its uncommitted changes, then open it, run commands in it, read its logs, share it and delete it. Environments nobody uses go to sleep, so many fit on one server. Free under Apache-2.0. + +**Status**: out of beta since 2.1.0. Only the latest release gets fixes, and the dashboard updates a server in one click. -Spawner runs a copy of your application for each branch, with its own URLs, database and logs, on a server you own. Developers, reviewers and coding agents create one in seconds from a branch or straight from a worktree, uncommitted changes included, then open it, run commands in it, read its logs, share it, and delete it. Environments nobody uses go to sleep, and many fit on one server. + + + The Environments page of the Spawner dashboard: one row per environment, with its status, its source, who started it, its memory, and when it sleeps and expires. + ```text $ cd ~/code/blog-feat-login # an agent's worktree, branch feat/login @@ -17,7 +26,7 @@ $ spawner up --wait feat-login (blog) is ready web https://feat-login--blog.preview.example.com api https://api--feat-login--blog.preview.example.com -$ spawner exec feat-login db -- mysql -uapp -papp app -e "insert into users (name) values ('ada')" +$ spawner exec -i feat-login db -- mysql -uapp -papp app < fixtures.sql $ spawner logs feat-login api --errors $ spawner share feat-login # a link for someone without an account $ spawner down feat-login @@ -25,33 +34,31 @@ $ spawner down feat-login ## Quick start -On a server dedicated to previews (Ubuntu 22.04 or 24.04, Debian 12; 8 GiB of memory advised), with a DNS record `*.preview.example.com` pointing to it: +On a server dedicated to previews (Ubuntu 22.04 or 24.04, Debian 12, amd64 or arm64; at least 4 GiB of memory, 8 GiB or more advised), with a DNS record `*.preview.example.com` pointing to it: ```bash curl -fsSL https://github.com/Flosk6/Spawner/releases/latest/download/install.sh | sudo bash ``` -The installer asks for the domain, an e-mail and your DNS provider (for one wildcard certificate), installs Docker if needed, starts Spawner, and prints the link that creates the first admin account. Then, on your machine, in a project: +The installer asks for the domain, an e-mail and your DNS provider (for one wildcard certificate), installs Docker if needed, starts Spawner, and prints the link that creates the first admin account. Then, on your machine (Node.js 20 or later), in a project: ```bash -npm install -g spawner-cli && spawner login https://spawner.preview.example.com -spawner init && spawner up --wait +npm install -g spawner-cli +spawner login https://spawner.preview.example.com +spawner init ``` -`spawner init` writes `.spawner/` (a manifest and a Docker Compose file, to adjust) and the instructions for coding agents. See [installing](docs/install.md), and [the examples](examples): [Node.js and PostgreSQL](examples/node-postgres), [Laravel, Next.js and MySQL](examples/laravel-next-mysql). +`spawner init` writes `.spawner/` (a manifest and a Docker Compose file, to adjust), offers to add the instructions for coding agents, and prints the project's slug. An admin adds the project in the dashboard (Projects, New project): that slug, the repository, and the directory of `.spawner/` in a monorepo. Then `spawner up --wait` starts the environment of the branch. [The quick start](https://spawner.run/docs/quickstart/) goes through each step, and [the examples](https://spawner.run/docs/examples/) (Node.js and PostgreSQL; Laravel, Next.js and MySQL) are ready to deploy. ## How it works -```text - *.preview.example.com - | - browser, agent, CLI ---------> Traefik --------- asks Spawner before each request - | - +--------------------------+--------------------------+ - | | | - spawner. feat-login--blog. main--blog. - dashboard and API its own network: its own network: - (Spawner, Postgres) web, api, db web, api, db +```mermaid +flowchart LR + clients["browser, agent, CLI"] --> traefik["Traefik on *.preview.example.com"] + traefik --> spawner["spawner.preview.example.com
dashboard and API"] + traefik -.->|"checks access to protected URLs"| spawner + traefik --> env1["feat-login--blog.preview.example.com
web, api, db on their own network"] + traefik --> env2["main--blog.preview.example.com
web, api, db on their own network"] ``` A project describes its environment in its repository, in `.spawner/`: a manifest that names its URLs and its seed, and a regular Docker Compose file. @@ -67,64 +74,57 @@ seed: - { service: api, run: [php, artisan, migrate, --seed, --force] } ``` -For each environment, Spawner checks out the branch or receives the worktree, checks the compose file against a security policy, builds and starts it on its own Docker network, seeds it, and routes its URLs through Traefik with HTTPS. Updates keep the data. Every change is a job with a live log; the dashboard, the CLI, its MCP server and the API all drive the same jobs. +For each environment, Spawner checks out the branch or receives the worktree, checks the compose file against a security policy, builds and starts it on its own Docker network, seeds it, and routes its URLs through Traefik with HTTPS. Updates keep the data. Every change is a job with a live log, whether it comes from the dashboard, the CLI, the MCP server or the API. ## For coding agents An agent tests its own work in a real environment, from its worktree, without pushing: -- `spawner up --wait --json`, then every command with `--json` and stable exit codes (4: the environment failed, with the end of the build log; 6: no room; 7: `.spawner/` refused, with what to fix); +- `spawner up --wait --json`: commands print JSON with `--json` and exit with stable codes (4: the environment failed, with the end of the build log; 6: quota or capacity reached; 7: `.spawner/` refused, with what to fix); - `spawner url --with-token` gives the header that opens protected URLs, for curl or Playwright; - `spawner exec` runs a command in any service; `spawner logs --errors` keeps the error lines, stack traces whole; `spawner status` says which service crashed and why; -- `spawner mcp` offers the same operations as MCP tools (Claude Code, Codex, Cursor...); +- `spawner mcp` is an MCP server for Claude Code, Codex, Cursor and other clients (`claude mcp add spawner -- spawner mcp`), with nine tools: `spawner_up`, `spawner_status`, `spawner_list`, `spawner_logs`, `spawner_exec`, `spawner_stats`, `spawner_url`, `spawner_share` and `spawner_down`; - each agent on its own branch gets its own environment and database; agents deploying at once do not get in each other's way. -See [coding agents](docs/agents.md) and [the CLI](docs/cli.md). +See [coding agents](https://spawner.run/docs/agents/) and [the CLI](https://spawner.run/docs/cli/). ## Many environments on one server -- **Sleep**: after 2 hours without a visit or an action, an environment's containers stop; the next visit wakes it up within seconds. -- **Shared layers**: environments share their base images and dependencies; each one only adds its code and build output (about 4 MiB for the Laravel example). Spawner warns about Dockerfiles that prevent it. -- **Capacity**: Spawner says how many more environments of each project fit, from what they really use, and refuses one the server has no room for. -- **Lifetime and quotas**: environments expire after 72 hours unless someone deploys them again; 5 per person. Spawner cleans up what deleted environments leave, and nothing else. -- **Updates in one click**: when a new version is out, the dashboard offers it; Spawner backs its database up, restarts on it, and goes back by itself if it does not start. - -See [making environments cheap](docs/manifest.md#making-environments-cheap). +- **Sleep**: after 2 hours (by default) without a visit or an action, an environment's containers stop; the next visit to a protected URL wakes it up within seconds. +- **Shared layers**: environments share their base images and dependencies; each one only adds its code and build output (about 4 MiB for the Laravel example). Spawner warns about Dockerfiles that prevent it: see [making environments cheap](https://spawner.run/docs/manifest/#making-environments-cheap). +- **Capacity**: Spawner says how many more environments of each project fit, from what they really use, and refuses one that does not fit. +- **Lifetime and quotas**: by default, environments expire after 72 hours unless someone deploys them again, and each person may own 5. Spawner cleans up what deleted environments leave, and nothing else. ## Supervision -Each environment has its logs (by service, errors only, live, downloadable), the CPU and memory of each service over time, its disk, a timeline of crashes, out-of-memory kills, failing healthchecks and jobs, and a terminal into any service. A deleted environment stays readable for 7 days. Admins see the server: alerts, 30 days of history, every container, and the room left. +Each environment has its logs (by service, errors only, live), the CPU and memory of each service over time, its disk, a timeline of crashes, out-of-memory kills and failing healthchecks, and a terminal into any service; a deleted one stays readable for 7 days. Admins see the server: alerts, 30 days of history, every container, and how many more environments fit. ## Security -Branches run unreviewed code, so Spawner treats them as such: compose files are checked against an allowlist (no host mounts, privileged containers, host network or Docker socket), each environment has its own network, Traefik has no Docker socket, and the server is meant for previews only. Accounts have no passwords (invitations and passkeys, GitHub optional); tokens have scopes and expiries; previews are protected by default, and Spawner's cookies never reach the applications. Everything is in an audit trail. See [security](docs/security.md). +Branches run unreviewed code: compose files are checked against an allowlist (no host mounts, privileged containers, host network or Docker socket), each environment has its own network, Traefik has no Docker socket, and the server is meant for previews only. Accounts have no passwords (invitations and passkeys, GitHub optional); tokens have scopes and expiries; previews are protected by default, and Spawner's cookies never reach the applications. An audit trail records who did what. No telemetry: on its own, Spawner only asks GitHub for new releases (`SPAWNER_UPDATE_CHECK=false` turns that off). See [security](https://spawner.run/docs/security/). ## What Spawner does not do -- Host production, scale, or span several servers: one server per installation, dedicated to previews. -- Create an environment when a pull request opens: planned; today, CI can call `spawner up` from the branch. -- Pull from private image registries: images are public, or built from the sources. Repositories may live on any git host (SSH or HTTPS, with a deploy key). -- Single sign-on (OIDC) and permissions per project: planned. -- Isolate more than containers do: environments share the server's kernel. See [security](docs/security.md). +One installation runs previews on one server, from Docker Compose: no production, no Kubernetes, no cluster, and no environment opened by itself for a pull request (CI calls `spawner up`, see [the CI guide](https://spawner.run/docs/ci/)). See [the whole list](https://spawner.run/docs/concepts/#what-spawner-does-not-do), and [the comparison](https://spawner.run/compare/) with other tools. ## Documentation -- [Installing](docs/install.md): requirements, DNS providers, options, upgrades, removal -- [Concepts](docs/concepts.md): projects, environments, jobs, lifecycle, accounts, protected previews -- [The manifest](docs/manifest.md): `.spawner/spawner.yaml`, the compose file, its rules, and what makes environments cheap -- [The CLI and the MCP server](docs/cli.md) -- [Coding agents](docs/agents.md): Claude Code, Codex, Cursor -- [Security](docs/security.md) -- [Operations](docs/operations.md): backups, restores, disk, monitoring -- [Configuration](docs/configuration.md): every setting of the server -- [Architecture](docs/architecture.md): how Spawner is built, for those who read its code -- [Changelog](CHANGELOG.md) +- [Quick start](https://spawner.run/docs/quickstart/): from a fresh install to the first environment +- [Installing](https://spawner.run/docs/install/): requirements, DNS providers, options, upgrades, removal +- [Concepts](https://spawner.run/docs/concepts/): projects, environments, jobs, lifecycle, access, limits +- [The manifest](https://spawner.run/docs/manifest/): `.spawner/`, its rules, and what makes environments cheap +- [The CLI and the MCP server](https://spawner.run/docs/cli/): commands, JSON outputs, exit codes, MCP tools +- [Coding agents](https://spawner.run/docs/agents/): Claude Code, Codex, Cursor +- [Pull requests in CI](https://spawner.run/docs/ci/): previews from GitHub Actions and GitLab CI +- [Examples](https://spawner.run/docs/examples/) and [troubleshooting](https://spawner.run/docs/troubleshooting/) +- [Security](https://spawner.run/docs/security/), [operations](https://spawner.run/docs/operations/) (backups, disk, monitoring), [configuration](https://spawner.run/docs/configuration/) (every setting) +- [Architecture](https://spawner.run/docs/architecture/): how Spawner is built, for contributors ## Development ```text apps/ -├── api/ NestJS, Prisma (PostgreSQL), the environment engine; serves the web app and the CLI in production +├── api/ NestJS, Prisma (PostgreSQL): the API and the environment engine ├── cli/ The spawner CLI and MCP server, bundled into one file └── web/ Vue 3, Vite, Tailwind CSS, PrimeVue packages/ @@ -140,14 +140,14 @@ Requirements: Docker with Compose v2, Node.js 22 and pnpm 8. ```bash pnpm install && pnpm build cp .env.example .env # set SPAWNER_DATA_DIR to an absolute path -docker compose up -d --build # Postgres, Traefik and Spawner on http://spawner.localtest.me +docker compose up -d --build # Postgres, Traefik, Spawner: http://spawner.localtest.me docker logs spawner # the link that creates the first admin account -pnpm dev # or the API on :3000 and the web app on :8080, with hot reload +pnpm dev # or the API on :3000, the web app on :8080, hot reload pnpm lint && pnpm typecheck && pnpm test -scripts/e2e-engine.sh # end to end: a local stack, environments through the API, the CLI and MCP +scripts/e2e-engine.sh # end to end: a local stack, the API, the CLI and MCP ``` -Every subdomain of `localtest.me` resolves to 127.0.0.1. Over plain HTTP, browsers allow passkeys on `localhost` only, so a local install lets invitations log in without one. See [scripts](scripts/README.md) for the tests. +Every subdomain of `localtest.me` resolves to 127.0.0.1. Over plain HTTP, browsers allow passkeys on `localhost` only: a local install lets invitations log in without one. See [scripts](scripts/README.md) for the tests. ## Contributing diff --git a/apps/cli/README.md b/apps/cli/README.md index c170ad7..217e175 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,26 +1,74 @@ # spawner-cli -The command line and MCP server of [Spawner](https://github.com/Flosk6/Spawner): preview environments for every branch, on your own server. Developers and coding agents create, update and delete environments from a worktree (uncommitted changes included), run commands in them and read their logs. +The command line and MCP server of [Spawner](https://spawner.run). 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. + +With `spawner`, developers and coding agents create, update and delete environments from a worktree, uncommitted changes included, run commands in them and read their logs. + +## Install ```bash npm install -g spawner-cli -spawner login https://spawner.preview.example.com # enter the code it shows in the dashboard -cd my-project && spawner init # .spawner/, and the instructions for coding agents -spawner up --wait # the environment of the current branch -spawner exec feat-login db -- psql -U app -c "select count(*) from users" +``` + +It needs Node.js 20 or later, and a Spawner server: [install one](https://spawner.run/docs/install/) with a single command. Every server also serves the CLI of its own version at `/api/v1/cli/spawner`. + +## Log in + +```bash +spawner login https://spawner.preview.example.com +``` + +Type the code it shows in the dashboard and approve the login: the CLI stores a personal token. In CI or a container, `SPAWNER_URL` and `SPAWNER_TOKEN` replace the login. + +## Commands + +```bash +# write .spawner/, and offer to add the instructions for coding agents +spawner init + +# the environment of the current branch, from this worktree +spawner up --wait + +# run a command in a service +spawner exec feat-login db -- psql -U app -c "select * from users" + +# only the error lines, stack traces kept whole spawner logs feat-login api --errors -spawner share feat-login # a link for someone without an account + +# a link for someone without an account +spawner share feat-login + spawner down feat-login ``` -Every command has a `--json` output and stable exit codes. For agents that prefer tools, `spawner mcp` is an MCP server with the same operations: +Before the first `spawner up` of a project, an admin adds the project in the dashboard (Projects, New project). The other commands: `status`, `ls`, `stats`, `capacity`, `url`, `shell`, `stop`, `start`, `sleep`, `wake`, `extend`, `token`, `whoami` and `logout`. + +Commands print JSON with `--json` and exit with stable codes: 4 when the environment failed, 6 when the quota or the server's capacity is reached, 7 when `.spawner/` was refused. + +## MCP server + +`spawner mcp` is an MCP server on stdio, with the login and the rights of the CLI. In Claude Code: + +```bash +claude mcp add spawner -- spawner mcp +``` + +In a project's `.mcp.json`, or Cursor's `.cursor/mcp.json`: ```json -{ "mcpServers": { "spawner": { "command": "spawner", "args": ["mcp"] } } } +{ + "mcpServers": { + "spawner": { "command": "spawner", "args": ["mcp"] } + } +} ``` -It needs Node.js 20 or later, and a Spawner server: [install one](https://github.com/Flosk6/Spawner/blob/master/docs/install.md) with a single command. Every server also serves its own CLI at `/api/v1/cli/spawner`. +It has nine tools: `spawner_up`, `spawner_status`, `spawner_list`, `spawner_logs`, `spawner_exec`, `spawner_stats`, `spawner_url`, `spawner_share` and `spawner_down`. `spawner_exec` and `spawner_down` are marked destructive: whether a client asks before running them depends on the client and its permission settings. + +## Documentation -Commands, JSON outputs and exit codes: [docs/cli.md](https://github.com/Flosk6/Spawner/blob/master/docs/cli.md). +- [The CLI and the MCP server](https://spawner.run/docs/cli/): every command, JSON outputs, exit codes +- [Coding agents](https://spawner.run/docs/agents/): Claude Code, Codex, Cursor, and the instructions `spawner init` adds +- [Quick start](https://spawner.run/docs/quickstart/): from a fresh server to the first environment -License: [Apache-2.0](https://github.com/Flosk6/Spawner/blob/master/LICENSE). +Source: [github.com/Flosk6/Spawner](https://github.com/Flosk6/Spawner). License: [Apache-2.0](https://github.com/Flosk6/Spawner/blob/master/LICENSE). 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/ 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 @@ 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/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/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/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