Skip to main content

cogs-env-doctor

@cogs/env-doctor diagnoses and fixes quote-wrapped secret values on Render environment variables — the recurring bug where FOO='abc123' is copy-pasted verbatim out of a shell-quoted .env file into Render's dashboard or API, where there is no shell to strip the quotes, so the running app reads the literal string 'abc123' instead of abc123.

It's private to this monorepo and is never published to npm. Run it with pnpm -w exec cogs-env-doctor … from inside a clone of cogs.

:::danger STOP — fix is not cleared to run against a real service

The AC0 Render API capability spike has not been run. See spikes/render-env-var-capability.md in the package — it is explicitly marked "SPIKE NOT YET RUN", and every one of its claims (a)–(e) is UNVERIFIED.

Until that spike is run and the document is replaced with real evidence:

  • Do not run cogs-env-doctor fix against any real Render service — production or otherwise. Every safety guarantee below (read-back verification, rollback, the exit-code outcome matrix) depends on unverified assumptions about Render's API: that list reads return plaintext values rather than redacted/hashed ones, that a single-key read-back endpoint exists and returns plaintext, that a single-key PUT round-trips with body { value } alone, that a PUT does not auto-trigger a deploy, and that the bare-path PUT really is the bulk-replace operation the negative safety test guards against.
  • Treat check results as provisional. If Render turns out to redact values on read, no value would ever look quote-wrapped and check would report a confident, wrong "clean."
  • The spike is write-capable and must be run against a disposable Render service created for the purpose — never cogs-send-service.

Everything in this package is unit-tested against a mocked fetchImpl. No test makes a real network call. Mocked tests prove the code does what it intends to do; they cannot prove Render behaves as assumed. That is exactly what the spike is for.

:::

What it does

  1. Greps every var for wrapping ' / " via check.
  2. Fixes via a single-var PUTPUT /v1/services/{serviceId}/env-vars/{key}, body { value }. It never bulk-replaces (PUT /v1/services/{serviceId}/env-vars, the bare path, would replace the entire list and delete anything omitted) — enforced by a negative unit test, not just convention.
  3. Reads back after every write, including on the rollback path, and, when the stripped value is JWT-shaped, decodes and validates it to confirm the fix actually worked.

Usage

# Diagnose (read-only, never mutates)
pnpm -w exec cogs-env-doctor check --service cogs-send-service --json

# Preview a fix (dry-run — this is the default; no write happens)
pnpm -w exec cogs-env-doctor fix --service cogs-send-service --key SUPABASE_ANON_KEY

# Apply it (requires an explicit --yes)
pnpm -w exec cogs-env-doctor fix --service cogs-send-service --key SUPABASE_ANON_KEY --yes

--help output

Real output from node packages/env-doctor/dist/cli.js --help in this repo:

cogs-env-doctor — Render env-var quoted-secret diagnosis and single-key fix

1. grep every var for wrapping ' or "
2. fix via single-var PUT — NEVER bulk replace
3. read back (including on rollback) and, when JWT-shaped, decode + validate

Usage:
cogs-env-doctor check --service <id|name> [--json]
cogs-env-doctor fix --service <id|name> --key <NAME> [--yes] [--json]

Options:
--service <id|name> Required. A Render service id, or a "name" from
render-deploy.config.json. No --all / fleet mode exists.
--key <NAME> Required for fix. Exactly one env-var key.
--yes Required before any network write. Without it, fix is a
dry run (exit 4).
--api-key-env <NAME> Env var holding the Render API key. Resolution order:
this flag > the matched config entry's renderApiKeyEnv
(matched-but-absent is a usage error, never a fallthrough)
> the literal RENDER_API_KEY (only when nothing matched).
--config <path> render-deploy.config.json path. Default: ./render-deploy.config.json
--json Emit the pinned JSON envelope on stdout.
-h, --help Show this help.
--version Print the installed version and exit.

Exit codes:
0 clean / no-op / fix confirmed (incl. a write that landed despite a client error)
1 unexpected or transport error
2 usage error (no network write attempted)
3 check: NOT verified clean — findings present and/or coverage incomplete
4 fix dry-run preview (no mutation)
5 mismatch after write; rollback issued AND read-back confirmed
6 STATE UNKNOWN — read-back unresolvable (possibly on the rollback path)
7 write failed transport-side; original value confirmed unchanged (safe to retry)
8 suspected concurrent out-of-band edit (no rollback attempted)

WARNING: the AC0 Render API capability spike has NOT been run. Do not run `fix`
against a real Render service until spikes/render-env-var-capability.md is
replaced with real evidence.

Flags

FlagApplies toMeaning
--service <id|name>bothRequired. A Render service id (srv-…) or a name from render-deploy.config.json. There is no --all / fleet-wide mode.
--key <NAME>fixRequired for fix. Exactly one env-var key. Zero or more than one is a usage error (exit 2).
--yesfixRequired before any network write. Without it, fix is a dry run (exit 4).
--api-key-env <NAME>bothName of the env var holding the Render API key. See resolution order below.
--config <path>bothPath to render-deploy.config.json. Defaults to render-deploy.config.json relative to cwd.
--jsonbothEmit the pinned JSON envelope on stdout (human text is suppressed / routed to stderr).
-h, --helpbothUsage.
--versionbothPrint the version read from this package's own package.json and exit — before any other validation runs.

--json envelope

Pinned. Both subcommands emit this shape:

{
"tool": "cogs-env-doctor",
"version": "0.0.0",
"command": "check" | "fix",
"service": "srv-…",
"exitCode": 0,
"outcome": "…", // stable machine-readable outcome slug
"findings": [ // check only
{ "key": "SUPABASE_ANON_KEY", "quoteChar": "'" }
],
"unchecked": [ // check only — scopes this tool cannot inspect
{ "scope": "env-group", "id": "evg-…" }
],
"partial": true, // check only — true whenever `unchecked` is non-empty
"readback": { // fix only, when a write was attempted
"intended": "****bcd1",
"preWrite": "****cd1'",
"observed": "****bcd1",
"rollbackAttempted": false,
"rollbackValue": "****cd1'",
"rollbackConfirmed": true
}
}

Every value-bearing readback field is masked to last-4 (**** + the last four characters, or **** outright for values of 4 characters or fewer).

--service and --api-key-env resolution

--service accepts either a Render service id or a name from render-deploy.config.json's services[], matched against both the renderServiceId and name fields.

The Render API key's env var name resolves in this order:

  1. --api-key-env <NAME> if given.
  2. Otherwise, the renderApiKeyEnv field of the matched render-deploy.config.json entry. If the entry exists but has no renderApiKeyEnv, this is a usage error (exit 2) — it does not fall through to step 3.
  3. The literal RENDER_API_KEY — used only when --service matched no config entry at all.

Known limitations

  • Not live until redeploy — and this claim itself is unverified (see the warning above). Render does not apply an env-var change to a running service until it is redeployed, so a fix that reports exit 0 has corrected the stored value, not necessarily the running one — unless a PUT turns out to auto-trigger a deploy, in which case this caveat inverts.
  • Environment groups are invisible to this tool. Vars set via a linked Render environment group live behind a separate API surface this package does not implement. check fails closed: when a service has linked env groups (or the env-group listing call fails), it reports them in unchecked, sets partial: true, and returns exit 3 — never a bare 0.
  • The redactor has an 8-character registration floor, ported from packages/supabase-sync: values shorter than 8 characters are not registered for redaction, so a stripped secret shorter than that will not be redacted from error output.
  • No rate-limit / backoff handling for paginated Render API listing calls.
  • Local .env file linting is out of scope for this tool. That's what config-lint is for.
  • Vercel is not supported.

See the package README for the full reference, including the complete exit-code precedence table and rollback semantics.