From 2e4b06c957aec11237509f995bfa128c1574d981 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 8 Oct 2026 13:43:05 -0700 Subject: [PATCH 1/2] feat!: remove allowArbitraryFlags parse override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the Command.parse() override gated behind allowArbitraryFlags, along with the allowArbitraryFlags instance property and the yargs-parser / yargs-unparser (and @types) dependencies that only it used. The override existed solely to rescue a long-deprecated input style for heroku addons:create — passing arbitrary add-on config flags WITHOUT the '--' end-of-options separator (deprecated in changelog item 2925). The supported '--' syntax does not depend on this code: with 'static strict = false', tokens after '--' already flow into argv for the command to read. The override was also fragile: it round-tripped argv through yargs-parser and yargs-unparser, which coerces types (e.g. 1.20 -> 1.2, --flag=false -> --no-flag) and mangles flag names (camelCase/dot/short-flag expansion), and it was untested in this library. BREAKING CHANGE: Command#allowArbitraryFlags and the result.nonExistentFlags field it produced are removed. Commands that passed arbitrary flags without a '--' separator must now use the '--' end-of-options separator. heroku/cli's addons:create must be updated in the same release to stop setting allowArbitraryFlags and reading nonExistentFlags. --- package-lock.json | 26 +++++++---------------- package.json | 6 +----- src/command.ts | 53 ----------------------------------------------- 3 files changed, 8 insertions(+), 77 deletions(-) diff --git a/package-lock.json b/package-lock.json index e773465..2de1b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,9 +19,7 @@ "execa": "^9.6.1", "inquirer": "^12.11.1", "open": "^11.0.0", - "tsheredoc": "^1.0.1", - "yargs-parser": "^22.0.0", - "yargs-unparser": "^2.0.0" + "tsheredoc": "^1.0.1" }, "devDependencies": { "@heroku-cli/schema": "^1.0.25", @@ -35,8 +33,6 @@ "@types/proxyquire": "^1.3.31", "@types/sinon": "^21.0.0", "@types/supports-color": "^5.3.0", - "@types/yargs-parser": "^21.0.3", - "@types/yargs-unparser": "^2.0.3", "c8": "^12.0.0", "chai": "^6.2.2", "chai-as-promised": "^8.0.1", @@ -2419,20 +2415,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs-unparser": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-unparser/-/yargs-unparser-2.0.3.tgz", - "integrity": "sha512-4yUMRVOqW0s+sohpHbHmNf3G+cAK6gw9vHPHdWQ9w5/nzS7vEPHRvlLVhCJ748h9zYkWkAtDn8pTAuLwCvn3DQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", @@ -3297,6 +3279,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3714,6 +3697,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4982,6 +4966,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, "license": "BSD-3-Clause", "bin": { "flat": "cli.js" @@ -9061,6 +9046,7 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -9070,6 +9056,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, "license": "MIT", "dependencies": { "camelcase": "^6.0.0", @@ -9085,6 +9072,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" diff --git a/package.json b/package.json index 260c2a1..f542d7c 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,7 @@ "execa": "^9.6.1", "inquirer": "^12.11.1", "open": "^11.0.0", - "tsheredoc": "^1.0.1", - "yargs-parser": "^22.0.0", - "yargs-unparser": "^2.0.0" + "tsheredoc": "^1.0.1" }, "devDependencies": { "@heroku-cli/schema": "^1.0.25", @@ -33,8 +31,6 @@ "@types/proxyquire": "^1.3.31", "@types/sinon": "^21.0.0", "@types/supports-color": "^5.3.0", - "@types/yargs-parser": "^21.0.3", - "@types/yargs-unparser": "^2.0.3", "c8": "^12.0.0", "chai": "^6.2.2", "chai-as-promised": "^8.0.1", diff --git a/src/command.ts b/src/command.ts index 64d0e21..88453b1 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,5 +1,4 @@ import {Command as Base} from '@oclif/core/command' -import {type CLIError} from '@oclif/core/errors' import * as Flags from '@oclif/core/flags' import {APIClient, type IOptions} from './api-client.js' @@ -21,7 +20,6 @@ export abstract class Command extends Base { */ static promptFlagActive = true _heroku!: APIClient - allowArbitraryFlags = false /** * Helper function to get baseFlags without the prompt flag @@ -87,55 +85,4 @@ export abstract class Command extends Base { const Ctor = this.constructor as typeof Command return Ctor.promptFlagActive && ('prompt' in Ctor.baseFlags) } - - protected async parse(options?: any, argv?: string[]): Promise { - if (this.allowArbitraryFlags) { - try { - return await super.parse(options, argv) - } catch (error) { - const parser = (await import('yargs-parser')).default - const unparser = (await import('yargs-unparser')).default - const {flags: nonExistentFlags} = error as CLIError & {flags: string[]} - const parsed = parser(this.argv) - const nonExistentFlagsWithValues = {...parsed} - - if (nonExistentFlags && nonExistentFlags.length > 0) { - this.warn(`You're using a deprecated syntax with the [${nonExistentFlags.join(',')}] flag.\nAdd a '--' (end of options) separator before the flags you're passing through.`) - for (const flag of nonExistentFlags) { - const key = flag.replace('--', '') - Reflect.deleteProperty(parsed, key) - } - } - - for (const key in parsed) { - if (Reflect.has(parsed, key)) { - Reflect.deleteProperty(nonExistentFlagsWithValues, key) - } - } - - this.argv = unparser(parsed as any) - const result = await super.parse(options, argv) - result.nonExistentFlags = unparser(nonExistentFlagsWithValues as any) - - for (let index = 0; index < result.nonExistentFlags.length; index++) { - const positionalValue = result.nonExistentFlags[index] - const positionalValueIsFlag = positionalValue.startsWith('--') - if (positionalValueIsFlag) { - const nextElement = result.nonExistentFlags[index + 1] ?? '' - const nextElementIsFlag = nextElement.startsWith('--') - // eslint-disable-next-line max-depth - if (nextElement && !nextElementIsFlag) { - result.argv.push(`${positionalValue}=${nextElement}`) - } else if (!nextElement || nextElementIsFlag) { - result.argv.push(`${positionalValue}=true`) - } - } - } - - return result - } - } - - return super.parse(options, argv) - } } From 82c2a9652482ca18da15992c8f60d933c21ebb8d Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 8 Oct 2026 13:54:24 -0700 Subject: [PATCH 2/2] chore: allow-list the yargs dep removals in packed-consumer verifier The packed-consumer contract forbade any dependency removal versus the authoritative baseline. Removing the allowArbitraryFlags override dropped yargs-parser and yargs-unparser, tripping that check. Mirror the existing intentionalPackageAdditions / intentionalDependencyChanges pattern with an intentionalPackageRemovals allow-list so the intentional removal passes while unexpected removals still fail. --- scripts/verify-packed-consumer.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/verify-packed-consumer.mjs b/scripts/verify-packed-consumer.mjs index d3f2601..0f6db0b 100644 --- a/scripts/verify-packed-consumer.mjs +++ b/scripts/verify-packed-consumer.mjs @@ -73,6 +73,9 @@ const intentionalAdditions = new Set([ 'lib/login-state-coordinator.js', ]) const intentionalPackageAdditions = new Set(['@heroku/heroku-credential-manager']) +// Dependencies intentionally dropped versus the authoritative baseline. yargs-parser +// and yargs-unparser were only used by the removed allowArbitraryFlags parse override. +const intentionalPackageRemovals = new Set(['yargs-parser', 'yargs-unparser']) const sensitivePathPattern = /(^|\/)(?:\.npmrc|npmrc|npm-cache|\.npm|cache|logs?|_logs?)(?:\/|$)|(?:^|\/)(?:[^/]*(?:token|userconfig)[^/]*)$|heroku-credential-manager[^/]*\.tgz$/i const safeEnvironmentNames = new Set([ 'ALL_PROXY', @@ -560,7 +563,7 @@ async function verifyPackManifest(packMetadata, baseline) { const removedDependencies = Object.keys(baselineDependencies).filter(name => !(name in currentDependencies)) const changedDependencies = Object.keys(currentDependencies).filter(name => name in baselineDependencies && currentDependencies[name] !== baselineDependencies[name]) check(addedDependencies.every(name => intentionalPackageAdditions.has(name)), `unexpected dependencies added versus baseline: ${addedDependencies.join(', ')}`) - check(removedDependencies.length === 0, `dependencies removed versus baseline: ${removedDependencies.join(', ')}`) + check(removedDependencies.every(name => intentionalPackageRemovals.has(name)), `unexpected dependencies removed versus baseline: ${removedDependencies.filter(name => !intentionalPackageRemovals.has(name)).join(', ')}`) check(changedDependencies.every(name => intentionalDependencyChanges.has(name)), `unexpected dependency ranges changed versus baseline: ${changedDependencies.join(', ')}`) check(currentDependencies['@heroku/http-call'] === expectedDependencies.get('@heroku/http-call').version, '@heroku/http-call baseline range did not change to the required exact version') return {added, paths}