# CLI reference

> vwaffle commands init, pull, plan, apply, and help, plus flags --config, --output, --check, --dry-run, --yes, --project, and --team.

- 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`
- `package.json`
- `src/api.ts`
- `README.md`

---

---
title: "CLI reference"
description: "vwaffle commands init, pull, plan, apply, and help, plus flags --config, --output, --check, --dry-run, --yes, --project, and --team."
---

`vwaffle` is the published binary (`dist/index.js`, shebang `#!/usr/bin/env node`) that parses `process.argv`, then dispatches `init`, `pull`, `plan`, `apply`, `help`, or `version`. The parser lives in `src/index.ts`. The first non-flag argument is the command. With no arguments, the command defaults to `help`.

<CodeGroup>

```sh title="installed binary"
vwaffle <command> [options]
```

```sh title="npx"
npx vwaffle <command> [options]
```

```sh title="bunx"
bunx vwaffle <command> [options]
```

```sh title="from source"
bun run src/index.ts <command> [options]
```

</CodeGroup>

Package `bin` maps `vwaffle` → `./dist/index.js`. Version printed by `-v` / `--version` is the string `0.1.0` in `src/index.ts` (same as `package.json`).

```text
vwaffle <command> [options]
        │
        ├─ init     write starter JSON (no API)
        ├─ pull     GET /v1/security/firewall/config/active
        ├─ plan     diff live vs desired  [--check → exit 1]
        ├─ apply    --dry-run | --yes PUT
        ├─ help     usage text (default)
        └─ version  print 0.1.0
```

## Invocation

| Rule | Behavior |
| --- | --- |
| First non-flag token | Becomes `command`. Later non-flag tokens are `unknown option`. |
| No command | `help` |
| `-h` / `--help` | Sets `command` to `help`, even after another command |
| `-v` / `--version` | Sets `command` to `version` |
| Unknown flag | Throws `unknown option <arg>` |
| Flag missing its value | Throws `<flag> requires a value` |
| Combined shorts (`-cv`) or `--config=file` | Not supported; treated as unknown options |
| Flags before the command | Accepted; parse is a single left-to-right pass |

Flags are global. The parser does not reject a flag on the wrong command. Unused flags are ignored (`--check` on `apply`, `--output` on `plan`, `--yes` on `pull`).

<RequestExample>

```sh
vwaffle help
vwaffle --version
vwaffle plan --check -c firewall.config.json --project prj_xxx --team team_xxx
```

</RequestExample>

## Commands

| Command | API | Config file | Confirmation | Exit 1 |
| --- | --- | --- | --- | --- |
| `init` | none | writes `--config` | refuses overwrite | file already exists |
| `pull` | `GET …/config/active` | none | — | missing token/project or API error |
| `plan` | `GET …/config/active` | reads `--config` (non-strict) | — | errors; also `--check` when drift |
| `apply` | `GET` then `PUT …/config` | reads `--config` (strict unless `--dry-run`) | `--yes` required unless `--dry-run` | missing `--yes`, missing env, API error |
| `help` | none | none | — | — |
| `version` | none | none | — | — |

Unknown commands throw `unknown command <name>. Run \`vwaffle help\`.`

### `init`

Writes a starter desired config. Does not call Vercel and does not read `VERCEL_TOKEN`.

- Target path: `resolve(process.cwd(), options.config)` (default `firewall.config.json`).
- If `access(target)` succeeds, throws `<file> already exists; refusing to overwrite.`
- Creates parent directories (`mkdir(..., { recursive: true })`).
- Serializes the built-in `STARTER_CONFIG` with tab indent and a trailing newline.

<ResponseExample>

```text
Wrote firewall.config.json. Edit it, then run `vwaffle plan`.
```

</ResponseExample>

Starter body:

```json
{
	"firewallEnabled": true,
	"managedRules": {
		"owasp": { "active": false }
	},
	"rules": [
		{
			"name": "Block sensitive paths",
			"description": "Deny requests to paths that should never be publicly reachable.",
			"active": true,
			"conditionGroup": [
				{
					"conditions": [{ "type": "path", "op": "pre", "value": "/.git" }]
				}
			],
			"action": { "mitigate": { "action": "deny" } }
		}
	],
	"ips": []
}
```

`vwaffle init -c path/to/custom.json` writes that path instead.

### `pull`

Resolves project context, `GET`s the active firewall config, and prints tab-indented JSON.

- Without `--output`: writes the JSON to stdout (no trailing log line).
- With `-o` / `--output FILE`: writes `resolve(process.cwd(), FILE)` plus a trailing newline, then logs success.

<ResponseExample>

```text
Wrote active firewall configuration to firewall.config.json.
```

</ResponseExample>

`pull` does not read `--config`. Use `--output` to choose the destination file.

### `plan`

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

- Unset `${VAR_NAME}` values warn and drop the referencing `rules` entries; the command still runs.
- Identical JSON prints `No drift detected.`
- Drift prints a unified-style line diff (`--- live firewall configuration` / `+++ desired firewall configuration`) with 3 lines of context.
- `--check` sets `process.exitCode = 1` when the printed result is not `No drift detected.` The diff is still written to stdout.

<RequestExample>

```sh
vwaffle plan
vwaffle plan --check
vwaffle plan -c ./configs/prod.firewall.json --project prj_xxx
```

</RequestExample>

### `apply`

Puts the interpolated desired config. One of `--yes` or `--dry-run` is required.

```mermaid
flowchart TD
  apply["vwaffle apply"] --> gate{"--yes or --dry-run?"}
  gate -->|neither| err["exit 1: apply requires --yes"]
  gate -->|"--dry-run"| loadLoose["loadDesiredConfig strict: false"]
  loadLoose --> payload["print redacted JSON and return"]
  gate -->|"--yes" only| loadStrict["loadDesiredConfig strict: true"]
  loadStrict --> ctx["resolveContext"]
  ctx --> live["getActiveConfig GET /active"]
  live --> d["diff live vs desired"]
  d --> out["print redacted diff"]
  out --> drift{"result === No drift detected."}
  drift -->|yes| stop["return; no PUT"]
  drift -->|no| put["putConfig PUT /v1/security/firewall/config"]
  put --> done["Applied firewall configuration (version N)."]
```

| Mode | Interpolation | Network | Output |
| --- | --- | --- | --- |
| `apply` with neither flag | not loaded | none | `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` |
| `apply --dry-run` | `strict: false` (warn + skip rules) | none; skips `resolveContext` | tab-indented JSON with secret values replaced by `[REDACTED]` |
| `apply --yes` | `strict: true` (throws if any `${VAR}` unset) | `GET /active`, then `PUT` unless no drift | redacted diff, then apply line |
| `apply --yes --dry-run` | treated as dry-run (`strict: false`, no PUT) | none | same as `--dry-run` |

`--dry-run` does not require `VERCEL_TOKEN` or a project ID.

On a successful PUT, the CLI prints `Applied firewall configuration` and appends ` (version N)` when the API body includes `version`. If the diff is `No drift detected.`, apply returns without PUT.

### `help`

Prints the usage block from `printHelp()`. Also selected by default, `-h`, or `--help`.

### `version`

Prints `0.1.0` to stdout. Selected by `-v` or `--version`. Not listed under Commands in the help text; it is listed under Options.

## Flags

<ParamField body="-c, --config" type="string" default="firewall.config.json">
Path to the desired config, resolved from `process.cwd()`. Used by `init` (write target), `plan`, and `apply`. Not read by `pull`.
</ParamField>

<ParamField body="-o, --output" type="string">
For `pull` only. Write the active config to this path instead of stdout. Resolved from `process.cwd()`.
</ParamField>

<ParamField body="--check" type="boolean" default="false">
For `plan` only. Sets `process.exitCode = 1` when live JSON differs from desired JSON. Does not change the printed diff.
</ParamField>

<ParamField body="--dry-run" type="boolean" default="false">
For `apply`. Print the redacted interpolated payload and return. No `resolveContext`, no GET, no PUT. Interpolation is non-strict.
</ParamField>

<ParamField body="--yes" type="boolean" default="false">
For `apply`. Confirm the PUT. Without `--yes` or `--dry-run`, apply throws.
</ParamField>

<ParamField body="--project" type="string">
Vercel project ID. First match among `--project`, `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId`. Required for `pull`, `plan`, and non-dry-run `apply`.
</ParamField>

<ParamField body="--team" type="string">
Vercel team ID. First match among `--team`, `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId`. Optional; when set, appended as `teamId` on Firewall API URLs.
</ParamField>

<ParamField body="-h, --help" type="boolean">
Force the `help` command.
</ParamField>

<ParamField body="-v, --version" type="boolean">
Force the `version` command.
</ParamField>

<Warning>
`--yes` is a hard gate, not an interactive prompt. There is no confirmation TTY. CI and local apply both pass `--yes` explicitly.
</Warning>

## Environment consumed by the CLI

| Variable | Required for | Role |
| --- | --- | --- |
| `VERCEL_TOKEN` | `pull`, `plan`, `apply --yes` | Bearer token. Only source; no flag. |
| `VERCEL_PROJECT_ID` | same, if `--project` and `.vercel/project.json` are absent | Project query param |
| `VERCEL_TEAM_ID` | optional | Team query param |
| `VERCEL_API_URL` | optional | API origin; default `https://api.vercel.com` |
| `${VAR_NAME}` matches in the JSON file | `plan` / `apply` | Interpolated into string fields |

`init`, `help`, `version`, and `apply --dry-run` do not call `resolveContext`.

String placeholders must match `/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/`. Interpolated values are recorded as secrets and replaced with `[REDACTED]` in `apply` stdout (payload and diff text).

## Exit codes and errors

Unhandled throws are printed as `vwaffle: <message>` on stderr and set `process.exitCode = 1`.

| Situation | Message / result |
| --- | --- |
| Unknown flag | `unknown option <arg>` |
| Flag without value | `<flag> requires a value` |
| Unknown command | `unknown command <name>. Run \`vwaffle help\`.` |
| `init` target exists | `<file> already exists; refusing to overwrite.` |
| `apply` without `--yes` or `--dry-run` | `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` |
| Missing token | `VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.` |
| Missing project | `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.` |
| Unset `${VAR}` on `plan` or `apply --dry-run` | stderr warning `vwaffle: warning: skipping rules that reference unset variables: …`; matching `rules` dropped |
| Firewall API non-OK | `Vercel API <status> <statusText>: <body>` |
| `plan --check` with drift | stdout is the diff; exit code `1` |
| `apply --yes` with no drift | stdout `No drift detected.`; exit `0`; no PUT |

## Help text

The built-in usage string is the operator-facing contract. It is what `vwaffle`, `vwaffle help`, and `vwaffle -h` print:

<ResponseExample>

```text
vwaffle 0.1.0 — config-as-code for Vercel WAF / Security / Bot Protection

Usage: vwaffle <command> [options]

Commands:
  init                  Write a starter firewall.config.json in the current directory
  pull                  Fetch the active config; print it or write --output FILE
  plan [--check]        Compare the active config with the desired config
  apply --yes           Print the diff and PUT the desired config
  help                  Show this help

Options:
  -c, --config FILE     Path to the desired config (default: firewall.config.json)
  -o, --output FILE     For pull, write the response to FILE instead of stdout
      --check           For plan, exit 1 when the live config differs
      --dry-run         For apply, print the redacted payload without an API call
      --yes             Confirm apply
      --project ID      Vercel project ID (or VERCEL_PROJECT_ID / .vercel/project.json)
      --team ID         Vercel team ID (or VERCEL_TEAM_ID / .vercel/project.json)
  -v, --version         Print the version

Environment:
  VERCEL_TOKEN          Required for pull/plan/apply (https://vercel.com/account/tokens)
  VERCEL_PROJECT_ID     Project to target when not linked via `vercel link`
  VERCEL_TEAM_ID        Team scope for the API token

Values like ${MY_SECRET} inside the config file are interpolated from the
environment and redacted in all output.
```

</ResponseExample>

## Related pages

<CardGroup>
  <Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
    How plan, --check, --dry-run, and --yes sequence GET, diff, and PUT.
  </Card>
  <Card title="Project and team context" href="/project-context">
    resolveContext order for token, --project, --team, and .vercel/project.json.
  </Card>
  <Card title="Scaffold and pull a config" href="/scaffold-and-pull">
    init versus pull --output for the first firewall.config.json.
  </Card>
  <Card title="Detect drift in CI" href="/detect-drift-in-ci">
    plan --check as a non-zero gate when the dashboard diverges from git.
  </Card>
  <Card title="Apply from CI" href="/apply-from-ci">
    apply --yes on merge with project, team, and token inputs.
  </Card>
  <Card title="Authentication and context errors" href="/authentication-errors">
    Missing VERCEL_TOKEN, unresolved project ID, and Firewall API failures.
  </Card>
</CardGroup>
