Repository navigation
feat!: safe, consistent --json error documents - #461
Merged
Merged
Conversation
- toErrorJson returns an allowlisted error document (message, name, exitCode, code, statusCode, id, resource, url, suggestions, cause). Strings are ANSI-stripped, size-capped and redacted (the loaded token, HEROKU_API_KEY, HRKU- tokens, Authorization values, URL credentials). The parser context, Config and API client never reach stdout. HEROKU_DEBUG/DEBUG writes the full redacted error to stderr. - catch() stops a running spinner first, so the JSON error document isn't buffered and lost. - Spinners are quiet under JSON output. - static jsonSuppressedBy lists flags that print raw output and turn JSON off. BREAKING CHANGE: --json error documents carry only the documented fields, and jsonEnabled() is false when a jsonSuppressedBy flag is set.
A loaded Config sends getAuth() through credential storage under the data dir, which hung on the Windows runners. Also restore sinon stubs after each test so one timeout can't fail the rest.
… env The static Command.run reloads the Config it is given; with process.env wiped, that reload hung on Windows. Construct the command and call _run() instead, and keep the platform environment, removing only HEROKU_* and DEBUG.
--prompt asks questions a script running with --json will never answer. Prompting runs in init(), before parse() enforces exclusive, and HEROKU_CONTENT_TYPE=json sets no flag, so init() checks jsonEnabled() and throws before any prompt. exclusive: ['json'] on the flag documents the conflict.
- Stop a spinner already running when quiet mode starts, so neither the error document nor the result lands in its buffer. - this.exit(n) under JSON output prints no document and exits with n. - name falls back to the error's class name; documented as informational. - Add requestId and retryAfter; export the ErrorDocument type; README section on the document and its compatibility rules. - jsonSuppressedBy is decided by the parsed flags only, and ignores a value that comes from the flag's default. - Debug output on stderr only for HEROKU_DEBUG=1|true, not any DEBUG. - A thrown non-Error (undefined, null, a plain object) becomes an Error before oclif's catch(), which crashed reading its properties. - The --prompt rejection has the code HEROKU_PROMPT_WITH_JSON.
michaelmalave
left a comment
Contributor
There was a problem hiding this comment.
Solid, well-tested change: the --json error document is now small, consistent and token-free, and the spinner and --prompt handling are clear improvements. Tests and typecheck pass on this branch. A few small things below.
michaelmalave
approved these changes
Nov 13, 2026
michaelmalave
left a comment
Contributor
There was a problem hiding this comment.
A few comments but overall nice work. LGTM
Bump @heroku/js-blanket to ^1.4.0 and use CREDENTIAL_PATTERNS in redactSecrets, replacing the hand-rolled Heroku token, Bearer/Basic and URL credential regexes. This covers --json error documents and HEROKU_DEBUG error output, and adds Digest and other Authorization schemes, quoted header values, and PEM private keys. Add CREDENTIAL_PATTERNS ahead of PII_PATTERNS in the credential Sentry scrubber, so a URL password is replaced as a whole rather than partly matched by the email pattern.
heroku-fetch errors have fixed messages such as "Resource not found", so the API's own text, which only exists in body.message, was lost. Use body.message when it is a non-empty string, through the same strip, redact and cap steps. http-call errors now drop the appended Error ID and URL lines, which are already in the id and url fields, so both clients give the same message.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Under
--json, oclif's defaulttoErrorJsonspreads the error into the document. A parse error carriesparse.input.context, which is the command itself: its Config and its API client, loaded token included. That makes documents of several MB that contain the user's API token. This PR fixes that and makes--jsonerrors the same shape for every command.toErrorJsonreturns{error: {message, name, exitCode, code?, statusCode?, requestId?, retryAfter?, id?, resource?, url?, suggestions?, cause?}}(src/json-error-document.ts). TheJsonErrorDocumenttype is exported, and the README describes the document.HEROKU_API_KEYby exact value, then@heroku/js-blanket'sCREDENTIAL_PATTERNS(1.4.0, feat(patterns): add CREDENTIAL_PATTERNS for credentials in free text js-blanket#77). Those coverHRKU-tokens,Authorizationheader values in header, JSON and object-literal form, standaloneBearer/Basiccredentials, URL passwords and PEM private keys.statusCode,id,resource,url, theRequest-Id(requestId) and a rate limit'sretryAfterare promoted from the response. The body is never copied.messageis the API's own message (body.message) when there is one. heroku-fetch's errors have fixed messages such asResource not found, so the API's text would otherwise be lost.nameis the error'sname, or its class name when that's justError(oclif's errors), such asNonExistentFlagsError. It's documented as informational: scripts match onexitCode,statusCode,idandcode.causeis followed up to 3 levels and stops on cycles.exitCodeis the code the process actually exits with. This needs@oclif/core5.1.9 or later, whosecatch()exits with the error's own code (2 for a parse error), so the dependency floor is now^5.1.9. Older versions exited 1 for every error under--json.undefined,null, a plain object) becomes an Error first. oclif'scatch()crashed reading its properties, sothrow undefinedprinted no document.HEROKU_DEBUG=1ortrue, the full error (stack included, secrets redacted) goes to stderr. stdout still gets only the document.DEBUGdoesn't turn this on, since other tools use it for their own namespaces.beforeSendnow appliesCREDENTIAL_PATTERNSbeforePII_PATTERNS. Credentials go first because the email pattern otherwise matchespass@hostin a database URL, which leaks the username and still leaves part of the URL behind.this.exit(n)under JSON output prints no document and exits withn. oclif printed anEEXIT: 0error document, even forthis.exit(0).catch(). oclif only stops a running spinner when JSON output is off. A running spinner buffers stdout, so under--jsonthe error document was never written.ux.actionwrites nothing and doesn't take over stdout/stderr. A spinner already running when the command starts (a prerun hook, a parent command) is stopped first. The originals are put back infinally().static jsonSuppressedBy. Lists flags that print raw output (e.g.authorizations:create --short). When one is set,jsonEnabled()is false. The parsed flags decide: an error before or during parsing is still a JSON document, and a value from the flag'sdefaultdoesn't count. The README documents it.--promptrejected under JSON output. Prompts wait for answers a script never types.init()throws "--prompt can't be used with --json" (codeHEROKU_PROMPT_WITH_JSON) before any prompt, so it renders as the error document.exclusive: ['json']alone isn't enough: prompting runs ininit(), beforeparse()enforces it, andHEROKU_CONTENT_TYPE=jsonsets no flag.Behavior changes worth noting:
--jsonerror documents now carry only the fields listed above.messageis the API's message. http-call errors no longer append theError ID,AppandSee … for more information.lines;idandurlcarry those, and the app name is dropped.jsonEnabled()can now return false under--json.--promptwith--jsonorHEROKU_CONTENT_TYPE=jsonnow fails instead of prompting.this.exit(n)under--jsonprints nothing.DEBUGalone no longer writes debug output under--json.HEROKU_DEBUGoutput and credential Sentry events:Authorizationvalue with a scheme other than Bearer or Basic (Digest, AWS4-HMAC-SHA256, …) is redacted to the end of the header value. The scheme word is kept for Digest, Negotiate, NTLM, OAuth and Token.-----BEGIN … PRIVATE KEY-----line is kept.Bearer/Basicvalue outside anAuthorizationheader is redacted only when it looks like a credential, so prose like "Basic dynos" is left alone. Inside anAuthorizationheader it is always redacted.@oclif/core5.1.9 or later is required. Under--json, a parse error or the--promptrejection now exits 2 instead of 1, matching text mode. heroku/cli already requires^5.1.9.Type of Change
Breaking Changes (major semver update)
!after your change type to denote a change that breaks current behaviorFeature Additions (minor semver update)
Patch Updates (patch semver update)
Testing
Notes: Run
npm run buildhere. Then, in a heroku/cli checkout on a v12 branch with--jsoncommands (e.g.eb/W-23478633/certs-json), point@heroku-cli/commandat this build:npm install --no-save ../heroku-cli-command. All steps below are read-only.Steps:
./bin/run regions --json --bogus; echo $?. Before this change, the document is several MB and contains your token. Now it's a few lines:messagenames--bogus,nameisNonExistentFlagsError,exitCodeis 2 and matches the exit status printed byecho $?, and there's noparsekey../bin/run certs:info --app <an app you can read> --json --name does-not-exist, or any request that fails while a spinner is running. The JSON error document prints, and no spinner text appears on stderr../bin/run authorizations:info 00000000-0000-0000-0000-000000000000 --json. The document hasstatusCode: 404,id: "not_found"and arequestId, and nobody.messageis the API's text, notResource not found, and has noError IDline.HEROKU_DEBUG=1. stderr shows the full error with a stack, and any token appears as[REDACTED]. stdout is still just the document. WithDEBUG=express:*instead, stderr is empty../bin/run certs:info --app <app>without--jsonstill shows the spinner as before../bin/run certs:info --json --prompt; echo $?. No prompt appears; stdout is an error document whose message says--promptcan't be used with--json, withcode: "HEROKU_PROMPT_WITH_JSON", andecho $?prints 2, matchingexitCode. Repeat asHEROKU_CONTENT_TYPE=json ./bin/run certs:info --promptfor the same result../bin/run certs:info --promptwithout--jsonstill prompts (Ctrl-C to quit).Related Issues
GUS work item: W-24877112