# Plan and apply lifecycle

> plan versus apply, --check drift exit codes, --dry-run payload print, and the --yes confirmation required to PUT.

- Repository: jaredpalmer/vwaffle
- GitHub: https://github.com/jaredpalmer/vwaffle
- Human docs: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581
- Complete Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/llms-full.txt

## Source Files

- `src/index.ts`
- `src/diff.ts`
- `src/api.ts`
- `src/config.ts`

---

---
title: "Plan and apply lifecycle"
description: "plan versus apply, --check drift exit codes, --dry-run payload print, and the --yes confirmation required to PUT."
---

`vwaffle plan` and `vwaffle apply` share one desired file (`firewall.config.json` by default) and one live snapshot from `GET /v1/security/firewall/config/active`. `plan` always diffs and never writes. `apply` is gated: `--dry-run` prints a redacted payload and returns with no Vercel request; `--yes` interpolates strictly, prints the redacted diff, and `PUT`s the full desired body only when `diff` is not `No drift detected.`. Bare `apply` throws.

## Plan versus apply

| Surface | `plan` | `apply --dry-run` | `apply --yes` |
| --- | --- | --- | --- |
| Desired load | `loadDesiredConfig(..., { strict: false })` | same as `plan` | `loadDesiredConfig(..., { strict: true })` |
| Unset `${VAR_NAME}` | warn + drop matching `rules` | warn + drop matching `rules` | throw, no API call |
| `resolveContext` / `VERCEL_TOKEN` | required | not called | required |
| Live fetch | `getActiveConfig` (`GET /active`) | none | `getActiveConfig` (`GET /active`) |
| Stdout | raw `diff(live, desired)` | `redacted(config, secrets)` JSON | `redactText(diff, secrets)` |
| Write | none | none | `putConfig` (`PUT` empty path) only on drift |
| Extra flags | `--check` sets exit code `1` on drift | `--dry-run` wins if `--yes` is also set | `--yes` required unless `--dry-run` |

`--check` is parsed globally but only read in `cmdPlan`. `--yes` is only read in `cmdApply`. `vwaffle --yes` with no command still runs `help`.

## Lifecycle

```mermaid
stateDiagram-v2
    [*] --> parseArgs
    parseArgs --> cmdPlan: plan
    parseArgs --> cmdApply: apply

    state cmdPlan {
        [*] --> loadDesiredLoose
        loadDesiredLoose --> getActiveConfig: resolveContext
        getActiveConfig --> printDiff: diff(live, desired)
        printDiff --> planExit0: not --check or NO_DRIFT
        printDiff --> planExit1: --check and drift
    }

    state cmdApply {
        [*] --> confirmGate
        confirmGate --> applyError: neither --yes nor --dry-run
        confirmGate --> dryRun: --dry-run
        confirmGate --> applyYes: --yes without --dry-run
        dryRun --> printPayload: loadDesiredConfig strict false
        applyYes --> fetchLive: loadDesiredConfig strict true
        fetchLive --> printRedactedDiff: GET /active then diff
        printRedactedDiff --> noPut: NO_DRIFT
        printRedactedDiff --> putConfig: drift
    }

    planExit0 --> [*]
    planExit1 --> [*]
    applyError --> [*]
    printPayload --> [*]
    noPut --> [*]
    putConfig --> [*]
```

`putConfig` sends the entire interpolated desired object, not a patch. The live GET exists so `apply --yes` can print a diff and skip the PUT when there is no drift.

## `plan`

```bash
vwaffle plan
vwaffle plan --check
vwaffle plan --config path/to/firewall.config.json --project prj_xxx --team team_xxx
```

`cmdPlan` sequence:

1. `loadDesiredConfig(resolve(cwd, options.config), { strict: false })`
2. `resolveContext({ project, team })`
3. `getActiveConfig` → unwraps `response.active` when present
4. `console.log(diff(live, config))`
5. If `--check` and the printed string is not `No drift detected.`, set `process.exitCode = 1`

Drift comparison is tab-indented `JSON.stringify` of live versus desired. Identical serialization prints exactly:

```text
No drift detected.
```

Otherwise stdout is a context-trimmed line diff:

```text
--- live firewall configuration
+++ desired firewall configuration
  {
- 	"firewallEnabled": false
+ 	"firewallEnabled": true
  }
```

<Note>
`plan` does not call `redactText`. Interpolated secret values appear in the desired side of the printed diff. Redaction runs on `apply --dry-run` (payload) and `apply --yes` (diff text).
</Note>

### `--check`

<ParamField body="--check" type="boolean" default="false">
Plan-only. After printing the same diff as a normal plan, set exit code `1` when the result is not `No drift detected.`. Does not change stdout and does not call `putConfig`.
</ParamField>

Use in CI so a dashboard edit that diverges from the versioned file fails the job. Without `--check`, drift still prints and the process exits `0`.

## `apply` confirmation

<ParamField body="--yes" type="boolean" default="false">
Confirm a real apply. Required unless `--dry-run` is set.
</ParamField>

<ParamField body="--dry-run" type="boolean" default="false">
Print the interpolated, redacted desired JSON and return. Skips `resolveContext`, `getActiveConfig`, and `putConfig`. If both `--dry-run` and `--yes` are passed, `--dry-run` wins after the load.
</ParamField>

### No confirmation

```bash
vwaffle apply
```

Throws before loading the file:

```text
apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.
```

`main` writes `vwaffle: ` plus that message to stderr and sets `process.exitCode = 1`.

### `apply --dry-run`

```bash
vwaffle apply --dry-run
vwaffle apply --dry-run --config firewall.config.json
```

- Loads desired config with `strict: false` (same missing-env policy as `plan`).
- Prints `redacted(config, secrets)`: tab-indented JSON with every recorded secret value replaced by `[REDACTED]`.
- Empty secret strings are not redacted.
- Does not require `VERCEL_TOKEN`, `--project`, or a linked `.vercel/project.json`.

<RequestExample>
```bash
vwaffle apply --dry-run
```
</RequestExample>

<ResponseExample>
```json
{
	"firewallEnabled": true,
	"rules": [
		{
			"name": "Bypass for internal service",
			"action": { "mitigate": { "action": "bypass" } },
			"conditionGroup": [
				{
					"conditions": [
						{
							"type": "header",
							"op": "eq",
							"key": "x-internal-token",
							"value": "[REDACTED]"
						}
					]
				}
			]
		}
	]
}
```
</ResponseExample>

### `apply --yes`

```bash
vwaffle apply --yes
vwaffle apply --yes --config firewall.config.json --project prj_xxx --team team_xxx
```

<Steps>
<Step title="Load desired config strictly">
`loadDesiredConfig(..., { strict: true })`. Any unset `${VAR_NAME}` throws `missing environment variables: NAME ($.path), .... Set them before applying the firewall configuration.`
</Step>
<Step title="Resolve project and fetch live">
`resolveContext` then `GET {VERCEL_API_URL}/v1/security/firewall/config/active?projectId=...` (`teamId` query when a team is resolved). Default host is `https://api.vercel.com`.
</Step>
<Step title="Print the redacted diff">
`diff(live, config)` then `redactText`. Same `No drift detected.` short-circuit string as `plan`.
</Step>
<Step title="PUT only on drift">
If the diff is `No drift detected.`, return with no write. Otherwise `putConfig` sends `PUT {VERCEL_API_URL}/v1/security/firewall/config` with `Authorization: Bearer ${VERCEL_TOKEN}` and `Content-Type: application/json`.
</Step>
</Steps>

Success line after a write:

```text
Applied firewall configuration.
```

If the PUT JSON includes `version`, the line becomes `Applied firewall configuration (version N).`

There is no version precondition, lock, or rollback. The GET is only used for the printed diff and the no-drift skip.

## Exit codes

| Situation | Exit code |
| --- | --- |
| `plan` with no drift | `0` |
| `plan` with drift, no `--check` | `0` |
| `plan --check` with drift | `1` (`process.exitCode`) |
| `apply --dry-run` after a successful load | `0` |
| `apply --yes` with no drift (no PUT) | `0` |
| `apply --yes` after a successful PUT | `0` |
| `apply` without `--yes` or `--dry-run` | `1` |
| Missing `VERCEL_TOKEN` / project on `plan` or `apply --yes` | `1` |
| Unset `${VAR_NAME}` on `apply --yes` | `1` |
| Non-OK Firewall API response | `1` |
| Unknown command or flag, missing file, invalid JSON | `1` |

Thrown errors are printed as `vwaffle: ${message}` on stderr.

## Interpolation during the lifecycle

| Command | `strict` | Missing `${VAR_NAME}` |
| --- | --- | --- |
| `plan` | `false` | `console.warn` then `removeRulesWithMissingEnv` |
| `apply --dry-run` | `false` | same as `plan` |
| `apply --yes` | `true` | throw before `resolveContext` |

Warning on the loose path:

```text
vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)
```

`removeRulesWithMissingEnv` drops a `rules[]` entry when `JSON.stringify(rule)` contains `` `${NAME}` `` for any missing name. `managedRules` and `ips` are not filtered. `plan --check` therefore diffs the **filtered** desired object: an unset secret can hide a custom rule from the comparison or make live look drifted because that rule is absent on the desired side. `apply --yes` refuses to proceed instead.

## Diff and PUT contract

`diff` in `src/diff.ts` serializes both sides with `JSON.stringify(..., null, '\t')`, computes an LCS line diff, and keeps three lines of context around each change (`withContext`). Unchanged spans collapse to `  ...`.

`getActiveConfig` reads `GET /active` and returns `body.active` when that field exists, otherwise the body itself. `putConfig` PUTs the desired `FirewallConfig` to the collection URL (path `''` on `https://api.vercel.com/v1/security/firewall/config`, overridable with `VERCEL_API_URL`). Query params are always `projectId` and, when resolved, `teamId`.

Non-OK responses throw:

```text
Vercel API ${status} ${statusText}: ${body}
```

## Shared inputs

<ParamField body="--config" type="string" default="firewall.config.json">
Desired file path, also `-c`. Resolved with `resolve(process.cwd(), options.config)` for both commands.
</ParamField>

<ParamField body="--project" type="string">
Project ID override. Else `VERCEL_PROJECT_ID`, else `.vercel/project.json` `projectId`. Required for `plan` and `apply --yes`.
</ParamField>

<ParamField body="--team" type="string">
Team ID override. Else `VERCEL_TEAM_ID`, else `.vercel/project.json` `orgId`. Optional; omitted from the query string when unset.
</ParamField>

`VERCEL_TOKEN` is required for every path that calls `resolveContext` (`plan`, `apply --yes`, and `pull`). It is not read on `apply --dry-run`.

<Warning>
`apply --check` is a no-op: the flag is accepted by `parseArgs` and ignored by `cmdApply`. Drift gating for CI is `plan --check`, not apply.
</Warning>

## Related pages

<CardGroup>
<Card title="Preview and apply changes" href="/preview-and-apply-changes">
Operator path: plan, inspect with apply --dry-run, then PUT with apply --yes.
</Card>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run plan --check so a dashboard edit that diverges from the versioned file fails the build.
</Card>
<Card title="Apply from CI" href="/apply-from-ci">
Promote firewall.config.json by running apply --yes on merge.
</Card>
<Card title="Diff output" href="/diff-output">
How diff, diffLines, and withContext render live-versus-desired changes.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Unset ${VAR_NAME} on plan versus apply, skipped rules, and plan --check exit 1.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands, flags, and help text for init, pull, plan, and apply.
</Card>
</CardGroup>
