# Zapper documentation This raw document is generated from the Markdown files that power the Zapper VitePress documentation site. # Source: docs/index.md # Zapper A lightweight dev environment runner for local multi-service projects. ## Install ```bash npm install -g pm2 @mp-lb/zapper ``` ## Create `zap.yaml` ```yaml project: myapp env: [.env] native: backend: cmd: pnpm dev env: "*" frontend: cmd: pnpm dev cwd: ./frontend env: "*" docker: postgres: image: postgres:15 ports: - 5432:5432 ``` ## Run ```bash zap up zap status zap down ``` ## Add Tasks Use tasks for one-off project commands that should share the same local environment. ```yaml tasks: seed: preconditions: - test -n "$DATABASE_URL" cmds: - pnpm db:seed console: interactive: true silent: true cmds: - psql "$DATABASE_URL" ``` ```bash zap task seed zap task console ``` For full reference docs, see [Commands](commands.md), [Configuration](configuration.md), [Services](services.md), and [Tasks](tasks.md). For profile-based env files, service selection, and isolated stacks, see [Profiles](profiles.md). For readiness waits and dependency startup behavior, see [Health Checks](healthchecks.md). For packaging and local machine runtime plans, see [Local Runtime Compatibility](local-runtime.md). For the command result and rendering contract, see [Command Output](output.md). For CLI development, testing, and release workflow, see [CLI Development](cli-development.md). For the local menu bar app development loop, see [macOS Development](macos-development.md). # Source: docs/commands.md # Commands Zapper commands operate on the current project by default. Use `--config` to point at a specific config file. ## Global Options ```bash zap --config prod.yaml up zap --profile e2e up zap --debug restart zap --verbose task build ``` - `--config ` uses a specific config file instead of `zap.yaml`. - `--profile ` uses a profile for this invocation without changing saved profile state. - `-v, --verbose` increases logging verbosity. - `-q, --quiet` reduces logging output. - `-d, --debug` enables debug logging. ## Start And Stop Services ```bash zap up zap up --open zap up backend zap up api worker db zap down zap down backend zap restart zap restart api zap r api worker zap watch zap watch api ``` `zap up`, `zap down`, and `zap restart` accept service names and service aliases. Unknown names are reported, and valid names in the same command still run. `zap up ` includes dependencies. Explicit health checks decide whether dependents wait for those dependencies to be ready. `zap restart ` restarts only the targeted service. `zap watch` starts Docker services that define `watch` rules and keeps running. When a watched path changes, Zapper either restarts the container or rebuilds and restarts it based on the rule action. ## Status And Logs ```bash zap status zap status api db zap ls zap ls --extended zap ls --json zap logs api zap logs api worker --no-follow zap startup-log api ``` `zap ls --extended` includes instance, dangling, and alien resource inventory. When passing multiple services to `zap logs`, use `--no-follow`. If a service fails during startup, Zapper saves the last startup attempt output under `.zap/logs/`. Use `zap startup-log ` to inspect it. ## Tasks ```bash zap task zap task seed zap run seed zap task build --target=prod zap task test -- --coverage zap task build --list-params zap task seed --force zap task deploy --interactive ``` `zap run` is an alias for `zap task`. `--force` runs a task even when its status checks say it is already up to date. `--interactive` prompts for missing required task parameters instead of failing immediately. Task aliases work anywhere a task name is accepted, including `--list-params`. See [Tasks](tasks.md) for task configuration. ## Project Metadata ```bash zap launch zap launch "API Docs" zap links zap home zap notes zap open zap o "API Docs" zap open --home zap open --non-interactive ``` `zap launch` opens the configured homepage or a named link without prompting. `zap open` shows an interactive link picker when run without a link name. Use the arrow keys to choose a link, then press Enter to open it, or press Ctrl+C to abort. `zap open ` and `zap o ` open a named link directly. `zap open --home` explicitly opens the configured homepage. `zap open --non-interactive` keeps the old script-friendly behavior and opens the configured homepage. See [Project Metadata](project-metadata.md) for `homepage`, `links`, and `notes`. ## Project Utilities ```bash zap init zap init --instance e2e zap init -R zap validate zap stack id zap stack current zap stack list zap reset zap kill zap kill my-old-project zap kill --force zap clone zap clone api web ``` `zap init` ensures local state exists for the selected instance and runs `init_task` if configured. `zap init -R` re-randomizes configured ports. `zap stack` inspects the selected stack and known profile stacks. `zap kill ` does not require a local `zap.yaml`; it targets resources by prefix. ## Instances And Volumes ```bash zap up --instance e2e zap instance label zap instance label "local checkout" zap volume list postgres zap volume list postgres --json zap volume list --managed --id-only postgres zap volume prune zap volume reset ``` See [Instances](instances.md) and [Services](services.md) for the related configuration behavior. ## Profiles And Env ```bash zap profile list zap profile current zap profile use e2e zap profile reset zap --profile e2e up zap env --service api zap env api ``` Profiles combine env file stacks, service selection, and optional stack isolation. `zap profile use ` updates saved local state; `--profile ` is a one-command override. `zap env ` inspects the resolved environment for a service and no longer switches state. ## System Registry System commands inspect machine-wide Zapper state rather than only the current repository. They are used by desktop integrations, project discovery, and orphaned resource cleanup. ```bash zap system projects zap system projects --json zap system registry prune zap system registry forget zap system registry repair zap system resources audit zap system resources cleanup zap system resources cleanup --include-volumes zap global list zap global ls zap g ls zap global prune zap global prune --force zap g kill --force ``` `zap system projects` validates registered project roots and config paths. Missing projects stay in the registry with `state: "stale"` so CLI and desktop views share one source of truth. `zap global prune` audits stale registry entries, PM2 processes, Docker containers, and generated Docker volumes before deleting anything. Use `--force` or `-y` for non-interactive runs. For `zap global list`, `--all` is a legacy no-op; the command always lists all discovered global Zapper resources unless you pass a project name. On macOS, the system registry defaults to `~/Library/Application Support/Zapper/registry.json`. On Linux, it defaults to `$XDG_STATE_HOME/zapper/registry.json`, or `~/.local/state/zapper/registry.json` when `XDG_STATE_HOME` is unset. Set `ZAPPER_SYSTEM_STATE_HOME` to override the directory, or `ZAPPER_DISABLE_SYSTEM_REGISTRY=1` to disable registry writes. ## JSON Output Most non-streaming commands support `--json`. Action commands also support JSON Lines streaming with `--jsonl` where implemented. Examples of JSON-capable commands include `up`, `down`, `restart`, `clone`, `reset`, `kill`, `status`, `ls`, `profile`, `env`, `state`, `config`, `launch`, `links`, `home`, `notes`, `init`, `instance`, `system`, and git subcommands. When `--json` is enabled, Zapper suppresses incidental human logs and warnings so stdout stays parseable. Streaming commands keep stream output and are not JSON-encoded: ```bash zap logs [more-services...] [--no-follow] zap startup-log [more-services...] zap task ``` For the command result and rendering contract, see [Command Output](output.md). # Source: docs/configuration.md # Configuration Zapper projects are configured with a `zap.yaml` file in the project root. `packages/cli/src/config/schemas.ts` is the source of truth for supported fields. ## Minimal Config ```yaml project: myapp native: api: cmd: pnpm dev ``` ## Top-Level Fields ```yaml project: myapp env: [.env.base, .env] profiles: default: env_files: [.env.base, .env] services: "*" e2e: env_files: [.env.base, .env.e2e, .env] services: [api, worker, postgres] isolate: true ports: - FRONTEND_PORT - BACKEND_PORT init_task: seed git_method: ssh task_delimiters: ["{{", "}}"] native: # process definitions docker: # container definitions volumes: # top-level Docker volume declarations secrets: # top-level Docker secret declarations tasks: # task definitions homepage: http://localhost:3000 notes: "API: http://localhost:${API_PORT}" links: - name: API Docs url: http://localhost:${API_PORT}/docs ``` - `project` is required and is used as the PM2/Docker namespace. - `env` defines root environment file stacks. - `env_files` is accepted as a compatibility alias for root `env`. - `profiles` defines named runtime profiles with env files, service selection, and optional stack isolation. - `ports` lists uppercase env var names that Zapper assigns per instance. - `init_task` names a task to run after `zap init`. - `git_method` controls repo clone URLs: `ssh`, `http`, or `cli`. - `task_delimiters` changes task template delimiters. - `native` defines local PM2-managed processes. - `docker` and `containers` define Docker-managed services. - `volumes` declares reusable Docker named volumes. - `secrets` declares local file/env-backed secrets for Docker services. - `processes` is accepted as a legacy process form. - `tasks` defines one-off commands. - `homepage`, `links`, and `notes` expose project metadata to CLI and tools. ## Environment Files Root `env` is a file stack: ```yaml env: [.env.base, .env] ``` Use profiles when you need named env file stacks or service subsets: ```yaml profiles: default: env_files: [.env.base, .env] proddata: env_files: [.env.base, .env.proddata, .env] services: "*" ``` Select a saved profile or use one for a single command: ```bash zap profile use proddata zap --profile proddata up zap profile reset ``` Later files override earlier files. Root `env_files` remains a compatibility alias, but new configs should prefer `env`. Profile service subsets automatically include `depends_on` dependencies. A profile can list a native app service and Zapper will still include the Docker services that app depends on. See [Environment Variable Management](env-var-mgmt.md) for detailed resolution rules. ## Config Interpolation String values inside service, task, metadata, build, watch, volume, and secret configuration support shell-style interpolation from the resolved root env stack, assigned ports, and the current process environment: ```yaml env: [.env] docker: api: image: myapp/api:${API_TAG:-dev} ports: - "${API_PORT?API_PORT is required}:3000" ``` Supported forms: - `${VAR}` expands to the variable value or an empty string. - `${VAR:-default}` uses `default` when the variable is unset or empty. - `${VAR?message}` fails config loading with `message` when the variable is unset or empty. - `$$` emits a literal `$`. ## Port Assignment Define port variable names in config and initialize them with `zap init`: ```yaml project: myapp ports: - FRONTEND_PORT - BACKEND_PORT - DB_PORT ``` Assigned ports have highest precedence over values from `.env` files. This supports multiple instances of the same project without port collisions. Most config-backed commands initialize missing instance state automatically. Read-only commands such as `zap status`, `zap ls`, `zap state`, `zap logs`, and `zap startup-log` do not create or update `.zap/state.json` just by loading the project. State writes are protected by a local lock and saved atomically. If `.zap/state.json` is malformed, commands that need to update state fail instead of replacing it with default state. Interpolation uses assigned port values: ```txt FRONTEND_PORT=3000 FRONTEND_URL=http://localhost:${FRONTEND_PORT} ``` After initialization, if `FRONTEND_PORT` is assigned `54321`, `FRONTEND_URL` resolves to `http://localhost:54321`. ## Init Task Set `init_task` to run a task after initialization: ```yaml init_task: seed tasks: seed: cmds: - pnpm db:seed ``` `zap init` performs normal initialization and then runs the task. ## Git Cloning For multi-repo projects, add `repo` to services and choose a clone method: ```yaml project: myapp git_method: ssh native: api: cmd: pnpm dev cwd: ./api repo: myorg/api-service ``` | Method | URL Format | Notes | | ------ | ----------------------------------- | ------------------- | | `ssh` | `git@github.com:myorg/repo.git` | Requires SSH key | | `http` | `https://github.com/myorg/repo.git` | May prompt for auth | | `cli` | Uses `gh repo clone` | Requires GitHub CLI | Repos are cloned to the service `cwd`. ```bash zap clone zap clone api zap clone api web ``` # Source: docs/services.md # Services Zapper treats local processes and containers as peer services. Native services run through PM2. Docker services run through Docker CLI. ## Native Processes ```yaml native: api: cmd: pnpm dev ``` Full native process shape: ```yaml native: api: cmd: pnpm dev aliases: [be, backend] cwd: ./backend env: "*" depends_on: [postgres] repo: myorg/api-repo healthcheck: 10 ``` - `cmd` is required. - `aliases` are alternate names accepted by service-targeting commands. - `cwd` is relative to the project root. - `env` controls env routing for the service. - `depends_on` includes dependencies when starting this service. - `repo` is used by `zap clone`. - `healthcheck` can be a delay, HTTP URL, or explicit health check object. ## Docker Services ```yaml docker: redis: image: redis:latest ports: - 6379:6379 ``` Full Docker service shape: ```yaml docker: postgres: image: postgres:15 build: context: ./postgres dockerfile: Dockerfile.dev target: dev args: POSTGRES_VERSION: "15" aliases: [db, pg] ports: - 5432:5432 env: .zap/env/postgres.yaml volumes: - /var/lib/postgresql/data - postgres-logs:/var/log/postgresql - ./init.sql:/docker-entrypoint-initdb.d/init.sql networks: [backend] command: postgres -c log_statement=all depends_on: [other] healthcheck: type: delay seconds: 10 watch: - path: ./postgres action: rebuild secrets: - db_password ``` - `image` names the image to run. It is required unless `build` is set. - `build` builds an image from local source before the container starts. - `ports` use `host:container` mappings and support `${VAR}` interpolation. - `env` controls env routing for the container. - `volumes` supports managed volumes, named volumes, and bind mounts. - `networks` passes Docker network names. - `command` overrides the image command. Use a string for simple commands, or an array when you need exact argument boundaries: ```yaml docker: postgres: image: postgres:15 command: ["postgres", "-c", "log_statement=all"] ``` String commands are split into Docker arguments with basic shell-style quoting. Array commands are passed through exactly. `command` does not run through a shell unless you explicitly invoke one: ```yaml docker: app: image: alpine command: ["sh", "-c", "echo $HOSTNAME"] ``` - `watch` is used by `zap watch` for local restart/rebuild loops. - `secrets` grants the service access to top-level Docker secrets. When `build` is set without `image`, Zapper tags the local image as `zap..:dev`. Supported `build` forms: ```yaml docker: api: build: ./api worker: image: myapp-worker:dev build: context: ./worker dockerfile: Dockerfile.dev target: dev args: NODE_ENV: development ``` ## Environment Routing Service `env` has three modes: ```yaml env: "*" ``` Pass all values from the root env stack. ```yaml env: [.env.common, .env.frontend, .env.frontend.user] ``` Use a service-specific env file stack instead of the root stack. ```yaml env: .zap/env/api.yaml ``` Filter the root env stack through a strict whitelist file: ```yaml vars: - DATABASE_URL - JWT_SECRET ``` Inline variable whitelists are not supported in `zap.yaml`. Arrays under service `env` are file stacks. ## Dependencies Use `depends_on` to include related services when starting a target: ```yaml docker: postgres: image: postgres:15 native: api: cmd: pnpm dev depends_on: [postgres] ``` When you run `zap up api`, Zapper also starts `postgres`. `depends_on` affects service selection by default: - `zap up ` includes transitive dependencies. - Services without explicit health checks can start in the same wave as their dependents. - If a dependency defines `healthcheck`, dependent services wait until that check passes before starting. - `zap down` stops targeted services in a single wave. - `zap restart ` restarts only the targeted service, not its dependencies. See [Health Checks](healthchecks.md) for readiness behavior. ## Profiles Profiles are top-level runtime selections. A profile can choose the env file stack, the services that participate in the stack, and whether it gets an isolated stack instance. ```yaml profiles: default: env_files: [.env.local, .env] services: [api, postgres] e2e: env_files: [.env.local, .env.e2e, .env] services: [api, postgres, worker] isolate: true native: api: cmd: pnpm dev worker: cmd: pnpm worker ``` ```bash zap up zap profile use e2e zap restart zap profile reset ``` Profile service selection includes dependencies automatically. If a profile lists `api` and `api` depends on `postgres`, `postgres` participates in that profile without needing to be listed directly. ## Docker Volumes Zapper supports Compose-style mounts, top-level named volumes, and a managed volume form. ```yaml volumes: shared-cache: name: myapp-cache external-data: external: true docker: postgres: image: postgres:15 volumes: - /var/lib/postgresql/data - /var/lib/postgresql/wal:ro - postgres-logs:/var/log/postgresql - shared-cache:/cache:ro - ./init.sql:/docker-entrypoint-initdb.d/init.sql - internal_dir: /var/lib/postgresql/wal mode: ro - name: postgres-config internal_dir: /etc/postgresql - type: bind source: ./fixtures target: /fixtures read_only: true - type: volume source: external-data target: /data read_only: true ``` When a volume entry is only a container path, or an object without `name`, Zapper generates a Docker volume name and stores it under the selected instance in `.zap/state.json`. Each instance gets its own generated volume for the same service/path pair. Explicit named volumes keep Compose-style behavior and are shared anywhere that name is reused. Top-level `volumes` can map a logical name to a Docker volume `name`, or mark it `external` so Zapper does not explicitly create it. Bind mounts such as `./init.sql:/container/path` are omitted from `zap volume list`. ```bash zap volume list postgres zap volume list --managed postgres zap volume list --managed --id-only postgres zap volume prune zap volume reset ``` `zap volume prune` deletes generated Docker volumes that are still in state but no longer appear in `zap.yaml`. `zap volume reset` forgets generated assignments without deleting Docker volumes. ## Docker Secrets Top-level `secrets` define local secret material, and Docker services opt in per secret. Secrets are mounted read-only under `/run/secrets/` unless a service-specific target is provided. ```yaml secrets: db_password: env: POSTGRES_PASSWORD stripe_key: file: .secrets/stripe_key docker: postgres: image: postgres:15 secrets: - db_password api: image: myapp-api:dev secrets: - source: stripe_key target: /run/secrets/stripe/api_key ``` Env-backed secrets are written to `.zap/secrets/` with owner-only permissions before the container starts. File-backed secrets are mounted directly from the project root. ## Docker Watch `zap watch` starts the selected watched Docker services, then watches their configured paths. ```yaml docker: api: image: myapp-api:dev build: ./api watch: - path: ./api/src action: rebuild - path: ./api/config action: restart ``` ```bash zap watch zap watch api ``` `restart` calls `docker restart` for the running container. `rebuild` runs the normal Zapper restart path, so services with `build` rebuild their local image before the new container starts. ## Common Docker Examples ```yaml docker: postgres: image: postgres:15 ports: - 5432:5432 env: .env.postgres volumes: - /var/lib/postgresql/data redis: image: redis:7-alpine ports: - 6379:6379 mongodb: image: mongo:7 ports: - 27017:27017 volumes: - /data/db ``` # Source: docs/healthchecks.md # Health Checks Health checks are optional readiness gates for service dependencies. By default, Zapper does not wait after starting a service. ## Default Behavior `depends_on` means "include this service when starting the target service." It does not mean "wait for this service to be ready" unless the dependency has an explicit `healthcheck`. ```yaml docker: postgres: image: postgres:15 native: api: cmd: pnpm dev depends_on: [postgres] frontend: cmd: pnpm dev depends_on: [api] ``` With this config, `zap up frontend` includes `postgres`, `api`, and `frontend`, and starts them in the same wave when they are not already running. ## Waiting For Readiness Add `healthcheck` to a dependency when dependents should wait for it before starting: ```yaml docker: postgres: image: postgres:15 healthcheck: 10 native: api: cmd: pnpm dev depends_on: [postgres] ``` Here, `api` waits until `postgres` passes its health check. Numeric `healthcheck` values are delay checks measured in seconds. The explicit delay form is equivalent: ```yaml healthcheck: type: delay seconds: 10 ``` URL health checks poll until the endpoint returns a successful HTTP status: ```yaml native: api: cmd: pnpm dev healthcheck: http://localhost:3000/health frontend: cmd: pnpm dev depends_on: [api] ``` `frontend` starts after the `api` health URL responds successfully. Use the explicit HTTP form when you need polling controls: ```yaml healthcheck: type: http url: http://localhost:${API_PORT}/health timeout: 60 interval: 1 ``` - `timeout` is the maximum number of seconds `zap up` waits before moving on. - `interval` is the number of seconds between poll attempts. - If `timeout` is omitted, Zapper waits up to 120 seconds. - If `interval` is omitted, Zapper polls once per second. ## Status `zap status` reports a running service without a `healthcheck` as `up`. For delay checks, status is `pending` until the configured delay has elapsed. For HTTP checks, status is `pending` until the URL returns a successful response. ## Current Shape Supported forms: ```yaml healthcheck: 10 healthcheck: http://localhost:3000/health healthcheck: type: delay seconds: 10 healthcheck: type: http url: http://localhost:3000/health timeout: 60 interval: 1 ``` # Source: docs/tasks.md # Tasks Tasks are one-off project commands that can use the same env, cwd, and parameter interpolation model as the rest of Zapper. ## Basic Task ```yaml tasks: seed: cmds: - pnpm db:seed ``` ```bash zap task seed zap run seed ``` ## Task Options ```yaml tasks: seed: desc: Seed the database aliases: [s] cwd: ./backend env: .zap/env/backend.yaml silent: false interactive: false params: - name: count default: "10" desc: Number of records - name: env required: true desc: Target environment preconditions: - test -n "$DATABASE_URL" - sh: test -f prisma/schema.prisma msg: Missing Prisma schema status: - test -d node_modules cmds: - pnpm db:migrate - cmd: "pnpm db:seed --count={{count}}" silent: false ``` - `desc` is shown in task listings. - `aliases` are alternate names accepted by `zap task`. - `cwd` is relative to the project root. - `env` uses the same routing modes as service env. - `silent` hides Zapper task-start lines and command headers. - `interactive` inherits stdio directly for TTY-sensitive commands. - `params` defines named task parameters. - `preconditions` must pass before commands run. - `status` skips the task when every status command succeeds. - `cmds` runs shell commands or nested task calls in order. If a task name or alias is not defined, the command fails with `Task not found: . Check task names or aliases`. ## Output, Silent Mode, And Interactive Mode By default, Zapper prints each command before execution, then streams stdout and stderr in muted task output. Set `silent: true` on a task to hide Zapper's task-start line and command headers while preserving command output. Set it on a command object to hide that command's header, or on a nested task call to hide the nested task-start line and command headers. Set `interactive: true` on a task or command object for TTY-sensitive commands such as database shells, REPLs, SSH sessions, and CLIs that prompt for authentication. Interactive commands inherit stdio directly and skip Zapper's output recoloring. `interactive` on a task or command controls the spawned command's stdio. It is different from `zap task --interactive`, which prompts for missing required parameters before execution. ```yaml tasks: console: interactive: true silent: true cmds: - psql "$DATABASE_URL" ``` ## Parameters Define named parameters with defaults or mark them required: ```yaml tasks: build: params: - name: target default: development - name: minify cmds: - "npm run build -- --env={{target}}" ``` ```bash zap task build --target=production --minify=true ``` Required parameters fail before commands run: ```yaml tasks: deploy: params: - name: env required: true cmds: - "deploy.sh {{env}}" ``` ```bash zap task deploy --env=staging zap task deploy --interactive ``` Without `--interactive`, missing required params fail fast. With `--interactive`, Zapper prompts for missing required params and then runs the task with the provided answers. ## Pass-Through Arguments Use {{ARGS}} to forward extra CLI arguments with shell quoting: ```yaml tasks: test: cmds: - "pnpm vitest {{ARGS}}" ``` ```bash zap task test -- --coverage src/ ``` `{{ARGS}}` and `{{CLI_ARGS}}` shell-quote each argument independently, so paths with spaces and quotes survive better in shell commands. `{{REST}}` remains available as the older raw joined string for backward compatibility. ## Special Vars Tasks can use built-in template vars: ```yaml tasks: inspect: cmds: - 'echo "project={{PROJECT}} task={{TASK}}"' - 'echo "root={{ROOT_DIR}} cwd={{CWD}} instance={{INSTANCE}}"' ``` | Var | Value | | ---------- | ----------------------------------------- | | `ROOT_DIR` | Project root containing the loaded config | | `CWD` | Resolved working directory for the task | | `TASK` | Current task name | | `PROJECT` | Current Zapper project name | | `INSTANCE` | Current instance key | | `REST` | Raw pass-through args joined with spaces | | `ARGS` | Shell-quoted pass-through args | | `CLI_ARGS` | Alias for `ARGS` | Built-in vars are also available when interpolating nested task `vars`, preconditions, and status checks. ## Task Context Env Every task command receives task context through environment variables: ```bash ZAPPER_ROOT ZAPPER_CWD ZAPPER_TASK ZAPPER_PROJECT ZAPPER_INSTANCE ``` Use these from scripts that should not depend on template interpolation. ## Nested Tasks Use a task command object to run another task as part of a command sequence. Nested task calls can pass vars and suppress command headers for the called task. ```yaml tasks: build: params: - name: target required: true cmds: - "pnpm build --target={{target}}" deploy: cmds: - task: build vars: target: production silent: true - ./deploy.sh ``` ## Preconditions Preconditions are shell commands that must succeed before a task runs. They use the task's resolved env and cwd. ```yaml tasks: migrate: preconditions: - test -n "$DATABASE_URL" - sh: test -f prisma/schema.prisma msg: Missing Prisma schema cmds: - pnpm prisma migrate dev ``` ## Status Checks Status checks decide whether a task is already up to date. If every status command succeeds, Zapper skips the task. Use `--force` to run anyway. ```yaml tasks: install: status: - test -d node_modules cmds: - pnpm install ``` ```bash zap task install zap task install --force ``` ## Custom Delimiters If commands contain literal {{ and }}, change delimiters: ```yaml project: myapp task_delimiters: ["<<", ">>"] tasks: build: cmds: - 'echo "Building <>"' ``` ## Parameter Metadata For tooling integration, get parameter info as JSON: ```bash zap task build --list-params ``` Task aliases can be used here as well: ```bash zap t b --list-params ``` ```json { "name": "build", "params": [ { "name": "target", "default": "development", "required": false, "desc": "Build target" } ], "acceptsRest": false } ``` ## Common Patterns ```yaml tasks: db:migrate: env: .zap/env/database.yaml cmds: - pnpm prisma migrate dev db:seed: env: .zap/env/database.yaml cmds: - pnpm prisma db seed lint: cmds: - pnpm eslint . --fix - pnpm prettier --write . test: env: .zap/env/database.yaml cmds: - pnpm vitest run ``` # Source: docs/project-metadata.md # Project Metadata Project metadata gives people and tools quick access to useful project URLs and notes without encoding them in scripts. ## Homepage Set a top-level homepage as the default target for `zap launch`: ```yaml homepage: http://localhost:3000 ``` ```bash zap launch zap home ``` ## Links Links are named URLs for docs, dashboards, staging environments, and other project resources. ```yaml links: - name: API Docs url: http://localhost:${API_PORT}/docs - name: Staging url: https://staging.example.com - name: Figma url: https://figma.com/file/abc123 ``` ```bash zap open zap launch "API Docs" zap links zap open "API Docs" zap o "API Docs" zap open --home ``` `zap open` opens an interactive picker for the homepage and configured links. Pass a link name, use `zap open --home`, or use `zap launch` when you want to open a URL without prompting. Link URLs support `${VAR}` interpolation from root env files and assigned ports. | Property | Required | Description | | -------- | -------- | ------------------------------------- | | `name` | Yes | Display name, up to 100 characters | | `url` | Yes | URL, with `${VAR}` interpolation | ## Notes Notes are top-level project text that can include interpolated env values. ```yaml env: [.env] notes: | Frontend: http://localhost:${FRONTEND_PORT} API: http://localhost:${API_PORT} ``` ```bash zap notes zap notes --json ``` # Source: docs/instances.md # Instances Zapper is instance-first. A project can have multiple stack instances, and each instance has: - Its own random `id` (used in PM2/Docker names) - An optional human `label` for display in status output and the desktop app - Its own assigned `ports` map - Its own generated Docker `volumes` map for path-only volume mounts This prevents collisions across separate checkouts and also supports multiple stacks from one repo (for example, E2E runs). ## Defaults - If `--instance` is omitted, Zapper resolves the default instance key from `state.json` (`defaultInstance`, fallback: `default`). - Instance keys must contain lowercase letters and hyphens only. ## Initialization - Any config-backed command ensures the target instance exists before running. - `zap init` is the explicit/idempotent way to force that setup and then run `init_task` if configured. - `zap init -R` re-randomizes all configured ports for the selected instance. - `zap volume prune` deletes generated Docker volumes whose service/path no longer exists in the current config. - `zap volume reset` clears generated volume assignments in state without deleting Docker volumes. Examples: ```bash zap status zap up zap up --instance e2e zap init --instance e2e zap instance label zap instance label "local checkout" zap --instance e2e instance label "e2e stack" ``` Labels can be any string up to 100 characters. They do not affect resource names; the random instance ID remains the runtime namespace. When a label is set, human status output shows both the label and the ID. Run `zap instance label` without a value to print the current display label for the selected instance. ## Naming PM2 and Docker names are always namespaced: - `zap...` ## State file Zapper stores instance state in `.zap/state.json`: ```json { "defaultInstance": "default", "instances": { "default": { "id": "a1b2c3", "label": "local checkout", "ports": { "FRONTEND_PORT": "54321" }, "volumes": { "zap.myapp.a1b2c3.vol1": { "service": "postgres", "internal_dir": "/var/lib/postgresql/data" } } }, "e2e": { "id": "k9m2pq", "ports": { "FRONTEND_PORT": "61234" }, "volumes": { "zap.myapp.k9m2pq.vol1": { "service": "postgres", "internal_dir": "/var/lib/postgresql/data" } } } } } ``` # Source: docs/resource-management.md # Resource Management Zapper names the resources it creates so they can be discovered later: - PM2 processes and Docker containers: `zap...` - Generated Docker volumes: `zap...volN` For the proposed machine-wide project registry and dashboard model, see [Global Registry Design](global-registry.md). `zap ls` shows configured services and assigned ports by default. Use `zap ls --extended` (or `zap ls --all`) for the local inventory view: configured services first, then recognized instances from the local `.zap/state.json` and resources that look related to the project but no longer line up with the current config or state. ## Resource Types ### Current resources Current resources belong to the selected instance and still match a service or managed volume path in the current `zap.yaml`. ### Dangling resources Dangling resources belong to an instance recorded in this repo, but no longer match the current `zap.yaml` or current state. Common causes: - A service was renamed or removed while its PM2 process or Docker container still exists. - A generated Docker volume exists but is no longer tracked in `.zap/state.json`. - A managed volume path changed, leaving the old generated volume assignment stale. Use `zap ls --extended` to see these. The usual repair is to stop/delete the stale resources rather than hand-editing state. ### Unrecognized resources Unrecognized resources match the current project name but do not belong to any instance recorded in the local `.zap/state.json`. They usually come from another checkout, older state, or manual resource creation. Use `zap global list` (or `zap global ls`, `zap g ls`) for a machine-wide view of discovered Zapper PM2 and Docker container resources. Use `zap global kill ` when you want project-wide cleanup across checkouts. ## Cleanup Commands - `zap down` stops resources for the selected instance and current config. - `zap kill` deletes all PM2 processes and Docker containers for the current project across instances. - `zap global kill ` deletes PM2 processes and Docker containers for a named project. - `zap global prune` audits stale registry entries and orphaned resources before mutating anything. After confirmation, it deletes orphaned PM2 processes, Docker containers, and generated Docker volumes, then removes stale registry entries. Use `--force` (`-y`) for non-interactive cleanup. - `zap volume prune` deletes stale generated Docker volumes for the selected instance. - `zap volume reset` forgets generated volume assignments in `.zap/state.json` without deleting Docker volumes. For one-off cleanup, Docker and PM2 commands are still valid escape hatches: ```bash docker rm -f docker volume rm pm2 delete ``` ## Practical Recovery If a config change leaves old resources around: ```bash zap ls --extended zap volume prune zap kill zap up ``` If generated volume state is confusing but you want to keep the Docker volumes for manual inspection: ```bash zap volume reset zap init ``` # Source: docs/global-registry.md # Global Registry Design This describes Zapper's machine-wide project registry and orphaned resource audit model. Some sections still describe follow-up improvements and should be read as design notes where called out. ## Problem Zapper can already answer "what is running for this repo?" because each project has a local `zap.yaml` and `.zap/state.json`. A dashboard needs a wider view: - Which Zapper projects exist on this machine? - Which projects currently have PM2 processes, Docker containers, or generated Docker volumes? - Which live resources are stale, unregistered, orphaned, or from another checkout? - Where should a user go on disk to inspect or operate on a project? The hard part is that the reliable sources have different blind spots: - PM2 and Docker can reveal live resources, but names only give `project`, optional `instanceId`, and `service`. They do not reliably tell us the originating project root. - A global file can remember project roots, but it can go stale when repos move or are deleted. - Writing global metadata can leak local repo paths if the file is too broad, world-readable, or synced accidentally. The recommended design has two related but separate surfaces: - A reliable global project registry that answers which Zapper projects exist, where they live, and what Zapper reports for them when loaded normally. - An orphaned resources audit that scans PM2 and Docker directly for live resources that no registered/current project can explain. The project registry should be the primary source for the desktop app's Projects tab. For each registered project, Zapper can load that project and use existing status/list/config paths to report services, ports, and `up`/`pending`/`down`. Direct PM2 and Docker scanning should power a separate Orphaned Resources tab, because resources can keep running after a project name changes, a service is removed, a checkout moves, or local state is deleted. For the precise definition of a Zapper project root, including nested `zap.yaml` files and custom config paths, see [Project Roots](project-roots.md). ## Current State Zapper currently names managed resources with a predictable namespace: - PM2 processes and Docker containers: `zap...` - Legacy unscoped resources: `zap..` - Generated Docker volumes: `zap...volN` The code path for this is centralized in `packages/cli/src/utils/nameBuilder.ts`. The current local resource inventory in `zap ls --extended` uses: - `.zap/state.json` for known instance keys, instance IDs, ports, and generated volumes. - PM2 process names and Docker container/volume names for live resources. - Current `zap.yaml` services to classify dangling resources. For a loaded project, `zap status` builds expected PM2/Docker names from the project context, then checks PM2 for native processes and Docker for containers. It also applies the same Zapper healthcheck logic that can report a live process as `pending` before it becomes `up`. The current `zap global list` and `zap global kill` commands discover projects from live PM2 and Docker names only. That is useful for cleanup, but it cannot show inactive registered projects, cannot map a live project name back to a repo root, and does not have enough project context to exactly mirror local `zap status` for each registered service. ## Goals - Provide a global project registry without requiring a daemon. - Use `system` naming for machine-wide commands and environment variables so they are clearly separate from repo-local commands and `.zap/state.json`. - Treat the registry as a managed, reliable index of known Zapper projects. - Use the global registry as the primary project index for the desktop Projects tab. - Reuse Zapper's existing project-local status behavior when reporting whether a service is `up`, `pending`, or `down`. - Use a separate direct PM2/Docker audit to detect orphaned, unregistered, ambiguous, and legacy resources that registry-backed project queries cannot see. - Map known live resources back to a project root when possible. - Keep stale registry data harmless and easy to prune. - Avoid storing environment values, commands, notes, homepage URLs, or other high-sensitivity config data globally. - Work on macOS and Linux, with a path override for unusual setups. ## Non-Goals - Do not invent a separate global status algorithm that can disagree with local `zap status`. - Do not make stale registry data imply a process is running. It may only cause the central view to show a registered project or service as `down`, stale, or unresolved. - Do not mix orphan detection into the core project registry model. Orphaned runtime resources should be shown, but as a separate audit view. - Do not introduce a machine daemon as the first implementation. - Do not require `zap.yaml` behavior changes. - Do not store full service definitions globally. ## Storage Location Use one small JSON state file plus a lock file. Recommended lookup order: 1. `ZAPPER_SYSTEM_STATE_HOME`, if set. 2. On Linux: `$XDG_STATE_HOME/zapper`, falling back to `~/.local/state/zapper`. 3. On macOS: `~/Library/Application Support/Zapper`. 4. Final fallback: `~/.zapper`. Files: ```text registry.json registry.lock ``` The registry file should be created with user-only permissions where the platform supports it, equivalent to `0600`. The directory should be equivalent to `0700`. ## Registry Shape Project names are not unique across checkouts, so the registry needs a stable entry ID that is not just `project`. Use: ```text registryId = sha256(realProjectRoot + "\0" + realConfigPath) ``` Store the real/canonical paths for local use, but do not copy config contents into the registry. Example: ```json { "version": 1, "updatedAt": "2026-05-05T10:20:30.000Z", "projects": { "sha256:abc123": { "registryId": "sha256:abc123", "project": "myapp", "projectRoot": "/Users/alice/Code/myapp", "configPath": "/Users/alice/Code/myapp/zap.yaml", "firstSeenAt": "2026-05-01T09:00:00.000Z", "lastSeenAt": "2026-05-05T10:20:30.000Z", "lastCommand": "up", "zapperVersion": "0.1.0", "statePath": "/Users/alice/Code/myapp/.zap/state.json", "instances": { "default": { "id": "a1b2c3", "label": "local checkout", "lastSeenAt": "2026-05-05T10:20:30.000Z" } } } } } ``` Fields intentionally excluded: - Resolved environment variables. - Raw command strings. - Docker images, volume bindings, and port values. - Notes, homepage, links, repo URLs, and task definitions. Those can be loaded lazily from the project itself when the user explicitly asks for details and the project root still exists. ## Write Points Update the registry after a config-backed command has successfully loaded enough context to know: - `projectName` - `projectRoot` - `configPath` - selected `instanceKey` - selected `instanceId` - selected instance label, if set - Zapper version - command name, if available Good write points: - `zap init` - `zap up` - `zap down` - `zap restart` - `zap status` - `zap ls` - `zap task` - `zap profile` - `zap env` - `zap config` Avoid registry writes for commands that do not load project config, such as orphan/resource audit commands, unless they are explicitly repairing or pruning the registry. Registry writes should be treated as part of the product contract, not as a throwaway cache. Use locking, validation, and atomic writes. If a registry write fails, Zapper should surface that failure clearly instead of silently losing the project update. The exact command failure policy can vary by command, but the registry layer itself should not be "best effort" in design or tests. If an existing registry entry has the same project root and config path but a different project name, treat it as a project rename. Update the entry in place and print a one-time text-mode warning that old resources may still be running under the previous project name. Do not emit that warning in `--json` mode. ## Runtime Metadata Names are useful, but not perfect. Add low-sensitivity runtime metadata to make future discovery more reliable. For Docker containers, extend labels: ```text com.zapper.project= com.zapper.service= com.zapper.instance-id= com.zapper.instance-key= com.zapper.registry-id= com.zapper.project-root-hash= ``` Do not put raw project roots or config paths in Docker labels. Docker labels are visible to anyone with access to the local Docker daemon. For PM2 processes, add equivalent environment metadata to the PM2 ecosystem: ```text ZAPPER_PROJECT ZAPPER_SERVICE ZAPPER_INSTANCE_ID ZAPPER_INSTANCE_KEY ZAPPER_REGISTRY_ID ZAPPER_PROJECT_ROOT_HASH ``` These values should be treated as hints. Resource names still provide backwards compatibility for older processes. ## Project Registry Read Algorithm A project registry read starts from the registry and uses existing Zapper project commands for detail: 1. Load and validate `registry.json`. If invalid JSON is found, rename it aside, report the problem, and start with an empty registry only after preserving the broken file for inspection. 2. For each registered project whose `configPath` still exists, load the project context the same way a local command would. 3. For each registered instance, ask the same status/list/config code used by local Zapper commands for services, ports, and service state. This keeps central output aligned with `zap status`, `zap ls`, and related commands. 4. If a project root or config path is missing, mark the registry entry stale. 5. If a project config cannot be loaded, mark it unresolved and show the stored registry metadata only. Suggested project registry classifications: - `registered-active`: Registry entry exists and at least one service is currently `up` or `pending` according to normal Zapper status semantics. - `registered-inactive`: Registry entry exists, but normal Zapper status reports all services as `down`. - `registered-unresolved`: Registry entry exists, but the project config cannot be loaded well enough to run normal Zapper commands. - `stale-registry`: Registry entry points to a missing config or missing project root. Registry entries should drive the Projects tab: which projects are visible and which project-local queries should run. Normal Zapper command code should drive displayed services, ports, config-derived details, and service states. ## Orphaned Resource Audit The orphaned resource audit scans PM2 and Docker directly. It is separate from the project registry read path: 1. List PM2 processes, Docker containers, and generated Docker volumes. 2. Parse Zapper resource names. Prefer labels/env metadata when present, but fall back to `zap...`. 3. Match live resources to registered projects by `registryId` when metadata is present. 4. If metadata is absent, match by `project` and known `instanceId`. 5. If a live resource belongs to a registered project/instance but no longer appears in that project's current config/state, mark it as dangling. 6. If a live resource cannot be mapped to a registered project, mark it as live-unregistered or legacy. 7. If multiple registered projects match a live resource, mark it ambiguous rather than guessing. 8. Classify each project/resource. Suggested orphan audit classifications: - `live-unregistered`: Live Zapper-looking resources exist, but no registry entry matches them. - `live-ambiguous`: Live resources match more than one registered project. - `orphaned-resource`: Live resource exists, but its original project, instance, or service can no longer be resolved from the registry plus current project state. - `dangling-resource`: Resource belongs to a known instance, but no longer appears in current config/state. - `legacy-resource`: Resource uses old `zap..` naming without an instance ID. Runtime-only resources should not be merged into registered service status by custom logic. They should be shown in the Orphaned Resources tab with explicit cleanup actions. ## Dashboard Model The desktop app should present at least two separate tabs. ### Projects The Projects tab is backed by the global registry and normal Zapper project commands. It should present three levels: 1. Project rows: project name, root path if known, state classification, active resource counts, last seen time. 2. Instance rows: instance key, instance ID, optional label, service status counts, assigned port count. 3. Service rows: type, service/resource name, Zapper status, enabled/profile-filter state, classification, last known location, reason. For privacy and performance, the first load should not parse every `zap.yaml`. Only parse project config when: - The dashboard needs exact service status for that project. - The project is expanded in the dashboard. - A command requires service definitions. - The user asks for ports, links, notes, or detailed service metadata. The central view may show a coarse project row from registry metadata before loading config, but service-level names, ports, and `up`/`pending`/`down` should come from the same project-local paths used by normal commands. ### Orphaned Resources The Orphaned Resources tab is backed by direct PM2/Docker scans. It should show: - Live Zapper-looking PM2 processes with no registered/current owner. - Live Zapper-looking Docker containers with no registered/current owner. - Generated Docker volumes that no current project state owns. - Legacy resources that use old names without instance IDs. - Ambiguous resources that match multiple possible registered projects. This tab should be action-oriented: inspect, open likely project, stop/delete selected resource, or run a confirmation-heavy cleanup command. ## Staleness And Pruning Registry entries should be validated on read: - If `projectRoot` or `configPath` is missing, mark `stale-registry`. - If `.zap/state.json` is missing, keep the project registered but let the normal project load/status path decide what can still be shown. - If a project has not been seen for a long time, keep it but show it as old. Direct PM2/Docker scans are necessary for orphan detection, but a missing PM2/Docker resource does not mean the registry entry is stale. It may simply be an inactive project. A registry entry should usually be considered stale because its project root/config path is gone or cannot be loaded, not because nothing is currently running. Add explicit maintenance commands: ```bash zap system projects zap system registry prune zap system registry forget zap system registry repair zap system resources audit zap system resources cleanup zap global list zap global ls zap global prune zap global prune --force ``` Suggested behavior: - `system projects` always validates registered project roots and config paths, returning missing entries with `state: "stale"` without mutating the registry. - `prune` removes entries whose config path is missing after any matching live resources have been cleaned up. - `forget` removes one entry without touching PM2 or Docker. - `repair` rewrites the registry from currently accessible entries and live metadata. - `system resources audit` scans PM2/Docker for orphaned, dangling, legacy, and ambiguous resources without changing the registry. - `system resources cleanup` stops/removes selected audited resources after explicit confirmation. - `global list` and its `global ls` alias always list discovered global PM2/container resources. `--all` is retained only as a compatibility no-op for this command. - `global prune` audits stale registry entries and orphaned PM2 processes, Docker containers, and generated Docker volumes before mutating anything. After confirmation, it removes orphaned resources and then prunes stale registry entries. `--force` (`-y`) skips the confirmation for automation. - Runtime orphan cleanup should be explicit and confirmation-heavy because it deletes live PM2/Docker resources that current project config may no longer describe. Cleanup commands should remain separate: - `zap down` stops current configured resources for the selected repo/instance. - `zap kill` removes current project resources by project prefix. - `zap global kill` removes live resources by runtime discovery. - `zap global prune` removes live resources that no longer match the current global registry, then removes stale registry entries. - `zap system registry forget` only edits the system registry. ## Privacy Controls Provide clear controls because the registry stores local paths. Recommended controls: - `ZAPPER_DISABLE_SYSTEM_REGISTRY=1` disables registry writes. - `ZAPPER_SYSTEM_STATE_HOME=` moves the system registry. - `zap system registry forget ...` removes individual entries. - `zap system registry prune` removes stale entries. - Keep file permissions user-only. - Never store env values or raw service definitions globally. - Never write raw project roots into Docker labels or PM2 process names. Open question: whether there should also be a config-level opt-out in `zap.yaml`. That would be convenient but changes supported config fields, so it should wait until the command/env controls prove insufficient. ## Implementation Plan 1. Add a `GlobalRegistry` module. - Resolve platform-specific state directory. - Load/save `registry.json`. - Use atomic writes: write temp file, fsync where practical, rename. - Guard writes with a lock file or advisory lock. - Validate with a Zod schema. 2. Add registry touch after config load. - Capture project root/config path, selected instance, and command name. - Treat write correctness as part of the registry contract; do not silently drop failed updates. - Add unit tests for first write, update, stale/corrupt read, and concurrent write behavior. 3. Enrich runtime resources. - Add Docker labels for instance ID/key and registry ID. - Add PM2 environment metadata for the same values. - Preserve name parsing fallback for existing resources. 4. Build a project registry service. - Start from registry entries. - Load project contexts when service/config details are needed. - Reuse existing status/list/config code for registered project details. - Return structured JSON with classification, last known location, and reasons. 5. Build an orphaned resource audit service. - Scan PM2, Docker containers, and Docker volumes directly. - Compare live resources against registry entries and current project state. - Keep cleanup separate from registry maintenance. - Return structured JSON with classification, last known location, and reasons. 6. Upgrade global commands. - Make `zap system projects --json` use the project registry service. - Add `zap system resources audit --json` for orphaned resources. - Keep cleanup based on runtime resources, not registry-only rows. - Add `zap system registry` maintenance commands. 7. Add dashboard/API integration. - Prefer a CLI JSON contract first. - Let the Projects tab consume registry output. - Let the Orphaned Resources tab consume resource audit output. 8. Update docs. - Document implemented commands in the docs website command reference. - Keep this design doc updated as decisions become behavior. ## Risks And Mitigations - Stale paths: validate on read and make prune/forget cheap. - Project name collisions: use `registryId` for registered projects and mark ambiguous runtime matches instead of guessing. - Leaking repo paths: store only local paths in a user-only file; use hashes in runtime labels/env. - Registry corruption: treat as recoverable, rename aside, and report the problem clearly instead of silently falling back forever. - Concurrent commands: use atomic writes and locking. - Symlinked checkouts: key by real paths, but keep the display path last used by the user. - Older live resources: keep name parsing fallback indefinitely. ## Recommended First Cut Start with the smallest useful version: - Registry file with project root, config path, project name, last seen time, instance key, instance ID, and optional instance label. - Reliable writes from config-backed commands, with validation and atomic file replacement. - `zap system projects --json` output that starts from registered projects and reports project/service details using normal Zapper command semantics. - `zap system resources audit --json` output for live orphaned/unregistered/ambiguous resources discovered from PM2 and Docker. - `zap system registry prune` and `zap system registry forget`. Then add runtime metadata labels/env once the registry ID exists. That keeps the first change useful while preserving compatibility with existing PM2 and Docker resources. # Source: docs/project-roots.md # Project Roots This is an internal note for reasoning about Zapper project identity, especially for the system registry. ## Definition A Zapper project root is the directory that contains the resolved config file. In code: ```text configPath = resolveConfigPath(...) projectRoot = dirname(realpath(configPath)) ``` Zapper does not first discover a Git repository root or package workspace root. The selected config file defines the Zapper project boundary. ## Default Config Discovery When no `--config` value is passed, Zapper searches upward from the current working directory for: 1. `zap.yaml` 2. `zap.yml` The nearest matching directory wins. Within the same directory, `zap.yaml` wins over `zap.yml`. Example: ```text repo/ zap.yaml apps/ api/ zap.yaml ``` Running `zap status` from `repo/apps/api` uses `repo/apps/api/zap.yaml`, so the Zapper project root is `repo/apps/api`. Running `zap status` from `repo/apps/api/src` also uses `repo/apps/api/zap.yaml`, because it is found before `repo/zap.yaml` while walking upward. Running `zap status` from `repo` uses `repo/zap.yaml`, so the Zapper project root is `repo`. These are distinct Zapper projects even if they live in the same Git repository. ## Custom Config Paths When `--config ` is passed, Zapper uses that file directly. It does not fall back to a parent `zap.yaml` if the custom file is missing. The project root is still the directory containing the selected config file: ```bash zap --config ./prod.yaml status ``` uses `./prod.yaml`, so the project root is the current directory if `prod.yaml` is there. ```bash zap --config ./apps/api/local.yaml status ``` uses `./apps/api/local.yaml`, so the project root is `./apps/api`. If `--config ` is passed, Zapper searches upward from that directory for `zap.yaml` or `zap.yml`. It does not search downward inside the directory. ## System Registry Identity The system registry should identify a Zapper project by the resolved config path, not only by the `project` field inside `zap.yaml`. The current registry ID is derived from: ```text realProjectRoot + "\0" + realConfigPath ``` This is intentional: - Multiple directories in one Git repo may each have their own `zap.yaml`. - Multiple configs can use the same `project` name. - Multiple configs can live in the same directory. - A checkout can move on disk. The `project` field in `zap.yaml` remains the runtime resource namespace used in PM2/Docker names. It is not globally unique and should not be treated as the system registry identity by itself. ## Practical Rule If a directory contains a `zap.yaml` or `zap.yml`, it can be a Zapper project root when Zapper resolves that file. Nested Zapper projects are allowed and should be treated as separate projects by system-level tooling. # Source: docs/env-var-mgmt.md # Environment Variable Management This document explains the environment variable model and the reasoning behind it. For concise syntax reference, see [Configuration](configuration.md) and [Services](services.md). ## Goals Zapper should make local development environment variables easy to share across services without forcing users to copy the same values into many places. The original model is still sound: 1. Load environment variables from a central source. 2. Treat that source as the local source of truth. 3. Decide what each service receives. The problem is that explicit routing is too much ceremony for many local projects. Zapper should support careful routing, but the common path should be small enough that most projects can understand it at a glance. ## Recommended Model Use one field: `env`. At the root level, `env` defines the global environment file stack: ```yaml env: [.env.local, .env.user] ``` Root-level `env_files` remains as a compatibility alias: ```yaml env_files: [.env.local, .env.user] ``` At the service level, `env` chooses how that service receives environment variables: ```yaml env: "*" # Pass all values from the global env stack env: [.env.api] # Replace global env with this service file stack env: api.env.yaml # Route global env through this strict whitelist file ``` There is no inline whitelist array in `zap.yaml`. Arrays in `zap.yaml` are file stacks. Variable allowlists live only in external whitelist files. This gives Zapper one concept with three levels of power: 1. `env: "*"` for the default local developer. 2. `env: [files...]` for the power user who wants direct file assignment. 3. `env: whitelist.yaml` for the large-team user who needs central storage plus explicit routing. ## Resolution Rules Root `env` and root `env_files` both mean "load these environment files as the global stack." If both are present, Zapper rejects the config instead of guessing which one wins. Service-level `env` resolves as follows: 1. `env: "*"` passes every value from the resolved global env stack. 2. `env: [files...]` loads those files for that service and exposes every value from that service stack. This replaces the global stack for that service. 3. `env: path/to/whitelist.yaml` loads a strict whitelist file and exposes only the listed variables from the global env stack. 4. Missing `env` means no Zapper-managed env for that service. The service file-stack rule is an override, not a merge. That keeps precedence straightforward: - Root `env` defines the default source. - Service `env: "*"` uses the default source. - Service `env: [files...]` replaces the default source. - Service `env: whitelist.yaml` filters the default source. Generated Zapper values, such as assigned ports, are part of the resolved environment source before either `*` or whitelist filtering is applied. ## Persona Stress Test The model is useful only if it handles the common case without ceremony and still has credible answers for more demanding setups. ### Persona 1: Default Local Developer This user has a few services and a manageable number of variables. Most values are local-only coordination values: ports, local URLs, feature toggles, and container credentials that are not meaningful outside the dev machine. They want: - One obvious place to put shared values. - One gitignored place to put personal overrides. - No per-service env bookkeeping. They should use root `env` and service `env: "*"`: ```yaml project: myapp env: [.env.local, .env.user] native: frontend: cmd: pnpm dev env: "*" backend: cmd: pnpm dev env: "*" docker: postgres: image: postgres:15 env: "*" ``` This is intentionally permissive. It is the right default because local dev often values alignment more than isolation. The star is visible enough to signal broad access. How the model handles it: - Strong fit. - Small `zap.yaml`. - No variable duplication. - No extra routing files. - Easy migration path if one service later needs a custom file stack. ### Persona 2: Security-Conscious Power User This user has enough secrets that they do not want every service receiving the same gitignored user file. They also prefer ordinary env files over Zapper-owned whitelist policy. They want: - Direct file assignment per service. - Shared non-sensitive files where useful. - Separate user secret files for sensitive services. - No central whitelist registry in `zap.yaml`. They should use service-level file stacks: ```yaml project: myapp native: frontend: cmd: pnpm dev env: [.env.common, .env.frontend, .env.frontend.user] backend: cmd: pnpm dev env: [.env.common, .env.db, .env.backend, .env.backend.user] worker: cmd: pnpm worker env: [.env.common, .env.db, .env.worker, .env.worker.user] ``` This is direct file assignment. The service's `env` array is the source stack for that service, and all values from that stack are exposed to that service. How the model handles it: - Strong fit. - Security boundaries are represented by file boundaries. - `zap.yaml` stays readable because it names file stacks rather than individual variables. - The main weakness is that file composition becomes the routing system. If the project grows to hundreds or thousands of variables, this can become hard to maintain. ### Persona 3: Large-Team Platform Owner This user has a large environment surface, possibly hundreds or thousands of variables. Many values need to line up across services. Copying variables into service-specific files would be risky and tedious. They want: - Central env files as the source of truth. - Explicit routing so services receive only what they need. - Routing policy outside the core service config. - A strict schema for routing files. They should use a global stack and service-level whitelist files: ```yaml project: enterprise-app env: [.env.company, .env.local, .env.user] native: frontend: cmd: pnpm dev env: .zap/env/frontend.yaml backend: cmd: pnpm dev env: .zap/env/backend.yaml worker: cmd: pnpm worker env: .zap/env/worker.yaml ``` With `.zap/env/backend.yaml`: ```yaml vars: - DATABASE_URL - REDIS_URL - JWT_SECRET ``` This is the most complex setup, but it earns that complexity. The environment values remain centralized, while routing policy moves into dedicated files that can be reviewed separately from service definitions. How the model handles it: - Good fit for very large projects. - Avoids copying values across service files. - Keeps `zap.yaml` from being polluted by long whitelist definitions. - The complexity is isolated to projects that actually need it. ## Whitelist Files A service string other than `*` should be interpreted as a whitelist file path, not a named whitelist embedded in `zap.yaml`. Whitelist files have a strict schema: ```yaml vars: - DATABASE_URL - REDIS_URL - JWT_SECRET ``` Rules: - The top level must be an object. - `vars` must be an array of non-empty variable names. - Unknown keys are rejected. - `*` is not a valid whitelist file path or variable name. - Whitelist files require a global env stack. If root `env` or `env_files` is missing, service `env: some-whitelist.yaml` errors because there is no central source to filter. The last rule is important: a whitelist does not load values. It only selects values from the global env source. ## Weird Cases ### Root `env` and Root `env_files` Invalid: ```yaml env: [.env.local] env_files: [.env.local] ``` Both fields mean the same thing. Supporting both at once creates unnecessary precedence rules, so this should be a validation error. ### Service `env: "*"` Without Global Env Valid, but usually empty unless generated values such as assigned ports exist: ```yaml native: api: cmd: pnpm dev env: "*" ``` Because `*` means "all currently available values", this produces an empty env when there is no root env source and no generated values. Whitelist files are stricter: they require a root env source because they filter central values. ### Service File Stack With Global Env Valid: ```yaml env: [.env.local, .env.user] native: api: cmd: pnpm dev env: [api/.env.local, api/.env.user] ``` The service stack replaces the global stack for this service. It does not merge with the global stack. Users who want shared values can include the shared file directly: ```yaml native: api: cmd: pnpm dev env: [.env.common, api/.env.local, api/.env.user] ``` ### Inline Variable Arrays Invalid: ```yaml env: - DATABASE_URL - JWT_SECRET ``` There is no inline whitelist array in `zap.yaml`. A service `env` array is a file stack, so entries are interpreted as file names or paths. Zapper accepts ordinary file names such as `.env.something` and `service-env`, but rejects entries that look like uppercase variable names such as `DATABASE_URL`. Explicit variable routing belongs in a whitelist file: ```yaml native: api: cmd: pnpm dev env: .zap/env/api.yaml ``` With `.zap/env/api.yaml`: ```yaml vars: - DATABASE_URL - JWT_SECRET ``` ### Mixing File Stacks and Whitelist Files Invalid: ```yaml native: api: cmd: pnpm dev env: [.env.common, .zap/env/api.yaml] ``` An `env` array is a file stack. A string is a whitelist file path. Mixing those concepts in one value makes resolution unclear and should be rejected. ## Why This Is the Middle Ground This is technically three capabilities, but only one needs to be common: - Common path: root `env`, service `env: "*"`. - Power-user path: service `env: [files...]`. - Large-team path: root `env`, service `env: whitelist.yaml`. The benefit is that all three use one field and one idea: - Root `env` defines the default source. - Service `env` defines how the service receives env. - `*` is shorthand for "all values from the default source." - A service file stack is an explicit source override. - A whitelist file filters the default source. ## Current State The implemented model is: - Most projects use root `env` and service `env: "*"`. - Projects with existing env-file conventions use service `env: [files...]`. - Security-conscious large projects use service `env: whitelist.yaml`. - Root `env_files` remains a compatibility alias. - Inline `whitelists` and inline service variable arrays disappear from the core YAML spec. # Source: docs/local-runtime.md # Local Runtime Compatibility This document tracks how Zapper should be distributed, why we are making the current packaging choices, and what options remain on the table to make Zapper work predictably on local machines. ## Goals Zapper should feel like local infrastructure, not like a tool that only works from one carefully configured terminal session. - The macOS app should launch from Finder, Spotlight, login items, and the menu bar without requiring shell-specific setup. - The CLI should remain easy to install and use from a terminal. - Desktop reads and actions should not depend on a user-managed Node version manager just to start Zapper itself. - Project commands should still run in the user's project environment, because `zap.yaml` commands often rely on project-specific tools such as `node`, `pnpm`, `npm`, `docker`, language toolchains, and local shims. - Failures should be repairable and diagnosable without asking users to reverse engineer macOS launch environments. ## Current State The CLI is published as the npm package `@mp-lb/zapper`. Its executable is a JavaScript entrypoint with a `#!/usr/bin/env node` shebang. The macOS app is a native Swift/AppKit menu bar app. Release builds now include a bundled Node runtime, the built Zapper CLI, production CLI dependencies, and PM2. The app prefers that bundled runtime for system reads and actions: - `zap system projects --json` - `zap home --json` - `zap up --json` - `zap down --json` The app still supports `ZAPPER_CLI_PATH`, common install-location discovery, and an external CLI picker in Settings for development and diagnostics. Runtime metadata and repair-oriented controls live behind the gear menu rather than in the primary dashboard. The primary dashboard is stack-oriented: each row is one project instance, and expanded stack rows show compact service controls. The important improvement is that a Finder-launched app no longer needs the user's `node` from `nvm`, `fnm`, `mise`, `asdf`, Volta, Homebrew, or another shell-only setup just to run Zapper itself. Because release builds use Apple's hardened runtime, nested runtime binaries are signed with `apps/macos/Signing/Zapper.entitlements`. Those entitlements allow the bundled Node/V8 runtime to allocate executable memory inside the notarized app bundle. ## Decision: Bundle Zapper's Runtime in the macOS App The desktop app should be self-contained for running Zapper itself. The macOS release should include: - a known-good Node runtime; - the built Zapper CLI JavaScript; - production dependencies needed by the CLI; - PM2 or a PM2 invocation path that does not depend on a global `pm2` executable. The Swift app invokes a bundled `zap` wrapper, which runs: ```text Zapper.app/Contents/Resources/ZapperRuntime/node/bin/node \ Zapper.app/Contents/Resources/ZapperRuntime/cli/dist/index.js \ system projects --json ``` This removes the desktop app's dependency on the user's Node installation, package manager shims, and shell startup files for Zapper's own code path. The user-selected CLI path remains as a diagnostic and development override, but it is not required for the released app. ## Important Boundary: Project Commands Are Not Hermetic Bundling Node for Zapper does not mean every project command becomes hermetic. If a service has this configuration: ```yaml native: web: cmd: pnpm dev ``` then `pnpm dev` should still run in the user's project environment. Zapper should preserve or reconstruct a useful shell environment for those commands. That is separate from the runtime used to execute Zapper itself. In practice, the CLI should capture the launch environment used for `zap up` and write PM2 wrapper scripts with the relevant `PATH` and process environment. The desktop app can provide a bundled Zapper runtime while still asking the CLI to run project commands through a login-shell-derived environment when needed. ## PM2 Reliability PM2 was another global executable dependency. It is now a CLI production dependency for bundled desktop builds, and the macOS app passes `ZAPPER_NODE` and `ZAPPER_PM2_JS` so the CLI can invoke PM2 through the bundled Node runtime instead of `spawn("pm2")`. Runtime executable lookup is centralized behind platform adapters in the CLI. PM2, Docker, shell wrappers, log tailing, and URL opening are resolved through the same boundary instead of scattering `process.platform` checks across command implementations. The adapters keep the default behavior simple: - macOS uses the bundled PM2 runtime when `ZAPPER_NODE` and `ZAPPER_PM2_JS` are present, otherwise falls back to `pm2`; - Linux uses the same PM2 environment override and otherwise falls back to `pm2`; - WSL is treated as Linux. Linux paths are preferred. If a user-provided `ZAPPER_NODE` or `ZAPPER_PM2_JS` value looks like a Windows absolute path, the adapter converts it with `wslpath -u` when available and falls back to the equivalent `/mnt//...` path; - native Windows falls back to `pm2.cmd` when no bundled PM2 runtime is configured. Remaining options if PM2 continues to be a source of local-machine compatibility issues: - include `pm2` in the CLI's production dependencies and run its JavaScript entrypoint with the same Node runtime that runs Zapper; - install or vendor a PM2 binary/script into the macOS app bundle; - replace PM2 long term with a native process supervisor if PM2 becomes the main remaining reliability risk. The current implementation follows the first option because it preserves PM2 behavior while removing the global `pm2` lookup for desktop-launched Zapper commands. ## System Registry Role The system registry under Application Support is useful for discovery, diagnostics, and repair, but it should not be required for first launch. Good uses for the registry: - last known working CLI runtime path; - last known working Node path; - last shell-derived `PATH`; - last PM2 invocation strategy; - diagnostic status from the CLI; - project and instance metadata for desktop display. Avoid making the desktop app depend on registry state before it can function. If the CLI has never been run, the desktop app should still be able to use its bundled runtime and show a clear empty or setup state. ## Homebrew Packaging Homebrew is a good distribution channel, but it should not be the only thing that makes the desktop app reliable. Useful Homebrew targets: - `brew install zapper` for the CLI; - `brew install --cask zapper` for the macOS app; - possibly a tap that installs both the CLI and app through one documented command. Homebrew can improve install ergonomics and upgrades. The app should still be self-contained when downloaded directly from the website or GitHub Releases. ## Options on the Table ### Bundle Node and the Existing TypeScript CLI This is the current recommendation. Pros: - fastest path to a reliable desktop app; - preserves the existing CLI implementation; - avoids a rewrite while the product surface is still moving; - fixes the primary Finder-launched app failure mode. Cons: - larger app bundle; - still depends on Node internally; - still needs a deliberate PM2 strategy; - project commands can still fail if the user's project environment is broken. ### Build the CLI as a JavaScript Binary Tools such as `pkg`, `nexe`, or Node single executable application support could produce a single CLI artifact. Pros: - simpler desktop invocation; - potentially simpler Homebrew packaging; - no external Node requirement for Zapper itself. Cons: - native modules and dynamic imports can complicate packaging; - TypeScript/Node ecosystem packaging can be brittle; - PM2 still needs attention; - debugging can be harder than with bundled JavaScript plus Node. This remains a reasonable follow-up if bundling Node and dependencies is not clean enough. ### Rewrite the CLI in Go or Rust This is a long-term option, not the near-term reliability fix. Pros: - single native binary; - strong fit for Homebrew and desktop bundling; - no Node runtime dependency for Zapper itself. Cons: - large rewrite cost; - risks slowing product iteration; - does not remove the need to run project commands in user environments; - PM2 replacement or interop still needs a design. Consider this only if the Node-based CLI runtime remains a recurring source of distribution problems after the bundled-runtime phase. ### Use the System Registry as Bootstrap The CLI could write paths and environment data into the global registry for the desktop app to consume. Pros: - useful repair and diagnostics path; - lets the desktop app reuse a known-good terminal environment; - can improve UX after a successful CLI run. Cons: - does not solve first launch; - creates stale-path problems after toolchain upgrades; - can make desktop behavior depend on hidden historical state. Use this as supplemental metadata, not as the primary runtime strategy. ## Phased Plan ### Phase 1: Current Patch - Find `zap` in more common locations. - Merge shell and package-manager paths when running `zap`. - Add an in-app CLI picker and persisted override. - Add an app icon and continue signing/notarizing releases. This improves the current release but does not fully solve runtime reliability. ### Phase 2: Bundled Desktop Runtime - Package Node into `Zapper.app`. - Package the built CLI and production dependencies into `Zapper.app`. - Have Swift invoke bundled Node plus bundled CLI directly. - Keep external CLI override for development and diagnostics. - Verify the released app works on a machine without a globally available `node`. Status: implemented for macOS release builds. ### Phase 3: PM2 Runtime Strategy - Make PM2 a runtime dependency rather than a required global executable. - Invoke PM2 through the same bundled Node runtime where possible. - Keep PM2 state compatible with CLI usage from the terminal. - Add diagnostics that distinguish "Zapper runtime failed" from "project command failed". Status: partially implemented. PM2 is bundled and invoked through `ZAPPER_NODE` and `ZAPPER_PM2_JS` for desktop-launched CLI commands, with runtime adapter functions keeping platform-specific path and command resolution out of command implementations. Better diagnostics are still pending. ### Phase 4: Install and Repair UX - Add a desktop diagnostics panel or command output view. - Add a CLI repair/doctor command if registry state becomes useful: `zap doctor`, `zap desktop repair`, or similar. - Store last known good runtime/environment metadata in the system registry. - Document Homebrew installation once the formula/cask exists. ### Phase 5: Re-evaluate Binary Distribution If the bundled Node approach remains fragile or too heavy, revisit: - JavaScript binary packaging; - a Go or Rust rewrite; - replacing PM2 with a native supervisor. The decision should be based on concrete failures from the bundled-runtime phase, not on the existence of Node alone. # Source: docs/output.md # Command Output This document describes Zapper's internal command output contract: the structured result a command produces after interpreting user input and running core logic. It is not about terminal styling. Human-readable text, JSON, and any future machine-readable stream should all be renderings of the same structured command output. ## Goals - Commands should have one clear output contract. - JSON output should be predictable across the CLI. - Human rendering and machine rendering should share the same source data. - Core execution code should not print user-facing output directly when it can report structured results instead. - Action commands should report what happened; query commands should return the information requested. ## Command Kinds Zapper commands mostly fall into two groups. ### Query Commands Query commands read state and return that state. Examples: - `zap status` - `zap ls` - `zap links` - `zap home` - `zap state` - `zap system projects` - `zap system resources audit` For these commands, JSON output is the data itself. Human output is a formatted view of the same data. Example query result: ```json { "services": [ { "name": "api", "status": "online" } ] } ``` ### Action Commands Action commands do work and return a report. Examples: - `zap up` - `zap down` - `zap restart` - `zap launch` - `zap reset` - `zap clone` - `zap task ` - `zap system resources cleanup` For these commands, JSON output is a receipt/report for the invocation. It should describe what was attempted, what changed, what was skipped, and what failed. Human output is a formatted view of that report and any progress events that contributed to it. Example action report: ```json { "status": "success", "action": "up", "started": ["api", "web"], "alreadyRunning": ["db"], "stopped": [], "failed": [], "opened": { "status": "success", "url": "http://localhost:3000" } } ``` ## Rendering Model The preferred flow is: ```text command input -> command/core logic -> structured command output -> renderer ``` Renderers decide how output is displayed: - Human renderer: progress lines, summaries, tables, warnings. - JSON renderer: one stable JSON value for `--json`. - JSONL renderer: one JSON event per line for commands that document streaming machine output. The command and core layers should not need to know whether the user requested human output or JSON except for behavior-affecting options. Output formatting is a renderer concern. ## Events and Reports Long-running action commands may produce progress before they finish. Internally, that progress should be represented as structured events, not direct text logs. Example events: ```jsonl {"type":"service.starting","service":"db"} {"type":"service.started","service":"db"} {"type":"service.starting","service":"api"} {"type":"service.started","service":"api"} {"type":"launch.opened","url":"http://localhost:3000"} {"type":"command.completed","status":"success"} ``` Those events can be rendered immediately for humans and reduced into the final action report for JSON. Public `--json` should remain a single JSON value. Commands that support streaming machine output should expose it through an explicit `--jsonl` flag rather than changing `--json`. ## Interactivity Interactivity is separate from output shape. `--json` controls rendering. It should not by itself mean "do not prompt" or "do not open a browser." If Zapper needs an automation-safe mode, use a separate option such as `--noninteractive` for behavior: - do not prompt; - do not require a TTY; - fail or skip instead of asking for confirmation; - avoid side effects that require a user session, if the command defines them that way. ## Implementation Guidance Current command handlers return `CommandResult` values and `commandResultRenderer` formats those values. Keep that central shape. When improving action commands: 1. Make the core operation return a structured report. 2. Replace direct user-facing logs from core execution with structured events where useful. 3. Have human output and JSON output render from the same result/report. 4. Keep migrations narrow. Convert one command family at a time, starting with `up`, `down`, and `restart`. Avoid building a broad framework before commands need it. The useful invariant is simple: commands produce structured output, renderers display it. ## Migration Phases Track this work in small, shippable phases. Each phase should preserve current human behavior unless the phase explicitly changes it. - [x] **Phase 0: Document the contract.** Define query data, action reports, renderer responsibilities, event terminology, and the `--json` / interactivity boundary. - [x] **Phase 1: Service action reports.** Make `up`, `down`, and `restart` return structured reports while preserving existing human progress output. Add `zap up -o/--open` as a compound action that starts services and reports homepage launch status. - [x] **Phase 2: Centralize service progress events.** Replace direct user-facing logs from service execution with structured events that the human renderer formats. - [x] **Phase 3: Reduce events into reports.** Build final action reports from emitted events rather than separately assembled arrays. - [x] **Phase 4: Apply the pattern to other action commands.** Migrate action commands one command family at a time. - [x] Simple action reports: `launch`, `clone`, and `reset`. - [x] Profile/environment changes. - [x] Git actions. - [x] System, global, instance, init, kill, and volume actions. - [x] **Phase 5: Add JSONL for service action events.** `up`, `down`, and `restart` support `--jsonl`, which streams structured service events and ends with a `command.completed` line. Keep `--json` as a single final JSON value. ## Naming The codebase may use names such as result, response, report, or output in different layers. Prefer these meanings: - **Result**: the command-level value returned to the CLI runner. - **Report**: the structured receipt returned by an action command. - **Data**: the structured value returned by a query command. - **Event**: a structured progress item emitted while an action is running. - **Output**: the general contract that renderers consume. # Source: docs/cli-development.md # Local Development Guide for contributing to zapper. ## Repository Layout Zapper is a pnpm workspace: - `packages/cli` contains the published CLI package, source, unit tests, e2e tests, examples, and CLI-specific tooling. - `apps/landing-page` contains the Next.js landing page. - `docs` contains the VitePress documentation site. Its Markdown files remain the source of truth, and `pnpm --filter @mp-lb/zapper-docs raw` generates `llms.txt` and `llms-full.txt` for agents and automation. - `infra` contains Terraform-managed deployment resources for the landing page. ## Prerequisites - Node.js 18+ - pnpm - PM2 (`npm install -g pm2`) - Docker (for testing docker services) ## Getting Started ```bash npm uninstall --global zapper-cli @maplab/zapper @mp-lb/zapper # Start fresh pnpm remove --global zapper-cli @maplab/zapper @mp-lb/zapper # Remove stale pnpm links pnpm add --global @mp-lb/zapper # Make sure it's installed with pnpm pnpm install pnpm build pnpm add --global "link:$(pwd)/packages/cli" --config.ignore-scripts=true ``` After linking, your global `zap` command points to the local CLI package build. Make changes, run `pnpm build`, and test immediately. If pnpm says its global bin directory is not on your `PATH`, run `pnpm setup` once and restart your shell before retrying the global commands. ## Linking & Unlinking ```bash which zap # Should show pnpm global path ls -la $(which zap) # Should symlink to packages/cli/dist/index.js sed -n '1,80p' $(which zap) # Should execute this checkout's packages/cli/dist/index.js pnpm list -g --depth 0 # Should show @mp-lb/zapper link:/packages/cli # Unlink when done pnpm remove --global @mp-lb/zapper npm install --global @mp-lb/zapper # Reinstall from npm ``` If `zap` still points at an old checkout, remove the stale global package name and link again: ```bash pnpm remove --global zapper-cli @maplab/zapper @mp-lb/zapper pnpm build pnpm add --global "link:$(pwd)/packages/cli" --config.ignore-scripts=true ``` ## Testing ```bash pnpm test # Run CLI unit tests pnpm test:watch # CLI unit test watch mode pnpm --filter @mp-lb/zapper test yaml-parser.test.ts # Specific CLI test file pnpm test:e2e # E2E in isolated Linux VM (macOS + Lima) pnpm dev:renderer # Renderer vibe sheet (local development preview) pnpm dev:landing # Landing page dev server pnpm dev:docs # VitePress docs dev server on 127.0.0.1:4315 pnpm docs:build # Build the docs site and generated raw docs ``` For manual CLI testing, use the example projects in `packages/cli/examples/`. After building, cd into one and run `zap up`. ## CLI Analytics The CLI sends one best-effort product analytics event, `command.run`, when a top-level command handler is invoked. Events use the shared event shape: the PostHog event name is `command.run`, `source.platform` is `cli`, and command breakdowns live in `details` as space-separated CLI names such as `command_l1: "profile"` and `command_l2: "profile use"`. The PostHog project token is public product analytics configuration. Put it in the repo root `.env.production` as `POSTHOG_KEY`; `POSTHOG_HOST` is optional and defaults to `https://us.i.posthog.com`. `pnpm build` injects those values into the compiled CLI package. Runtime `POSTHOG_KEY` and `POSTHOG_HOST` environment variables override the injected values for local testing. Analytics is intentionally silent and best-effort. Missing config, offline network, request timeouts, and PostHog errors do not print output or affect command exit behavior. Set `ZAPPER_ANALYTICS_DISABLED=1` or `DO_NOT_TRACK=1` to disable capture for a process. ## macOS Menu Bar App The native macOS app lives in `apps/macos`. It is a lightweight SwiftUI dashboard hosted by an AppKit menu bar status item. It shells out to the bundled `zap` CLI runtime for reads and actions: `zap system projects --json`, `zap home --json`, and `zap up`/`zap down`/`zap restart` for instances or individual services. It does not parse `.zap` state or `zap.yaml` directly. The first version does not require opening Xcode. Build and run it with: ```bash apps/macos/bin/build apps/macos/bin/run apps/macos/bin/clean ``` For the short local rebuild/restart loop, see [macOS Development](macos-development.md). The build script uses `swiftc` and writes `apps/macos/build/Zapper.app`. By default it also packages a local Node runtime, the built CLI, production CLI dependencies, and PM2 under `Contents/Resources/ZapperRuntime`. Run `pnpm --filter @mp-lb/zapper build` before `apps/macos/bin/build`. Set `PACKAGE_ZAPPER_RUNTIME=0` to skip runtime packaging for local Swift-only development. Release builds prefer the bundled `zap` wrapper so Finder-launched app sessions do not require a globally available `node`. `ZAPPER_CLI_PATH` and the in-app CLI picker in Settings remain available for development and diagnostics. The main dashboard lists stacks, where each stack is one project instance. Default instances show as the project name; non-default instances append the instance key in parentheses. If multiple stacks would render with the same name, the row also includes the instance label and random instance ID, falling back to just the ID when no label is set. Stack rows show running-service summaries with a small status LED and high-value actions. Expanded stack rows include project path and instance identity. Pinning lives in the stack overflow menu. The Open control is hidden when no homepage or project links are configured, opens directly when there is one target, and becomes a menu when there are multiple targets. Pinned stacks are stored in local app preferences and appear in a Pinned section above unpinned stacks. Missing registry entries appear as one compact, expandable warning with a Prune button. Clicking Prune replaces the warning with an inline destructive confirmation; accepting runs `zap global prune`. The menu bar status item stays compact: it shows the bolt icon and the running service count, without status words. Unpinned stacks are grouped into Active and Inactive sections using the same state as the stack LED: running, pending, or errored stacks are active; gray LED stacks are inactive. Expanded stack details group services by native and Docker runtime. Service start, stop, and restart controls live in each service overflow menu, and actions show immediate stale-state feedback in the stack and service rows before converging on the next real CLI refresh. Amber LEDs are reserved for real CLI-reported pending state; the spinner means the app knows the displayed state is stale. The open popover polls briefly at a faster cadence, settles to a slower idle cadence, polls at least every 8 seconds while closed, and polls more frequently while action state is stale. During refresh, the header keeps showing the last service summary and uses a fixed-size spinner/check indicator to avoid layout shifts. Paths, runtime metadata, last update time, last action, CLI override controls, refresh, and quit are tucked into info menus or the gear menu. The popover uses native macOS material and resizes to content up to a capped height before scrolling. GitHub Actions builds release assets through `.github/workflows/macos-release.yml`. The workflow runs on `v*` tags or manual dispatch, installs Node and pnpm, builds the CLI, builds the signed app with the bundled runtime, zips `Zapper.app`, and attaches both `Zapper--macOS.zip` and the stable `Zapper-macOS.zip` asset to the matching GitHub Release. Local app builds are ad-hoc signed unless `CODESIGN_IDENTITY` is set. Release builds load `CSC_LINK`, `APPLE_ID`, and `APPLE_TEAM_ID` from `.env.production`, decrypt `proj/secrets.txt.enc` with the GitHub Actions `SECRETS_KEY` secret, load `CSC_KEY_PASSWORD` and `APPLE_APP_SPECIFIC_PASSWORD`, and then sign with the hardened runtime before notarizing and packaging. The build signs nested runtime binaries with `apps/macos/Signing/Zapper.entitlements` so the bundled Node/V8 runtime can run under the hardened runtime. ## Documentation Site The docs website is a VitePress workspace package in `docs`. Keep editing the Markdown files in `docs/`; VitePress turns them into the website, and the raw docs generator publishes agent-friendly files from the same source. ```bash pnpm dev:docs pnpm docs:build pnpm docs:preview ``` `pnpm build` runs the docs build through Turbo. The generated site lives in `docs/.vitepress/dist`, and the published raw files are available at `/llms.txt` and `/llms-full.txt` in the built site. ### E2E in Linux VM (macOS) ```bash bash ./packages/cli/etc/e2e_setup.sh # One-time: install Lima + provision base VM pnpm test:e2e # Each run clones an isolated throwaway VM ``` Notes: - `pnpm test:e2e` runs in an ephemeral cloned VM and auto-deletes it on exit. - Base VM name defaults to `zapper-e2e-base` (override with `ZAP_E2E_BASE_VM_NAME`). - Keep a failed run VM for debugging: `ZAP_E2E_KEEP_VM=1 pnpm test:e2e`. - By default, `pnpm test:e2e` is strict and fails if VM setup is missing. ## Web Deployment The landing page lives in `apps/landing-page`, and the docs site lives in `docs`. Both are deployed by `.github/workflows/deploy-web.yml`. Deployment resources are managed through Terraform in `infra`. Terraform creates separate Vercel projects for the landing page and docs site, plus Cloudflare DNS records for `zapper.mp-lb.dev` and `docs.zapper.mp-lb.dev` by default. The landing page deploy is built from the repo's root pnpm workspace metadata. Keep the root `package.json`, `pnpm-workspace.yaml`, and `pnpm-lock.yaml` as the deploy source of truth, and do not commit a separate `apps/landing-page/package-lock.json`. A stray npm lockfile in the app directory can cause Vercel to detect the wrong package manager for that project. ```bash cd infra terraform init -backend-config="bucket=-terraform-state" -backend-config="prefix=terraform/state/zapper" terraform apply \ -var="project_name=zapper" \ -var="vercel_api_token=$VERCEL_API_TOKEN" \ -var="cloudflare_api_token=$CLOUDFLARE_API_TOKEN" ``` The workflow provisions the Vercel projects/domains through Terraform, builds `@mp-lb/zapper-landing-page` and `@mp-lb/zapper-docs` for verification, deploys the landing page from the repo root using Vercel's prebuilt output, then deploys the docs site from the `docs` workspace directory. The landing page `/download/mac` route redirects to the latest macOS GitHub Release zip. Add `GCP_SA_KEY`, `VERCEL_API_TOKEN`, `CLOUDFLARE_API_TOKEN`, optional `VERCEL_ORG_ID`, and optional `DESKTOP_RELEASES_GITHUB_TOKEN` to `proj/secrets.txt.enc`; GitHub Actions decrypts it with `SECRETS_KEY`. `GCP_PROJECT_ID` is derived from the service account JSON when it is not provided separately. Terraform resolves the active Cloudflare zone from `domain`. Terraform passes the desktop releases token into the Vercel project runtime environment when present. ## Release CI Auth Release publishing runs through `.github/workflows/release.yml`. - The workflow decrypts `proj/secrets.txt.enc` with the repository `SECRETS_KEY` GitHub Actions secret. - `proj/secrets.txt.enc` must contain `NPM_TOKEN` with publish access to `@mp-lb/zapper`. - If release CI fails with auth or 2FA errors, confirm `NPM_TOKEN` exists in the encrypted secrets file and belongs to an npm identity with publish rights for the `@mp-lb` scope. # Source: docs/macos-development.md # macOS Development The native menu bar app lives in `apps/macos`. You do not need Xcode for the normal local loop. ## Fast Local Loop Build the CLI, rebuild the app bundle, stop any running Zapper app, and launch the freshly built development app: ```bash pnpm --filter @mp-lb/zapper build apps/macos/bin/run ``` `apps/macos/bin/run` calls `apps/macos/bin/build`, kills running `Zapper` processes, then opens `apps/macos/build/Zapper.app`. Use this when the production app is already running and you want the menu bar app you see to be the local build. ## Useful Commands ```bash apps/macos/bin/build # Build apps/macos/build/Zapper.app apps/macos/bin/run # Build, stop running Zapper apps, start the local app apps/macos/bin/clean # Remove apps/macos/build ``` CLI npm release publishing is handled by `.github/workflows/release.yml` using the `NPM_TOKEN` value from `proj/secrets.txt.enc`. The macOS app release workflow is separate and starts from pushed `v*` tags after the CLI package version is final. By default the app build packages the built CLI, Node runtime, production CLI dependencies, and PM2 into the app bundle. If you only changed Swift code and want a faster local-only build, you can skip runtime packaging: ```bash PACKAGE_ZAPPER_RUNTIME=0 apps/macos/bin/run ``` ## Notes - The app shells out to its bundled `zap` wrapper for `zap system projects --json`, links, and start/stop/restart actions. - Use the gear menu to choose an external CLI only when debugging CLI selection. - If the popover still looks stale, open the app menu and refresh after launch.