The jetstack CLI

What It Is

jetstack is the command-line tool for building and shipping Custom Apps: set up a workspace for a tenant, scaffold apps, upload releases, review plans, deploy, promote between environments, roll back and read logs. It also keeps a tenant's configuration in a repository — see Configuration As Code. The Developer Workspace explains how a workspace is organized and how to start.

It is a single PHP file (jetstack.phar) and needs nothing but PHP 8.2 or newer with the curl, json and zlib extensions — the same PHP you write app handlers in. It talks to the tenant's app deployment API, signed in as you or with a deploy token.

Why It Matters

It turns releasing an app into the same routine as any other software: code in git, one command to deploy, the same command in CI, and every deploy recorded in the tenant.

Installing

Every tenant serves the CLI of its platform version, and only to its tenant administrators and superusers: the people trusted to build on the tenant. They get it from the tenant's web UI:

  1. Sign in to the tenant and open Command-line tool in the system menu (https://<tenant>/cli/install).
  2. Copy the install command for your system and run it in a terminal:
curl -fsSL "https://<tenant>/downloads/jetstack/install.sh?t=jdg_…" | sh          # macOS, Linux
irm "https://<tenant>/downloads/jetstack/install.ps1?t=jdg_…" | iex               # Windows (PowerShell)
  • Only for you, for an hour. The command carries a download grant made for you. It stops working after an hour, or when you are no longer an administrator; reload the page for a new one.
  • What it installs. A standalone binary for the system (Linux x86_64 and aarch64, macOS arm64 and x86_64, Windows x64) that needs no PHP. On other systems, it installs the PHAR, which needs PHP 8.2 or newer with the curl, json and zlib extensions.
  • Where. ~/.local/bin/jetstack (%LOCALAPPDATA%\Programs\jetstack on Windows), or JETSTACK_INSTALL_DIR. The script says when that folder is not on your PATH.
  • Checked. The download must match the release's checksum.
  • Single files. The same page lists the binaries and the PHAR to download one by one; run the PHAR as php jetstack.phar.
  • From the API. GET /api/v1/app/cli.download returns the same install commands and files to an administrator's sign-in.
  • Pipelines. A pipeline token (jetstack token create) may download the CLI too: jetstack ci init writes a workflow whose install step sends it (see Continuous Integration).

Someone who is not an administrator gets the CLI from one: they share the command, which works only for an hour.

jetstack version prints the CLI's version, whether it is the binary or the PHAR, the system, and the PHP it runs on.

The CLI carries this documentation for the platform version it was published with, so jetstack docs works offline and matches the tenant the CLI came from. A CLI installed without it (from source) takes it from the workspace's development environment into .jetstack/docs/.

Staying Up To Date

  • jetstack self-update installs the CLI the workspace's development environment serves (--env prod for production's, --from <url> outside a workspace, --to <x.y.z> for another version). It downloads with your sign-in, so it works for tenant administrators and superusers (and pipeline tokens). It installs nothing whose checksums are not signed by the release key, keeps the replaced CLI next to it, and jetstack self-update --rollback puts it back.
  • Version checks. Every request tells the tenant which CLI it comes from:
    • when the tenant serves a newer CLI, the CLI says so once;
    • when the tenant needs a newer CLI than this one, the command stops with exit code 5;
    • when an environment serves an older CLI — usual while a platform release reaches development before production — the CLI notes that the environment runs an earlier platform release.

Getting Started From The Console

The tenant page of the console has a Develop card with the two steps a developer needs for that tenant: the link to its Command-line tool page, where an administrator gets the install command, then jetstack connect. It also has a prompt to paste into an AI assistant. Self-hosted installations print the same steps when they finish, and write them to DEVELOP.md in the installation folder.

The Workspace File

jetstack connect writes jetstack.json at the root of a workspace — one repository for one tenant, with its apps in apps/<slug>/ and its configuration in tenant/:

{
  "$schema": ".jetstack/schema/jetstack.workspace.v1.schema.json",
  "workspace": 1,
  "name": "acme",
  "cli": ">=0.3.0",
  "environments": {
    "dev": { "url": "https://acme-dev.jetstack.app", "mode": "sandbox" },
    "prod": { "url": "https://acme.jetstack.app", "mode": "protected" }
  },
  "defaultEnv": "dev",
  "apps": "apps",
  "tenant": "tenant",
  "appSettings": {
    "portal": { "build": { "command": "php build.php", "output": "dist" } }
  }
}
  • environments — each environment is a tenant URL with a mode:
    • sandbox (development): jetstack dev applies an app's data model and other configuration on its own;
    • protected (production): every change asks first, and a run without a terminal must pass --yes.
  • defaultEnv — the environment commands use without --env: development. No command defaults to production.
  • apps and tenant — the folders of the apps and of the tenant's configuration.
  • appSettings — per app, only what differs from the defaults: build.command (run before packing), build.output (default public), server (default server), manifest (default jetstack.app.json).

App commands act on one app: the one named with --app <slug>, or the one whose folder the command runs in. --all runs build, plan, deploy, releases, status, envs, promote and types for every app in turn.

The file holds no secrets; commit it.

One-App Projects

An older project keeps its app in its root, described by jetstack.config.json:

{
  "app": "case-portal",
  "build": { "command": null, "output": "public" },
  "server": "server",
  "environments": {
    "dev": { "url": "https://case-dev.example.jetstack.app" },
    "prod": { "url": "https://case.example.jetstack.app", "confirm": true }
  }
}
  • app — the app's slug. The first deploy creates the app when the tenant does not have it.
  • build.command — optional; a command to run before packing, such as a front-end build. build.output is the folder shipped as the bundle's public/.
  • server — the handlers folder, shipped as server/. manifest defaults to jetstack.app.json.
  • environments — each environment is a tenant URL; confirm: true asks before changing it, and a run without a terminal must pass --yes. sandbox: true marks a tenant for experiments, where jetstack dev applies manifest changes on its own.

Such a project keeps working. jetstack workspace upgrade turns it into a workspace: the app moves to apps/<slug>/, jetstack/ becomes tenant/, and jetstack.config.json becomes jetstack.json.

A repository that only keeps a tenant's configuration (jetstack tenant) needs just environments; app and the build settings are for apps.

Signing In

People and pipelines sign in differently, because they need different things: a person should act as themselves and lose access when they leave; a pipeline needs a narrow, long-lived credential that belongs to no one.

People: the browser

jetstack login --env dev opens the tenant's normal sign-in page in your browser. You sign in the usual way — MFA included — and approve the CLI on a page that names the computer asking. The CLI then holds a short-lived sign-in and acts as you: it can do exactly what you can do in the tenant, no more.

How it works:

  • the browser hands the approval back to the CLI on your own computer (a local address), protected so that no other program or site can use it
  • a sign-in older than 15 minutes is refreshed first, so an unattended browser tab cannot approve anything
  • the CLI sign-in lasts 12 hours by default (cliTokenTtlHours) and is not renewed silently — sign in again when it ends
  • it is refused while you are signed in as another user

A CLI sign-in ends when:

  • you run jetstack logout --env dev
  • you revoke it under Profile → CLI sign-ins, which lists every computer you signed in, when it was last used, and from where
  • your password changes, or your account is deactivated
  • it expires

Use --no-browser to print the address instead of opening it, for example when your default browser is not the one you use. The browser must run on the same computer as the CLI; on a remote machine, use a deploy token.

jetstack whoami --env dev shows who the CLI acts as, with which roles, and until when.

Pipelines: deploy tokens and tenant tokens

In CI, set JETSTACK_TOKEN_<ENV> (for example JETSTACK_TOKEN_PROD) to a token, or JETSTACK_TOKEN when every environment uses the same one. A token in the environment wins over a saved sign-in.

  • A deploy token, created on an app's page, deploys apps.
  • A tenant token, created on the History page, reads or applies the tenant's configuration (jetstack tenant) — see Configuration As Code.

The two never stand in for each other.

To keep a deploy token on your own computer instead, run jetstack login --env dev --token <deploy token>.

Where sign-ins are kept

Sign-ins and saved tokens are stored per tenant in ~/.config/jetstack/credentials.json (%APPDATA%\jetstack\ on Windows), readable only by you.

What a CLI sign-in reaches

SurfaceCLI sign-inDeploy token
App deployment commandsyes, as you (needs deployApps)yes, within its scopes
jetstack tenantyes, as you (needs manageTenantConfig)no — a tenant token does
REST API recordsyes, as you (needs useApi, like your API keys)no
MCP, webhooksnono

Using It From Scripts And Assistants

The CLI is built to be driven by pipelines and AI coding assistants as well as by people.

Help

jetstack help lists every command by group. jetstack help <command> (or jetstack <command> --help) shows the command's options and examples. jetstack help --json prints the whole catalog: every command with its arguments, options, examples and related commands, the global options and the exit codes. An assistant can read it once instead of guessing.

Options Every Command Takes

  • --json — the result as JSON on standard output. A failure prints {"error": {"code", "message", "hint", "details"}} instead.
  • --no-input — never ask. A question that would need an answer fails with exit code 3, naming the option that answers it (usually --yes).
  • --quiet — print only results and errors, no running commentary.
  • --help — the command's help.

An option a command does not have is an error (exit code 2), not silently ignored.

When Nobody Is At The Keyboard

The CLI never waits for an answer that cannot come. It behaves as with --no-input when standard input is not a terminal, or when one of these variables is set: CI, GITHUB_ACTIONS, GITLAB_CI, BUILDKITE, JENKINS_URL, CLAUDECODE, CURSOR_AGENT, JETSTACK_NO_INPUT. In that case connect and login do not open a browser, and anything that would ask for confirmation needs --yes.

Exit Codes

CodeMeaning
0Success
1The command failed (the tenant refused, a plan could not be applied, validation found problems)
2Wrong command, option or argument
3It needs confirmation: pass --yes
4Not signed in, or not allowed
5The CLI and the platform do not fit: update the CLI

Skills

AGENTS.md tells an assistant the rules and the commands. Skills add step-by-step workflows that an assistant loads when a task calls for one. The CLI carries them, matching its version, and jetstack agents setup --skills installs them into the workspace:

SkillFor
jetstack-add-data-typeA new type, properties or an enumeration, in an app's manifest or the shared data model
jetstack-role-permissionsWhat a role may do: types, single properties, only some records, system permissions; proven with perms explain and a test
jetstack-ship-to-productionTests, git and production's own changes checked, then one promotion with its plan shown first
jetstack-restore-stateAn earlier entry of the change history restored, with git kept in step
jetstack-debug-handlerLogs, failed events, permissions and data checked; the fix made with a test
jetstack-data-migrationA data migration chosen (blocking or background), written in batches, tested on development and shipped with its release

They are in the Agent Skills format (<name>/SKILL.md), so Claude Code, Codex, VS Code and Cursor all read them. In Claude Code, each is also a slash command, such as /jetstack-ship-to-production. Commit them with the workspace. jetstack agents refresh updates them to the CLI's version and removes ones the CLI no longer ships. Skills the team writes in the same folders are left alone.

Dev Container

jetstack agents setup --devcontainer writes .devcontainer/devcontainer.json, for VS Code's Dev Containers, GitHub Codespaces and other tools that read the format. On creation the container:

  • installs the jetstack CLI from the tenant, so it needs no PHP, when JETSTACK_TOKEN_<ENV> of the development environment is set on the computer (downloading needs an administrator's sign-in or a pipeline token); otherwise it says where an administrator gets the install command;
  • runs jetstack agents setup, so the assistants find jetstack mcp;
  • adds Node, and Claude Code when it is one of the workspace's assistants.

Sign-ins are kept in a volume, so they survive rebuilds; jetstack login in the container opens the browser of the computer in front of it. JETSTACK_TOKEN_<ENV> variables set on that computer are passed in. Port 8787 (jetstack dev) is forwarded.

Commands

connect <url> [--dev <url> --prod <url>]

Makes the current folder a workspace for the tenant at <url> — either of its environments:

  1. Reads /.well-known/jetstack.json from the tenant, which names both environments.
  2. Writes jetstack.json, .gitignore, the schemas editors read, AGENTS.md and CLAUDE.md, and an empty apps/.
  3. Starts a git repository.
  4. Signs in to development in the browser.
  5. Signs in to production and takes its configuration into tenant/.
  6. Commits everything as the first commit, then runs doctor.

Options:

  • --dev <url> --prod <url> — name the environments yourself, for a tenant that does not describe itself.
  • --name <name> — the workspace's name (default: the tenant's).
  • --no-git — no repository.
  • --no-agents — no AGENTS.md or CLAUDE.md.
  • --no-pull — production stays untouched.

Without a terminal (CI, an assistant), connect does not open a browser; sign in later with jetstack login.

doctor [--env <env>] [--json]

Checks the workspace and each environment, and says what to do about each problem:

  • Workspace: jetstack.json, the apps, whether AGENTS.md is up to date, the git repository.
  • Environments: whether each is reachable and which platform version it runs, the sign-in, the permissions the CLI needs (deployApps, manageTenantConfig, useApi, useMcp), and the tenant switches it depends on.

It exits with an error when something essential fails. --json prints the whole report.

status [--json]

In a workspace, outside any app: each environment, whether you are signed in, and the release live there for every app. Inside an app (or with --app): that app's live release and recent deployments.

app new <slug> [--name <name>] [--template records|basic]

A new app in apps/<slug>/, from the same templates as init. Its manifest points at the workspace's schema, so editors check it.

workspace upgrade [--yes]

Turns a one-app project into a workspace (see One-App Projects). It lists the moves and asks first; without a terminal it needs --yes.

mcp [--env <env>] [--allow-prod-writes] [--dir <path>]

An MCP server on standard input and output, for assistants on this computer. It signs in to the environment as you and offers the tenant's tools, plus tools that run here: jetstack_docs_search and jetstack_docs_read (this CLI's documentation), jetstack_workspace_status, jetstack_validate, jetstack_migrations, jetstack_app_deploy (with allowMigrations for data migrations on production) and jetstack_tenant_pull. It serves development unless --env names another environment. On a protected one, only read-only tools are offered unless --allow-prod-writes. --dir names the workspace when the assistant starts the server elsewhere (Claude Desktop). Assistants start it themselves; see Connecting Assistants.

agents setup [--claude] [--vscode] [--cursor] [--codex] [--env <env>] [--skills] [--devcontainer [--force]]

Writes the files Claude Code (.mcp.json), VS Code (.vscode/mcp.json), Cursor (.cursor/mcp.json) and Codex (.codex/config.toml) read to start jetstack mcp, keeping their other servers. Without options it configures the assistants the workspace shows signs of. When jetstack is not on the PATH, the files name this CLI by its path; put it on the PATH before committing them.

--skills also installs the CLI's skills: .claude/skills/ for Claude Code (VS Code and Cursor read it too) and .agents/skills/ for Codex, or for VS Code or Cursor without Claude Code.

--devcontainer also writes .devcontainer/devcontainer.json (see Dev Container). A dev container the CLI did not write is replaced only with --force.

agents refresh

Brings the CLI's section of AGENTS.md up to date, and the skills it installed. Text outside that section — the team's own notes — stays as it is, and so do the team's own skills. The section names the commands, the rules an assistant follows and an index of this documentation.

agents status [--json]

Whether AGENTS.md has the CLI's section, whether it was written by this version of the CLI, whether CLAUDE.md includes it, and which of the CLI's skills are installed, from which version. Exits with 1 when agents refresh would change something.

help [<command>] [--json]

See Help.

docs [<topic>] [--json]

Prints a chapter of this documentation as Markdown, for example jetstack docs jetstack-cli. <topic> is a chapter's slug (its file name), optionally with a section: jetstack docs jetstack-cli#exit-codes. Without a topic, it lists the chapters. --json prints the chapter's slug, title, the section asked for and the Markdown.

docs list [--json]

Every chapter, grouped by section, with its slug.

docs search <words> [--limit <n>] [--json]

Finds the sections whose title or text mention the words, best matches first (10 by default), each with the slug#section to read it. Exits with 1 when nothing matches.

validate [--json]

Checks the workspace's files without talking to the tenant:

  • jetstack.json against the workspace schema.
  • Each app's jetstack.app.json against the manifest schema: required keys, value types, allowed values and route keys.
  • The files in tenant/: whether each is valid JSON, whether each entity file names its $selector, and whether a .conflict.json file is left unresolved.

Inside an app folder, it checks jetstack.json and that app only. Every problem is reported with its file and the path inside the file (a JSON Pointer such as /routes/GET ~1notes/auth); --json prints the report as {"ok", "files": {<file>: [{path, message}]}}. The tenant still checks the rest when it plans.

plan and deploy run the same manifest check first, and stop before uploading when it fails.

init [dir] [--name <name>] [--template records|basic]

Scaffolds a separate one-app project, outside any workspace (inside one, use app new). The app is manifest-v2 and needs no build step: its page imports the App SDK directly. Every template brings the project file and AGENTS.md, a guide for AI coding assistants.

  • records (the default) — a data type with grants for signed-in users, a menu item, three handlers, and a page that reads the records straight from the tenant and writes through a handler.
  • basic — a page and one public handler.

dev [--env <env>]

Runs the app from your computer against a real tenant, so a change shows on the next reload instead of after a deploy:

  • The front-end is served at http://127.0.0.1:8787/app/<slug>/ from your build output (--dir to pick another folder), with the same bootstrap and runtime the tenant adds — as you, with your roles.
  • Handlers, migrations and the manifest go into the app's draft as you save them: the draft the Studio, AI agents and the app's page work on. Calls to the app's routes run in that draft, in the tenant, as you.
    • Each file sent names the version it replaces. A file someone changed meanwhile, in the Studio or by an agent, is not overwritten: it is reported, and yours goes over theirs only when you save it again after looking.
    • Changes made in the draft arrive here. A file you have not changed since is brought into your folder ("pulled server/notes/list.php (changed by Ada (studio))"). A file changed on both sides is reported instead.
    • At the start, what differs is sent, files only the draft has come here, and files changed on both sides are reported.
    • A tenant from before the shared draft gives you a draft of your own instead, as earlier versions of jetstack dev had.
  • Calls to the REST API and the tenant's assets go to the tenant with your sign-in. The app's log — your $jetstack->utils->log() lines and handler errors with their ids — follows in the terminal.
  • Other apps — the ones the app links to or embeds at /app/<other>/ — come from the tenant, live as they are there, so a wrapper shows its plugins.
  • When you are a superuser or tenant administrator, the pages carry the system tray. Its tools open in windows of the tenant, where you are signed in in the browser; the local server's pages cannot frame them.
  • The data model. On a development environment, the types, properties, roles and grants the manifest declares are put on the tenant when they change, and at the start when they are not there yet: the draft's data model, without a deploy. Renames and removals wait for the deploy.
  • Elsewhere. Changes that only a deploy applies — types, roles, grants, schedules — are pointed out. On a sandbox environment (mode sandbox in a workspace, "sandbox": true in a one-app project), jetstack dev deploys them right away without activating the release, as it does at the start when this checkout has not applied the manifest's configuration.

Options:

  • --port <n> — the local port (default 8787, or the next free one).
  • --dir <folder> — serve this folder instead of the build output.
  • --proxy <url> — front your own front-end dev server instead of a folder, for example a bundler's on http://127.0.0.1:5173. Pages from it get the runtime too. Its live-reload connection is not forwarded; point it at its own server.
  • --branch <name> — work on a branch of the app's draft instead: your files sync with the branch, and the routes and the log are the branch's. The data model is left alone: it reaches the environment from the app's draft once the branch is merged.

jetstack dev needs a sign-in from jetstack login: it acts as you. Ctrl-C stops it; the draft stays, and you publish it in the Studio, on the app's page or with jetstack draft publish.

jetstack dev syncs the manifest, server/ and migrations/. For the rest of the app — src/ of an app built in the Studio, static/ — use jetstack draft pull and push.

draft pull, draft push, draft status, draft build, draft publish

The app's draft on an environment is the copy that the Studio, AI agents and the app's page all change. These commands bring it into the app's folder and send your changes back, in a workspace with --app or inside apps/<slug>/. The folder is laid out as the bundle is: the manifest, public/, server/ and the rest at its root.

  • draft pull writes the draft's files into the folder. It never overwrites a file you changed since your last pull; --force does.
  • draft push sends what you changed since your pull: new and changed files, and removals. Each file names the version you pulled, so a file that someone changed meanwhile (in the Studio, or an agent) is reported and not sent. The others go through. Then:
    • take theirs with draft pull --force;
    • or send yours over theirs with draft push --force.
  • draft status shows where the draft stands, and what changed here and there since your pull (--json for scripts). For a Studio app it also says whether a build of the current sources is ready to publish.
  • draft build builds a Studio app on this computer and sends the build to the draft (see below).
  • draft publish publishes the draft as a release (--label, --notes). Deploy it on the app's page. A Studio app that nothing has built since its last change is built by the platform first.

Only files an app keeps are exchanged: the manifest, index.html, studio.json, tsconfig.json, components.json, package.json, vite.config.ts, README.md and AGENTS.md at the root, and public/, server/, migrations/, src/, static/ and tests/. What was pulled last is remembered in .jetstack/draft-<env>.json in the app's folder.

draft status, pull, push and build take --branch <name> to work on a branch of the draft instead (remembered in .jetstack/draft-<env>--<name>.json); see below.

In an app built in the Studio (with studio.json), public/ is build output: it is neither pulled nor pushed, and jetstack build, plan and deploy refuse the app. Its releases are published from the draft.

draft build

Builds a Studio app here, with the project's own tools, and sends the build to the app's draft. The preview then shows that build, and publishing uses it. It is how an app that uses packages outside the app kit is built.

  1. It checks that the folder holds what the draft holds, as the build names the sources it was made from. If they differ, it lists the files and stops: send yours with draft push, or bring the draft's with draft pull.
  2. It runs the build: npm run build, or what --command names. Install the project's packages first (npm install).
  3. It sends the folder the build wrote: dist/, or what --out names. Its index.html must be among the files; it is changed to name the page's files from the app's base.
  4. With --publish it publishes the draft right after, with --label and --notes.
npm install
jetstack draft build --app crm
jetstack draft build --app crm --publish --label "Charts"

When the draft changed on the platform while the build ran, the build is refused: pull the change and build again.

draft branches, draft branch, draft merge, draft discard

A branch is a named copy of the app's draft, changed on its own and merged back.

  • draft branches lists them: name, title, revision, and who changed each last (--json for scripts).
  • draft branch <name> starts one with the draft's files (--title says what it is for). Names are lower-case letters, digits and dashes. Then work on it with --branch <name>: draft pull, draft push, jetstack dev.
  • draft merge <name> shows what merging the branch into the app's draft would do: the files that merge and the files that conflict. Nothing is written.
    • --apply merges it, as one change of the app's draft, and the branch goes. While files conflict nothing is merged.
    • --take draft or --take branch resolves every conflicting file with that side's version. To merge conflicting lines one by one, use the Studio.
    • --into branch works the other way: the app's draft's changes come into the branch, so that it merges cleanly later.
  • draft discard <name> throws a branch away with its files.

A branch is not published: merge it, then draft publish.

jetstack draft branch invoice-table --title "Invoice table"
jetstack draft pull --branch invoice-table
# … change files …
jetstack draft push --branch invoice-table
jetstack draft merge invoice-table
jetstack draft merge invoice-table --apply

types --env <env>

Writes the tenant's data model to .jetstack/types/, so editors know it:

  • records.d.ts — each record type as the API returns it, and the bodies a create, update and patch accept. Front-ends with a build step import it; plain JavaScript uses it with // @ts-check and a JSDoc import.
  • jetstack.php — stubs for the Internal API and a \Jetstack\Records\…Record class per type, for handlers and PHP services. Name what a handler uses with /** @var \App\InternalAPI\Api $jetstack */.
  • jetstack-sdk.d.ts — the typings of the App SDK as the tenant serves it; with records.d.ts, records('case') knows the fields of a case.

A project without a jsconfig.json or tsconfig.json also gets a jsconfig.json that type-checks the front-end and maps @jetstack/sdk to its typings. Existing editor settings are left alone; add the same paths entry to them yourself.

The model is described as your roles see it. Options:

  • --app-only — only the types this app declares.
  • --roles <r1,r2> — as these roles see it, for example the roles of the app's users (needs deployApps).
  • --types <a,b> — only these types.
  • --json — also keep the raw answer, with its JSON Schema, as records.schema.json.

Run it again after the data model changes. Whether to commit the files is your choice: committed, they let CI type-check without access to the tenant.

build

Runs the build and packs the bundle, then prints its content digest.

plan --env <env>

Uploads the build (reusing an identical release) and shows what deploying it would change.

deploy --env <env>

Builds, uploads, shows the plan and deploys. Options:

  • --no-activate — apply the configuration but do not switch the app to the release.
  • --allow-destructive — also remove types, properties and roles the release no longer declares.
  • --yes — skip the confirmation of environments marked confirm.
  • --label / --notes — recorded with the release.
  • --wait — when the app requires approval, wait until the deploy is approved and done, or turned down. The command fails unless the deploy went through. Ctrl-C stops waiting; the request stays.

The release records the git commit, and whether the working tree was dirty.

When the app requires approval, deploy prints waiting for approval and the deployment number, and ends successfully. Nothing changes until another person approves.

promote --to <env> [--from <env>] [--tag] [--remove <selector,…> | --remove-all]

In a workspace (no app chosen): the committed tenant/ and every app's release live on development, to the environment named — one plan, one confirmation, then the tree and the apps in order, recorded there as one history entry with the commit. What the tree no longer has stays on the target unless --remove names it (selectors from the plan, comma-separated) or --remove-all is given; the plan lists what stays. Removing what holds records also needs --allow-destructive. tenant/ must be committed (--allow-dirty overrides). Under the Reviewed policy, the environment's direct changes not yet pulled stop it. --tag tags the commit prod-<date>-<n> and names the entry the same; --json prints the plan and the outcome. See Promotion And Packages.

With --app (or inside an app's folder), it deploys that app's release live in --from (or --release <id>) to --to, unchanged. It is refused if the release's digest changes on the way. The copy records where it came from; see Environments And Promotion.

package export <root>... [--out <file>], package import <file> [--prefer package|here], package roots

Configuration packages between unrelated tenants: export roots such as type:client with everything they need, and import them elsewhere after a preview, merged three ways against what the last import from the same tenant brought. --prefer decides every conflict: the package's side or this environment's. See Configuration Packages.

approve --env <env> [<id>]

Shows the plan of a deploy that waits for approval, asks for confirmation, then approves and runs it. Without an id it takes the only waiting deploy, or lists them when several wait.

  • The plan includes the data migrations it would run. --show-code prints their code, taken from the release itself.
  • --tenant [<id>] approves a configuration apply waiting for approval instead. A tenant apply on production waits when its migrations need a second person. The command lists its migrations (--show-code prints them), then applies and follows it. jetstack reject --tenant <id> turns one down, or withdraws your own.
  • You cannot approve a deploy you requested, and a token cannot approve at all: sign in with jetstack login. A token in JETSTACK_TOKEN_<ENV> wins over your sign-in, so unset it in that shell.
  • If the plan changed since the request, the deploy is turned down and has to be requested again.
  • --yes skips the confirmation.

reject --env <env> <id> [--notes <reason>]

Turns down a deploy that waits for approval, or withdraws your own request. The reason is recorded with the deployment.

envs

Shows the live release of the app in every environment of the project file: its number, content digest, version and upload time, where it was promoted from, and how many deploys wait for approval. Environments marked with the same letter run the same code:

staging     [A] #14    9f3c1b2e7a40 1.4.0      09:12:40 ← dev #31
prod        [B] #12    51d0aa3c9e18 1.3.2      2026-09-21 16:05:11 1 waiting for approval
Environments with the same letter run the same code.

--json prints the raw list. An environment you are not signed in to is listed as such.

rollback --env <env> [--to <id>]

Deploys the previous release again, or the one given. Rollbacks never wait for approval, and never run data migrations: a release whose migrations have not run in the environment can only be deployed. The output lists the migrations that stay applied.

cancel --env <env> <id>

Stops a deploy that is still running its blocking data migrations (status migrating). The live release stays. Finished migrations stay applied, and the next deploy of the release carries on where this one stopped.

migration new <name> [--background]

Adds a data migration to the app. It writes three things:

  • migrations/<date>-<name>.php, from a template;
  • the migration's entry in the manifest, setting compat.platformApi to >=4;
  • a test, tests/<Name>MigrationTest.php.

Without --background the migration is blocking, so it runs before the release goes live. Replace the description's "TODO" before deploying; validate reports it.

With --tenant it is a migration of the tenant configuration instead: tenant/migrations/<id>.json and <id>.php, applied with jetstack tenant apply (see Configuration As Code).

migrations --env <env>

Where data migrations stand in the environment, each with its mode, status, progress and error:

  • in an app's folder, or with --app: that app's migrations;
  • in a workspace without an app chosen: the platform's and every app's.

--json gives the raw list. Exits 1 when a migration has failed.

migrations policy [confirm|approval] --env <prod>

What running data migrations on production needs: confirm (the person deploying confirms them; the default) or approval (a second person approves them). Without a value it shows the current setting. Tenant administrators and superusers change it, signed in as themselves; --reason goes into the Event Log. See Data Migrations On Production.

On production, deploy and promote list the migrations they would run and ask about them separately; --yes does not answer that question. A script passes --allow-migrations after the person agreed.

migrations retry <id> --env <env>

Runs a failed migration again. A background one runs with the next scheduler tick; a blocking one runs with the next deploy of the release.

migrations run <id> [--again] and migrations reset <id>

On development only, while writing a migration:

  • run runs it from the live release now, to the end; with --again, from the start;
  • reset makes the next deploy run it again.

Production runs migrations only through deploy and promote.

releases --env <env> and status --env <env>

List the releases (--json for a machine-readable list), and show the live release with recent deployments.

logs --env <env> [-f] [--limit <n>] [--runtime]

Shows the app's log: handler errors with their error ids, and $jetstack->utils->log() lines. -f keeps following. --runtime shows the output of a managed service's live container instead: its web server's access log and PHP's errors.

events --env <env> [--failed] [--retry [<id>]]

Lists the app's latest event deliveries — delivered, waiting for a retry, or given up — with attempts and errors. --failed shows only the ones waiting for a retry, --status <status> any one status, --limit <n> more of them, --json the raw list. --retry delivers failed and dead events again: all of them, or the one whose id follows.

runtime --env <env> [--rotate | --revoke]

For an app with its own service (External Runtime, Managed Runtime): shows the runtime the live release uses, the runtimes the tenant offers, the state of the service's credential and where its signing keys are published. For a managed service it also lists its containers: which is live, which are starting, failed or stopped.

  • --rotate creates a new credential and prints it once. Put it into the service's configuration as JETSTACK_APP_CREDENTIAL; the previous credential stops working at once.
  • --revoke revokes the credential, so the service can no longer call the platform.

Both ask first, even where deploys do not; --yes skips the question. --json prints the answer as JSON. A deploy token needs apps:deploy to change the credential and apps:read to see it.

tenant pull | status | plan | apply --env <env>

Keep the tenant's configuration in the repository — tenant/ in a workspace, jetstack/ in a one-app project; see Configuration As Code:

  • tenant pull brings the environment's changes into the folder. In a git repository, each entry of its change history since your last pull becomes a commit on jetstack/<env> — with the entry's author, time and message — and that branch is merged into yours; a conflict is a git conflict. The folder must have no uncommitted changes. Without git, or with --no-git, the tenant merges its changes into your files instead: your edits stay, and a field changed on both sides becomes a conflict, written to <file>.conflict.json, with an error exit. --force overwrites local edits.
  • tenant status says what changed in the tenant since your last pull, whether your tree differs from the tenant, and how many history entries are not pulled yet or wait for review.
  • tenant plan lists what applying the folder would create, change, remove and rename; --json prints the raw plan. It exits with an error when the tree defines something an app manages.
  • tenant apply shows the plan, asks before removing anything (--yes skips the question), applies the tree and follows the apply to its end. Types, properties, roles and other configuration that holds records are removed only with --allow-destructive. The command fails when the apply could not do everything, so a pipeline notices. It sends the commit it applies (when the folder is committed), which the history records.

Where the repository stands with each environment — the last entry taken and the commit that holds it — is kept in .jetstack/sync.json; commit it with the tree. The commands need the manageTenantConfig permission, or a tenant token in CI.

history [--env <env>] [--limit <n>]

The environment's newest change history entries: when, from where (builder, assistant, cli, mcp, rest, apply, deploy, promote, restore, …), who, the message, and where each stands: version <code>, from git <commit>, pulled, not in git, needs review.

history show <entry>, history diff <from> [<to>]

show prints one entry and what it changed against the entry before. diff prints what changed between two entries, or from one to the newest. An entry is named by its id (42) or by a version's code.

history name <entry> <code> [--notes <text>]

Publishes an entry as a version, which backend.version then reports. history name current <code> records the configuration as it is now and names it.

history policy [open|reviewed|locked] [--env <env>] [--reason <text>] [--yes]

Without a policy, shows production's change policy. With one, sets it. Only a tenant administrator or superuser signed in as themselves may set it, never a token. Production asks for confirmation first, and --reason goes into the event log. On development it says that development is always open: name production with --env prod.

restore <entry|version> [--env <env>] [--allow-destructive] [--yes]

Makes the environment's configuration and apps what they were in an entry. It shows the plan and the apps whose release changes, asks for confirmation, restores, and says which new entry recorded it. Removing what holds records needs --allow-destructive. Bring the result into git with tenant pull.

app pull [<slug>] [--env <env>] [--release <id>]

Brings an app's live release on an environment — one published from the tenant's Apps screen, for example — into apps/<slug>/. In a git repository it arrives as a commit on its own branch, merged into yours; with --no-git the files are written in place. --release takes another release than the live one.

login, logout, whoami

See Signing In.

self-update [--env <env> | --from <url>] [--to <x.y.z>] [--rollback]

See Staying Up To Date.

digest [dir]

Prints the content digest of an unpacked bundle directory, exactly as the platform computes it.

The Data Model, Records And Tenant Values

These commands do from the command line what the tenant's MCP tools do, with the same checks: the caller's own permissions decide, and a locked production refuses changes to its configuration. They work on development unless --env names another environment. On production (or any protected environment) they ask first, or need --yes; on development the options are the consent (--allow-destructive, --write, --mode replace).

Changes to the data model are recorded in the environment's change history, by you, from the CLI. Bring them into the workspace with jetstack tenant pull --env dev, or add --pull to the command.

The data model

  • model [<type>] lists every type (with the app that owns it, if any), or one type with its properties.
  • model create-type <system_name> [--name] [--plural] [--role entity|enumeration|checklist] [--config <json>] creates a type; --config adds anything else, such as "properties": […].
  • model add-property <type> <system_name> [--name] [--type pText|pInt|…|<type>] adds a property; a type's system name as --type makes a link to that type.
  • model update-property <type.property> --config <json>, model remove-property <type.property>, model remove-type <type>.
  • model enum add|remove|rename <type> <code>=<Title> … manages an enumeration's items.
  • model apply <file|-> applies a batch of instructions ({"method": "createType", "args": {…}}, as the MCP tool model_instructions_execute takes them).

Every model command checks the whole change first and changes nothing when something is wrong; --dry-run only checks. Removing types, properties or enumeration items deletes their records and needs --allow-destructive. Types and properties an app's manifest declares are refused: change them in the app and deploy it.

Records and SQL

  • records list|get|create|update|delete <type> … work with records through the REST API (--data <json> for create and update).
  • records import <type> <file> imports a JSON list or a CSV file with a header row, in mode append (default), merge or sync (matching by --pair-by <field>), or replace.
  • records action <type> <id> <actionId> [--args <json>] runs one of the type's actions.
  • sql "<statement>" runs one statement with {type.property} shortcodes, as you: read-only unless --write, under a time limit, at most 1000 rows. - reads the statement from standard input.

Tenant values, users and tokens

  • secrets lists the Secret Store's names; secrets set <name> takes the value from standard input (or asks without echoing it), never from an argument, so it does not land in the shell history; secrets delete <name>.
  • env lists the environment values apps read (their manifest's env); env set <name> <value>, env delete <name>.
  • users [--role <role>] [--search <text>] lists users and their roles; users roles add|remove <user> <role> gives or takes a role, as far as you may assign it.
  • token create --scope <scopes> [--days <n>] [--description <text>] creates a token for a pipeline: apps:deploy[:<slug>] and apps:read[:<slug>] make a deploy token (add apps:test for jetstack test in CI), tenant:read or tenant:apply a tenant token. It is shown once. token lists them, token revoke <id> revokes one.

Permissions And Permission Queries

The same as the Permissions screen, with the same rules (see Roles And Permissions). Changes are recorded in the change history; on production they ask first, or need --yes.

  • perms [--role <role>] [--type <type>] lists the explicit grants by role: system permissions, types and properties (type.property), each action with its permission query in brackets; a leading - marks a denial.
  • perms grant <role> <target> <action>... [--query <query>] gives a role actions on a type, a property (type.property) or system (system permissions as actions); --query limits a type grant to the records the query selects. A type grant also lets the role see the type's properties (list, show) and write them (create, edit); take single properties away after it. * stands for every action. Actions may also be comma-separated.
  • perms revoke <role> <target> <action>... takes actions away.
  • perms explain role:<role>|user:<login> <target> <action> [--record <id>] says whether the role or user may do it, and why, role by role; --record checks one record.
  • queries [--type <type>] lists permission queries, with the app that owns each and how many grants use it; queries show <name> shows one with its filters and grants.
  • queries set <name> --type <type> --filters <json|@file|-> [--title] [--description] creates a permission query or replaces its filters, in the manifest filter format ({"property": "owner", "type": "is", "param": "{$currentUser.id}"}). Grants that use it change with it.
  • queries delete <name> deletes a query no grant uses.

Grants and queries an app's manifest declares are refused: change them in the app.

Testing As Each Role

jetstack test runs the workspace's tests against development, each part of them as a throwaway user holding the roles the test names. A test answers "may a case reader open someone else's case?" the way the platform answers it, not the way the code reads.

Writing Tests

Tests live in apps/<slug>/tests/*Test.php (an app's) and tests/*Test.php (the workspace's). Each is a class extending Jetstack\Testing\TestCase; every public method whose name starts with test is a test, and setUp() / tearDown() run around each. They are plain PHP: there is nothing to install, and they never go into the app's bundle.

use Jetstack\Testing\TestCase;

final class CasesTest extends TestCase
{
    public function testReadersSeeOnlySharedCases(): void
    {
        $master = $this->as('case_master');
        $reader = $this->as('case_reader');
        $caseId = $this->expectAllowed($master->post('/cases', ['title' => 'Lease']))->json('case.id');

        $this->expectDenied($reader->get("/cases/{$caseId}"));
        $this->expectAllowed($master->post("/cases/{$caseId}/share", ['readerIds' => [$reader->id()]]));
        $this->expectAllowed($reader->get("/cases/{$caseId}"));
        $this->assertSame('query', $reader->explain('cp_case', 'list')['effective']['allowed']);
    }
}
  • $this->as('<role>', …) is a test user holding those roles, made on first use and removed when the run ends. It also holds the built-in user role, as every signed-in person does. It calls:
    • the app's routes: get('/cases'), post(…), put, patch, delete. A path that does not start with /api/ is a route of the app whose tests/ folder the test is in. ->app('<slug>') names another app;
    • records: records('<type>')->list(), get($id), create([…]), update($id, […]), delete($id);
    • MCP tools: tool('find_case', [...]), by name or the end of it;
    • explain('<type>' | '<type>.<property>' | 'system', '<action>', $record): perms explain for that user.
  • $this->guest() calls public routes, signed in as nobody. $this->me() calls them as you.
  • Every call returns a response: status, json('a.b.0'), ok() (2xx), denied() (401, 403, or 404 — a record outside the role's rule is not found).
  • expectAllowed($response) and expectDenied($response) check the answer and return it. The usual assertions are there too: assertSame, assertTrue, assertContains, assertCount, … and skip('why'). A file uploads as ['file' => new CURLFile($path)].

Running Them

  • test [--app <slug>] [--filter <text>] runs the tests, by default on development. Inside an app's folder, or with --app, only that app's. --filter keeps those whose app/Class::method contains the text.
  • It runs only on a development (sandbox) environment. A production tenant refuses to make test users even when the workspace calls it development.
  • It prints each test and a summary, and exits with 1 when one failed or errored. --tap prints TAP; --junit <file> also writes JUnit XML; --json prints the results as JSON.
  • test new <Name> [--app <slug>] adds tests/<Name>Test.php with an example built from the app's first role and route. app new writes one too.

Test users are named test-…@test.invalid and expire after an hour, or when the run ends, whichever comes first. The scheduler removes any a broken run leaves behind. They are data, not configuration, so they never appear in the change history.

Continuous Integration

ci init --github writes .github/workflows/jetstack.yml:

  • on every push to the main branch, it installs the CLI from the development tenant with the pipeline's token, deploys every app there and runs the tests, keeping the JUnit results;
  • on a v* tag, or when started by hand, it promotes to production — the committed tenant/ and the tested releases, as one change — in the GitHub environment production (add reviewers there to require an approval).

It needs two repository secrets:

  • JETSTACK_TOKEN_DEV, from jetstack token create --env dev --scope apps:deploy,apps:test. The apps:test scope lets the pipeline make test users and ask perms explain on development. Only someone who may create users and open the Permissions screen can create such a token.
  • JETSTACK_TOKEN_PROD, from jetstack token create --env prod --scope apps:deploy,tenant:apply: a promotion token, which may apply the tree and deploy the apps.

Example: A Pipeline

jetstack ci init --github writes a complete one (see Continuous Integration). By hand, the steps are:

- run: curl -fsSL -H "Authorization: Bearer $JETSTACK_TOKEN_DEV" https://<dev tenant>/downloads/jetstack/install.sh | sh && echo "$HOME/.local/bin" >> "$GITHUB_PATH"
  env: { JETSTACK_TOKEN_DEV: '${{ secrets.JETSTACK_TOKEN_DEV }}' }
- run: jetstack deploy --all --env dev --yes
  env: { JETSTACK_TOKEN_DEV: '${{ secrets.JETSTACK_TOKEN_DEV }}' }
- run: jetstack test --env dev --junit test-results/jetstack.xml
  env: { JETSTACK_TOKEN_DEV: '${{ secrets.JETSTACK_TOKEN_DEV }}' }
- run: jetstack promote --from dev --to prod --yes
  env:
    JETSTACK_TOKEN_DEV: ${{ secrets.JETSTACK_TOKEN_DEV }}
    JETSTACK_TOKEN_PROD: ${{ secrets.JETSTACK_TOKEN_PROD }}

Tenant Settings

These keys are not on the Application settings screen; set them in the tenant configuration.

cliSignIn

What it controls:

  • whether people can sign in the CLI through the browser, and whether existing CLI sign-ins keep working

Default:

  • true

How to configure it:

  • set it to false to allow only deploy tokens, for example in a tenant where every change must come through a pipeline

What it affects:

  • the approval page and the CLI sign-ins already made: while it is off, the page answers "not found" and every CLI sign-in is refused
  • deploy tokens and API keys are unaffected

cliTokenTtlHours

What it controls:

  • how long a CLI sign-in lasts, in hours

Default:

  • 12 — a working day; at most 24

How to configure it:

  • shorten it where laptops are shared or the tenant holds sensitive data
  • sign-ins made earlier keep the lifetime they were given

What it affects:

  • CLI sign-ins only; deploy tokens have their own validity

customAppsDevLoop

What it controls:

  • whether developers can run apps with jetstack dev: the dev drafts, and running an app's routes in a draft from their computer

Default:

  • true

How to configure it:

  • set it to false in a production tenant where nobody should run unreleased handler code, even in their own draft

What it affects:

  • jetstack dev stops at its start; drafts made and previewed on the app's page are unaffected

schemaApi

What it controls:

  • whether the tenant describes its data model to developers and their tools: the schema.* endpoints, and so jetstack types

Default:

  • true

How to configure it:

  • set it to false in a tenant that must not describe its model through the API, even to users allowed to read it

What it affects:

  • schema.* answers "not found" while it is off; records and every other endpoint are unaffected

cliOperations

What it controls:

  • whether the REST commands behind jetstack model, records import and action, sql, secrets, env, users and token answer (model.*, records.*, sql.*, secrets.*, env.*, users.*, tokens.*)

Default:

  • true

How to configure it:

  • set it to false in a tenant whose model, values and users must change only in the UI

What it affects:

  • those commands answer "forbidden" while it is off; MCP, the collection endpoints (records list, get, create, update, delete) and the rest of the CLI are unaffected
  • the commands need a person's sign-in or API key in any case: deploy and tenant tokens never reach them

testUsers

What it controls:

  • whether jetstack test may make throwaway test users on this tenant (the testUsers.* commands), and whether their tokens work

Default:

  • true

How to configure it:

  • set it to false in a development tenant where nobody should make test users

What it affects:

  • a production environment refuses test users whatever this setting says
  • while it is off, testUsers.create answers "forbidden" and existing test users' tokens stop working; the scheduler still removes expired test users