feat!: stop the installer from clobbering configured AuthKit projects - #205
feat!: stop the installer from clobbering configured AuthKit projects#205nicknisi wants to merge 3 commits into
Conversation
`npx workos` provisioned a fresh WorkOS environment and rewrote the project's env file before the installer state machine existed, then read back its own write when it later checked for existing credentials. In a project that already had AuthKit wired up, that replaced the real credentials and destroyed every comment in the file, and a failed run left all of it in place with no rollback. Reported against 0.18.0 by a customer whose Next.js app already had @workos-inc/authkit-nextjs, custom middleware, and SCIM/SSO reconciliation wired up. - Add a preflight guard (src/lib/preflight-authkit.ts) ahead of credential resolution in all three install entry points: it prompts on an interactive TTY, exits non-zero in agent/CI/JSON mode, and takes `--force` as the override. Matches AUTHKIT_PACKAGES only, so @workos-inc/node alone does not trip it. - Read the project's env file before provisioning, and refuse again at the write site, so a project that already has WORKOS_API_KEY is never provisioned over even by a direct caller. - Preserve comments, blank lines, and key order on every env write, and leave one git-ignored backup of the pre-CLI file. The backup is gitignored before it is written: `stageAndCommit` runs `git add -A`, so an unignored backup would commit a live API key. - Read the homepage URL before overwriting it, and state credential provenance in the dashboard-config output, so an unclaimed throwaway environment cannot be mistaken for production. - Classify deterministic gateway failures separately from transient ones, in one shared classifier instead of three duplicated regexes, so users stop being told to retry a failure that recurs every time. - Raise the 120s upstream socket timeout to 600s in both proxy factories. The reported path uses the claim-token proxy, so fixing only one was fixing neither. BREAKING CHANGE: `workos install` now stops in a project that already has an AuthKit SDK installed. Pass `--force` to continue.
Greptile SummaryThis PR fixes a real customer incident where
Confidence Score: 5/5Safe to merge. The guard runs before any credential resolution, the no-clobber invariant is enforced at two independent layers, and the env upsert replaces the destructive overwrite without touching keys it wasn't asked to change. The three structural correctness properties the PR turns on — guard placement before credential resolution, read-before-write at both the resolveInstallCredentials and tryProvisionUnclaimedEnv layers, and the failure-classifier ordering — are each individually tested. The failure classifier's round-trip tests confirm the deterministic verdict survives the emitError re-emit hop. The backup is gitignored before it's written. No rollback path introduces new data loss. Files Needing Attention: No files require special attention. The deliberate behaviour change in run-with-core.ts (reading .env for non-JS projects) is the only side-effect beyond the pure guard path, and it is documented in both the code and the PR description. Important Files Changed
|
Review found the no-clobber refusal only inspected one env file (`.env.local` whenever package.json exists), while credential-discovery already treats four files as WorkOS credential sources. A JS project whose real key lives in `.env`, with no AuthKit SDK yet, therefore still got a throwaway environment provisioned and `.env.local` written with it. `.env.local` outranks `.env` in Next.js/Vite/Remix/SvelteKit load order, so the app silently authenticated against an empty environment while the real key sat on disk looking correct: the read-back-your-own- write bug, surviving in the dashboard-first setup flow. `readProjectEnvCredentials` now scans all four files in framework precedence order and tolerates `export ` prefixes and indentation. `resolveProjectEnvPath` is unchanged: it is the write target, and conflating "which file would I clobber" with "does this project already have credentials" was the defect. Also from review: - Gate the preflight guard on `isJsonMode()` as well as `isPromptAllowed()`. `--json` on a real TTY took the interactive branch, printing prose into the machine-readable stream and failing as `prompt_unavailable` rather than `authkit_already_installed`. - Mirror the source file's permission bits onto the env backup, and birth new env files 0600. A `chmod 600 .env.local` was getting a world-readable twin holding a live API key, outside the `.env*` glob most secret scanners watch. - Preserve CRLF in env upserts instead of rewriting a Windows file to LF. - Explain the refusal on the path that actually fires. It previously logged to the debug file only, so the user saw a bare exit-4 `auth_required` indistinguishable from a provisioning failure. - Don't promise to rewrite an env file whose credentials will be kept. - Thread the API key into the provenance row, so a `--api-key` run stops naming a stored environment that the writes never touched. - Register `--force` in the machine-readable command tree, since the new error tells agents to pass it. - Change installer-core's empty-message fallback, which was byte- identical to the classifier's deterministic signature and would have rendered a git or filesystem failure as an AI-service failure. Test hardening for three assertions that passed with the fix reverted: the headless adapter had no coverage of the shared classifier at all, and the upstream_timeout and cli-adapter cases asserted only pre-existing behavior.
Three review findings from Greptile on #205. `upsertEnvLines` extracted the key with `trimmed.slice(0, eq)`, so any assignment the no-clobber reader recognizes but that form did not — `WORKOS_API_KEY = x` (spaces around `=`) or `export WORKOS_API_KEY=x` — failed the `pending` lookup. The old line kept its stale value and the new one was appended below it, leaving the file holding the key twice with conflicting values. The reader and writer now recognize the same shapes, and an `export ` prefix is preserved rather than dropped, since removing it would stop the var being exported when the file is sourced. The refusal message in `tryProvisionUnclaimedEnv` named `resolveProjectEnvPath` — the file the CLI *would* write — instead of the file the key was actually found in. In a JS project whose key lives in `.env`, that pointed the user at a `.env.local` that may not exist. Matches the idiom already used at `resolve-install-credentials.ts:60`. `workosRequest` sent `Content-Type: application/json` on every request, including the bodyless GET added for the homepage-URL read. Verified load-bearing by reverting each fix and observing the new tests go red: the two key-shape cases, the message-path case, and the header case. The indentation and commented-out-assignment cases are regression guards for behavior the refactor had to preserve, and pass either way.
Summary
A customer ran
npx workos@latestagainst a Next.js app that already had AuthKit fully wired up (@workos-inc/authkit-nextjs, custom middleware, SCIM/SSO reconciliation). The CLI provisioned a brand new unclaimed WorkOS environment, rewrote his.env.localwith those credentials, then died four minutes later on an opaque HTTP 500 from the LLM gateway and left everything it had written in place.He asked for "a check that detects an existing AuthKit install and stops before touching dashboard config." There wasn't one, and two of us thought there was. The check cited internally (
run-with-core.ts:252) is thecheckAuthenticationactor: it reads the CLI's own keyring config store and never looks at the project.detectSingleIntegrationonly answers which framework is present, and no@workos-inc/*package appears in it. The only AuthKit-aware detector we own (doctor/checks/sdk.ts) is reachable during install only after the agent has already run.The root cause is ordering, not a missing feature.
resolveInstallCredentialsruns in thebin.tshandlers before the installer state machine exists, provisions an unclaimed environment, and writes credentials into the project's env file.readExistingCredentialsthen reads that same file and finds the key the CLI just wrote. Two individually correct functions in the wrong order, where the guard silently reads back its own write. The terminalerrorstate has no rollback.What changed
src/lib/preflight-authkit.ts, wired ahead of credential resolution in all three install entry points (install,dashboard,$0). Prompts on an interactive TTY, exits non-zero in agent/CI/JSON mode,--forceoverrides.src/lib/project-env.tsreads whichever env file the CLI would write (.env.localwhenpackage.jsonexists, else.env) before provisioning.tryProvisionUnclaimedEnvrefuses again at the write site, so a direct caller can't bypass it.writeEnvLocaland the.envbranch ofwriteCredentialsEnvnow do line-preserving upserts, so comments, blank lines, and key order survive. One git-ignored backup of the pre-CLI file, written once and never overwritten.setHomepageUrlreads before writing and reportsalready setinstead of an unconditionalupdated. Output now states credential provenance, so an unclaimed throwaway can't be mistaken for production.failure-classifier.tsreplaces three duplicated 5xx regexes (agent-interface.ts,cli-adapter.ts,headless-adapter.ts). Deterministic gateway failures no longer render as "temporarily unavailable, try again in a few minutes."startCredentialProxyandstartClaimTokenProxy. The reported path uses the claim-token proxy, so fixing one was fixing neither.Two details worth a reviewer's eye:
ensureGitignore's existing covering patterns (.env.local,.env*.local,.env*) never match a.bak; a Next.js project ships.env*.localso the old guard no-ops; andstageAndCommitrunsgit add -Awith commit defaulting to true. An unignored backup would have committed a liveWORKOS_API_KEYandWORKOS_CLAIM_TOKEN.installer-core.tsre-emits the already-rendered message onto theerrorevent, so the adapters classify our own copy rather than the raw SDK text. Today's transient path only round-trips by accident ("temporarily unavailable"happens to matchservice.*unavailable). Hence the namedDETERMINISTIC_HEADLINEconstant and its round-trip test: without it the new verdict would be computed and then silently discarded one hop later.run-with-core.tsalso now reads.envfor non-JS projects, where the deleted private helper only ever read.env.local. That is the "close the read-back loop" half of the fix, but it is a behavior change beyond a pure refactor: a Django/Rails project withWORKOS_API_KEYin.envwill now have it picked up as existing credentials.Test plan
Unit tests only, by design, so the tests carry the whole guarantee. Several were mutation-checked (the fix was temporarily disabled to confirm the new assertions actually go red).
The three
grepchecks are deliberate.bin.tsrunsrunCli()at import and has no test seams, so the ordering invariant that this whole PR turns on cannot be asserted by a unit test. Guard placement relative toresolveInstallCredentialsis worth checking by eye in review.End-to-end sanity, run by hand:
WORKOS_MODE=agent workos install --install-dir <fixture with @workos-inc/authkit-nextjs>→ exit 1,{"error":{"code":"authkit_already_installed",...}}, and the fixture directory is untouched (no.env, no.env.local, no.gitignorewritten).@workos-inc/nodeonly sails past the guard, confirming no false positive on the base SDK.workos --force --helpparses on the.strict()$0parser.Not in this PR
LlmGatewayService.createMessagecallsmessages.createnon-streaming, and the pinned@anthropic-ai/sdk@0.40.0throws a status-lessAnthropicErrorfor anymax_tokens > 21,333, whichtoApiErrorResponseturns into an opaque 500. That is a separate PR againstworkos/workos(packages/api), independently mergeable. Its deploy should land with or after this release: once the gateway aggregates a stream internally, a non-streaming client receives no bytes until the turn completes, which the old 120s timeout would have killed.--repairfor projects the guard now turns away. The guard points atworkos doctor..envwriters in the python and go integrations still destroy comments.