# Preview and apply changes

> Diff live versus desired with plan, inspect a redacted payload with apply --dry-run, then PUT with apply --yes.

- 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: "Preview and apply changes"
description: "Diff live versus desired with plan, inspect a redacted payload with apply --dry-run, then PUT with apply --yes."
---

`vwaffle plan` diffs the active Vercel Firewall config against the interpolated desired file. `vwaffle apply --dry-run` prints that payload with secret values replaced by `[REDACTED]` and never calls the API. `vwaffle apply --yes` reprints a redacted live-versus-desired diff, then `PUT`s the desired JSON to `/v1/security/firewall/config` unless the configs already match.

## Command surfaces

| Command | Reads desired file | Calls Vercel | Writes Vercel | Interpolation | Printed output |
| --- | --- | --- | --- | --- | --- |
| `vwaffle plan` | Yes (`strict: false`) | `GET .../config/active` | No | Missing `${VAR}` warns; matching `rules` entries are dropped | Line diff, or `No drift detected.` |
| `vwaffle apply --dry-run` | Yes (`strict: false`) | No | No | Same skip-and-warn as `plan` | Redacted desired JSON |
| `vwaffle apply --yes` | Yes (`strict: true`) | `GET .../config/active`, then `PUT .../config` on drift | Yes, when the diff is not `No drift detected.` | Missing `${VAR}` fails the command | Redacted diff, then an apply line |

Default desired path is `firewall.config.json` in the current working directory (`-c` / `--config FILE`). `plan` and `apply --yes` resolve `VERCEL_TOKEN`, project ID, and optional team ID through `resolveContext`. `apply --dry-run` never calls `resolveContext`, so it does not require a token.

<Warning>
`apply` has no interactive prompt. Without `--yes` or `--dry-run` the process throws `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` and exits `1`.
</Warning>

## Prerequisites

- A desired file (scaffold with `vwaffle init`, or fetch with `vwaffle pull --output firewall.config.json`).
- For `plan` and `apply --yes`: `VERCEL_TOKEN`, plus a project ID from `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json` (`projectId` after `vercel link`).
- Optional team ID from `--team`, `VERCEL_TEAM_ID`, or `.vercel/project.json` (`orgId`).
- Environment values for every `${VAR_NAME}` placeholder you intend to send. `apply --yes` refuses unset variables; `plan` and `apply --dry-run` warn and omit the affected custom rules.

## Preview then apply

<Steps>
<Step title="Diff live against desired">

```sh
vwaffle plan
vwaffle plan -c path/to/firewall.config.json
vwaffle plan --project prj_xxx --team team_xxx
```

`cmdPlan` loads the desired file with `loadDesiredConfig(..., { strict: false })`, fetches the live config, and prints `diff(live, desired)`.

</Step>
<Step title="Inspect the PUT body without writing">

```sh
vwaffle apply --dry-run
vwaffle apply --dry-run --yes
```

`--dry-run` short-circuits after interpolation. Combined `--yes --dry-run` still prints the payload and does not `PUT`.

</Step>
<Step title="Confirm and PUT">

```sh
vwaffle apply --yes
```

The command prints the redacted diff first. If the result is exactly `No drift detected.`, it returns without `PUT`.

</Step>
</Steps>

## Lifecycle

```mermaid
sequenceDiagram
    participant File as firewall.config.json
    participant CLI as vwaffle
    participant API as Vercel Firewall API

    alt plan
        CLI->>File: loadDesiredConfig(strict false)
        CLI->>API: GET /v1/security/firewall/config/active
        API-->>CLI: active config
        Note over CLI: print diff(live, desired)
    else apply --dry-run
        CLI->>File: loadDesiredConfig(strict false)
        Note over CLI: print redacted(desired)<br/>no resolveContext, no HTTP
    else apply --yes
        CLI->>File: loadDesiredConfig(strict true)
        CLI->>API: GET /v1/security/firewall/config/active
        API-->>CLI: active config
        Note over CLI: print redactText(diff)
        alt No drift detected.
            Note over CLI: return, no PUT
        else drift
            CLI->>API: PUT /v1/security/firewall/config
            API-->>CLI: version?
        end
    end
```

## `plan` output

`diff` pretty-prints both sides with `JSON.stringify(..., null, '\t')`. Identical strings yield `No drift detected.` Otherwise the printer emits a two-line header and an LCS line diff with three lines of unchanged context. Unchanged spans collapse to `  ...`.

<ResponseExample>

```text
--- live firewall configuration
+++ desired firewall configuration
  	"firewallEnabled": true,
  	"rules": [
  		{
- 			"name": "Block sensitive paths",
+ 			"name": "Block admin and git",
  			"active": true,
```

</ResponseExample>

Prefixes:

| Prefix | Meaning |
| --- | --- |
| `  ` (two spaces) | Line present on both sides |
| `- ` | Present on live, absent from desired |
| `+ ` | Present on desired, absent from live |
| `  ...` | Omitted unchanged region |

`plan` interpolates `${VAR_NAME}` into the desired object before the compare, then prints that diff **without** running `redactText`. Interpolated secret values can appear on `+` lines. Use `apply --dry-run` or `apply --yes` when the printed text must hide those values.

`plan --check` still prints the same result. When it is not `No drift detected.`, `process.exitCode` is set to `1`. That path belongs to CI drift detection, not to apply.

## `apply --dry-run` payload

`apply --dry-run` prints `redacted(config, secrets)`: tab-indented JSON of the interpolated desired object, with every recorded secret string replaced by `[REDACTED]`. Empty secret values are not replaced. There is no live fetch and no `--- live` / `+++ desired` header.

<RequestExample>

```sh
vwaffle apply --dry-run
```

</RequestExample>

<ResponseExample>

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

</ResponseExample>

<Note>
Dry-run uses the same non-strict interpolation as `plan`. Unset variables produce `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)` on stderr, and `removeRulesWithMissingEnv` drops only `rules` entries whose serialized body still contains `${NAME}`. `managedRules` and `ips` are not filtered that way.
</Note>

## `apply --yes` write path

After a successful strict load, `apply --yes` fetches live config, prints `redactText(diff(live, desired), secrets)`, and only then writes.

:::endpoint PUT /v1/security/firewall/config Write the interpolated desired config
Base URL is `${VERCEL_API_URL ?? 'https://api.vercel.com'}/v1/security/firewall/config`. Query params: required `projectId`, optional `teamId`. Header `Authorization: Bearer ${VERCEL_TOKEN}`. Body is the interpolated `FirewallConfig` JSON (`firewallEnabled`, `managedRules`, `rules`, `ips`, and any other keys the file carries). Live config is read from `GET /v1/security/firewall/config/active` on the same base; `getActiveConfig` uses `response.active` when present.
:::

Success on stdout:

```text
Applied firewall configuration.
```

or, when the PUT JSON includes `version`:

```text
Applied firewall configuration (version 12).
```

No-op success is a single line:

```text
No drift detected.
```

There is no rollback, no version pin on the request, and no confirmation beyond the `--yes` flag.

## Flags used on this path

<ParamField body="--config" type="string">
Alias `-c`. Desired file path, resolved from `process.cwd()`. Default `firewall.config.json`.
</ParamField>

<ParamField body="--dry-run" type="boolean">
`apply` only. Print the redacted desired payload and return. Overrides `--yes` when both are set.
</ParamField>

<ParamField body="--yes" type="boolean">
Required to `PUT` (unless `--dry-run`). There is no TTY prompt.
</ParamField>

<ParamField body="--project" type="string">
Project ID for `plan` and `apply --yes`. Overrides `VERCEL_PROJECT_ID` and `.vercel/project.json`.
</ParamField>

<ParamField body="--team" type="string">
Team ID for those same commands. Overrides `VERCEL_TEAM_ID` and `.vercel/project.json` `orgId`.
</ParamField>

<ParamField body="--check" type="boolean">
`plan` only. Exit `1` on drift. Ignored by `apply`.
</ParamField>

## Interpolation on preview versus apply

`${VAR_NAME}` in string fields matches `[A-Za-z_][A-Za-z0-9_]*`. Replacements are recorded in a `secrets` map used by `redacted` and `redactText`.

| Mode | `loadDesiredConfig` `strict` | Unset `${VAR}` |
| --- | --- | --- |
| `plan` | `false` | Warn; drop matching `rules` |
| `apply --dry-run` | `false` | Same as `plan` |
| `apply --yes` | `true` | Throw `missing environment variables: NAME ($.path). Set them before applying the firewall configuration.` |

A dry-run payload can therefore omit rules that a later `apply --yes` will refuse to send until the variable is set.

## Errors and exit codes

Failures print `vwaffle: <message>` on stderr and set `process.exitCode` to `1`.

| Condition | Message / signal |
| --- | --- |
| `apply` without `--yes` or `--dry-run` | `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` |
| Missing `VERCEL_TOKEN` on `plan` / `apply --yes` | `VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.` |
| Unresolved project ID | `A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` so .vercel/project.json exists.` |
| Unset `${VAR}` on `apply --yes` | `missing environment variables: NAME ($.path). Set them before applying the firewall configuration.` |
| Firewall API error | `Vercel API <status> <statusText>: <body>` |
| `plan --check` with drift | Diff printed; exit code `1` (not used by `apply`) |

## Verification

| After | Expect |
| --- | --- |
| `vwaffle plan` with matching configs | `No drift detected.` and exit `0` |
| `vwaffle plan` after a local edit | `--- live firewall configuration` / `+++ desired firewall configuration` plus `- ` / `+ ` lines |
| `vwaffle apply --dry-run` | Tab-indented JSON; secrets as `[REDACTED]`; no `Applied firewall configuration` line |
| `vwaffle apply --yes` with drift | Redacted diff, then `Applied firewall configuration` (optional `(version N)`) |
| `vwaffle apply --yes` with no drift | `No drift detected.` only |
| Immediate `vwaffle plan` after a successful apply | `No drift detected.` |

## Related pages

<CardGroup>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
plan versus apply, --check exit codes, --dry-run, and the --yes gate.
</Card>
<Card title="Diff output" href="/diff-output">
How diff, diffLines, and withContext render live-versus-desired changes.
</Card>
<Card title="Secret interpolation" href="/secret-interpolation">
When ${VAR_NAME} is expanded, skipped, or fails apply, and how [REDACTED] is written.
</Card>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run plan --check so a dashboard edit fails the build.
</Card>
<Card title="Apply from CI" href="/apply-from-ci">
Promote firewall.config.json with apply --yes on merge.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands and flags: plan, apply, --dry-run, --yes, --config, --project, --team.
</Card>
</CardGroup>
