From 396457122f75fb01782da7044fa143ab6ddfd4ad Mon Sep 17 00:00:00 2001 From: Eric Black Date: Tue, 20 Oct 2026 11:07:56 -0700 Subject: [PATCH 1/2] chore(beta): refresh from v14.0.0 Bring the current v14.0.0 source into beta, including the #448 credential manager refactor (login off http-call) and the main-line catch-up from the recent v14.0.0 rebase (env-derived no-cache flag defaults, dependency bumps). Preserve the published 14.0.0-beta.2 package version, lockfile, beta manifest, and changelog baseline so the next Release Please candidate is 14.0.0-beta.3. No npm release is triggered by this PR. --- .../release-please-manifest.json | 2 +- .github/workflows/ci-acceptance.yml | 49 - .github/workflows/ci.yml | 19 - .github/workflows/release-on-push.yml | 1 + package-lock.json | 498 +--------- package.json | 22 +- .../verify-credential-manager-rollback.mjs | 887 ------------------ scripts/verify-packed-consumer.mjs | 820 ---------------- src/api-client.ts | 4 +- ...emetry.ts => credential-error-reporter.ts} | 26 +- .../credential-handlers/linux-handler.ts | 3 - .../credential-handlers/macos-handler.ts | 3 - .../credential-handlers/netrc-handler.ts | 3 - .../credential-handlers/windows-handler.ts | 3 - src/credential-manager-core/index.ts | 33 - .../lib/credential-manager-adapter.ts | 129 --- .../lib/credential-storage-selector.ts | 8 - .../lib/login-state.ts | 7 - .../lib/netrc-parser.ts | 20 - src/credential-manager-core/lib/types.ts | 12 - src/credential-manager-login-adapters.ts | 255 ++--- src/credential-manager.ts | 2 +- .../credential-handlers/linux-handler.ts | 191 ---- .../credential-handlers/macos-handler.ts | 160 ---- .../credential-handlers/netrc-handler.ts | 100 -- .../credential-handlers/windows-handler.ts | 172 ---- .../credential-manager-core/index.ts | 196 ---- .../lib/cli-command-telemetry.ts | 118 --- .../lib/credential-storage-selector.ts | 93 -- .../lib/login-state.ts | 49 - .../lib/netrc-parser.ts | 397 -------- .../credential-manager-core/lib/types.ts | 15 - src/env-auth.ts | 23 + src/file.ts | 24 - src/flags/app.ts | 2 + src/flags/org.ts | 2 + src/flags/team.ts | 2 + src/index.ts | 50 +- src/login-state-coordinator.ts | 7 +- src/login.ts | 5 +- test/api-client.test.ts | 2 +- test/autocomplete/completion-sources.test.ts | 19 +- ...t.ts => credential-error-reporter.test.ts} | 24 +- .../credential-manager-login-adapters.test.ts | 572 +++-------- .../acceptance/index.acceptance.test.ts | 239 ----- test/credential-manager/compatibility.test.ts | 357 ------- .../credential-handlers/linux-handler.test.ts | 321 ------- .../credential-handlers/macos-handler.test.ts | 243 ----- .../credential-handlers/netrc-handler.test.ts | 117 --- .../windows-handler.test.ts | 200 ---- .../source-declaration-compatibility.ts | 38 - .../fixtures/v13.2.0-storage-baseline.json | 69 -- .../helpers/acceptance-utils.test.ts | 257 ----- .../helpers/acceptance-utils.ts | 305 ------ test/credential-manager/helpers/netrc-stub.ts | 69 -- test/credential-manager/index.test.ts | 301 ------ .../lib/credential-storage-selector.test.ts | 135 --- .../lib/login-state.test.ts | 102 -- .../lib/netrc-parser.test.ts | 598 ------------ test/env-auth.test.ts | 60 ++ .../env-default-flags-plugin/commands/demo.ts | 15 + .../env-default-flags-plugin/package.json | 6 + test/flags/no-cache-default.test.ts | 48 + test/helpers/credential-manager-stub.ts | 3 +- test/helpers/unwrap.ts | 8 - test/login-state-coordinator.test.ts | 2 +- test/package-contract.test.ts | 160 ---- 67 files changed, 507 insertions(+), 8175 deletions(-) delete mode 100644 .github/workflows/ci-acceptance.yml delete mode 100644 scripts/verify-credential-manager-rollback.mjs delete mode 100644 scripts/verify-packed-consumer.mjs rename src/{credential-manager-core/lib/cli-command-telemetry.ts => credential-error-reporter.ts} (71%) delete mode 100644 src/credential-manager-core/credential-handlers/linux-handler.ts delete mode 100644 src/credential-manager-core/credential-handlers/macos-handler.ts delete mode 100644 src/credential-manager-core/credential-handlers/netrc-handler.ts delete mode 100644 src/credential-manager-core/credential-handlers/windows-handler.ts delete mode 100644 src/credential-manager-core/index.ts delete mode 100644 src/credential-manager-core/lib/credential-manager-adapter.ts delete mode 100644 src/credential-manager-core/lib/credential-storage-selector.ts delete mode 100644 src/credential-manager-core/lib/login-state.ts delete mode 100644 src/credential-manager-core/lib/netrc-parser.ts delete mode 100644 src/credential-manager-core/lib/types.ts delete mode 100644 src/deprecated/credential-manager-core/credential-handlers/linux-handler.ts delete mode 100644 src/deprecated/credential-manager-core/credential-handlers/macos-handler.ts delete mode 100644 src/deprecated/credential-manager-core/credential-handlers/netrc-handler.ts delete mode 100644 src/deprecated/credential-manager-core/credential-handlers/windows-handler.ts delete mode 100644 src/deprecated/credential-manager-core/index.ts delete mode 100644 src/deprecated/credential-manager-core/lib/cli-command-telemetry.ts delete mode 100644 src/deprecated/credential-manager-core/lib/credential-storage-selector.ts delete mode 100644 src/deprecated/credential-manager-core/lib/login-state.ts delete mode 100644 src/deprecated/credential-manager-core/lib/netrc-parser.ts delete mode 100644 src/deprecated/credential-manager-core/lib/types.ts create mode 100644 src/env-auth.ts delete mode 100644 src/file.ts rename test/{credential-manager/lib/cli-command-telemetry.test.ts => credential-error-reporter.test.ts} (87%) delete mode 100644 test/credential-manager/acceptance/index.acceptance.test.ts delete mode 100644 test/credential-manager/compatibility.test.ts delete mode 100644 test/credential-manager/credential-handlers/linux-handler.test.ts delete mode 100644 test/credential-manager/credential-handlers/macos-handler.test.ts delete mode 100644 test/credential-manager/credential-handlers/netrc-handler.test.ts delete mode 100644 test/credential-manager/credential-handlers/windows-handler.test.ts delete mode 100644 test/credential-manager/fixtures/source-declaration-compatibility.ts delete mode 100644 test/credential-manager/fixtures/v13.2.0-storage-baseline.json delete mode 100644 test/credential-manager/helpers/acceptance-utils.test.ts delete mode 100644 test/credential-manager/helpers/acceptance-utils.ts delete mode 100644 test/credential-manager/helpers/netrc-stub.ts delete mode 100644 test/credential-manager/index.test.ts delete mode 100644 test/credential-manager/lib/credential-storage-selector.test.ts delete mode 100644 test/credential-manager/lib/login-state.test.ts delete mode 100644 test/credential-manager/lib/netrc-parser.test.ts create mode 100644 test/env-auth.test.ts create mode 100644 test/fixtures/env-default-flags-plugin/commands/demo.ts create mode 100644 test/fixtures/env-default-flags-plugin/package.json create mode 100644 test/flags/no-cache-default.test.ts delete mode 100644 test/helpers/unwrap.ts delete mode 100644 test/package-contract.test.ts diff --git a/.github/release-configs/release-please-manifest.json b/.github/release-configs/release-please-manifest.json index ed8f2024..1f86328f 100644 --- a/.github/release-configs/release-please-manifest.json +++ b/.github/release-configs/release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "13.2.3" + ".": "13.2.4" } diff --git a/.github/workflows/ci-acceptance.yml b/.github/workflows/ci-acceptance.yml deleted file mode 100644 index 938e9f48..00000000 --- a/.github/workflows/ci-acceptance.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Node CI Suite - Acceptance - -on: - push: - branches: [v14.0.0, main, beta] - pull_request: - branches: [v14.0.0] - workflow_dispatch: - -permissions: - contents: read - -jobs: - acceptance: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - node-version: [22.x, 24.x] - env: - ACCEPTANCE_TESTS: 'true' - steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: ${{ matrix.node-version }} - package-manager-cache: false - - name: Install dependencies - run: npm ci - - name: Install libsecret tools for Linux - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libsecret-tools gnome-keyring - - name: Run acceptance tests on macOS and Windows - if: runner.os != 'Linux' - run: npm run test:ci:acceptance - - name: Run acceptance tests on Linux - if: runner.os == 'Linux' - run: | - # The command substitution intentionally expands in the child shell. - # shellcheck disable=SC2016 - dbus-run-session -- bash -c ' - eval "$(echo -n "heroku-credential-manager-ci" | gnome-keyring-daemon --unlock --components=secrets)" - npm run test:ci:acceptance - ' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79604b84..2a3f2d7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,22 +86,3 @@ jobs: run: npm ci - name: Run unit tests run: npm run test:coverage - verifiers: - runs-on: ubuntu-latest - steps: - - name: Check out source and baseline history - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - persist-credentials: false - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22.x - package-manager-cache: false - - name: Install dependencies - run: npm ci - - name: Run packed consumer verification - run: npm run verify:packed-consumer - - name: Run rollback verification - run: npm run verify:rollback diff --git a/.github/workflows/release-on-push.yml b/.github/workflows/release-on-push.yml index 3e01c2c6..bcb0bdcb 100644 --- a/.github/workflows/release-on-push.yml +++ b/.github/workflows/release-on-push.yml @@ -42,6 +42,7 @@ jobs: repositories: heroku-cli-command permission-contents: write permission-pull-requests: write + - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/package-lock.json b/package-lock.json index b2416bf3..e4010474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,18 @@ "version": "14.0.0-beta.2", "license": "ISC", "dependencies": { - "@heroku/heroku-credential-manager": "0.1.0", + "@heroku/heroku-credential-manager": "^0.2.1", + "@heroku/heroku-fetch": "^0.2.1", "@heroku/http-call": "5.5.2", "@heroku/js-blanket": "^1.0.0", - "@heroku/sdk": "^0.9.0", - "@heroku/types": "^5.4.0", + "@heroku/sdk": "^0.11.0", "@oclif/core": "^5.1.2", "@sentry/node": "^10.45.0", "ansis": "^4", "debug": "^4.4.0", - "execa": "^9.6.1", "inquirer": "^12.11.1", "open": "^11.0.0", - "tsheredoc": "^1.0.1" + "undici": "^8.11.2" }, "devDependencies": { "@heroku-cli/schema": "^1.0.25", @@ -29,22 +28,17 @@ "@types/chai": "^5.2.2", "@types/chai-as-promised": "^8.0.2", "@types/debug": "^4.1.12", - "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.6", - "@types/node": "^22", - "@types/proxyquire": "^1.3.31", + "@types/node": "26.6.4", "@types/sinon": "^21.0.0", - "@types/supports-color": "^5.3.0", "c8": "^12.0.0", "chai": "^6.2.2", "chai-as-promised": "^8.0.1", "eslint": "^10", "eslint-config-oclif": "^7", "fancy-test": "^3.0.16", - "fs-extra": "^11.3.0", "mocha": "^11", "nock": "^14.0.1", - "proxyquire": "^2.1.3", "rimraf": "^6.1.3", "sinon": "^21.0.1", "stdout-stderr": "^0.1.13", @@ -1097,9 +1091,9 @@ } }, "node_modules/@heroku/heroku-credential-manager": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@heroku/heroku-credential-manager/-/heroku-credential-manager-0.1.0.tgz", - "integrity": "sha512-jonzLCL5kt79oL3TZYxFsJ9d7eO6ZrGJmsjDkAjDb4CNFvC72DH5Ke6xZ+G9iN5HgQ36Y7Mji+V5h+QS0bEj/Q==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@heroku/heroku-credential-manager/-/heroku-credential-manager-0.2.1.tgz", + "integrity": "sha512-j1KHPfhiANMj5iLSUGeuYIhpCOqfPEpwYALffZc/wfFFlByjJAc9llJ3LcjM+vR0o+dh/+yix2Z+vs/pfz1SCw==", "license": "Apache-2.0", "dependencies": { "@heroku/js-blanket": "^1.0.0", @@ -1107,26 +1101,25 @@ "execa": "^9.6.1" }, "engines": { - "node": ">=20.5.0" + "node": ">=22" } }, "node_modules/@heroku/heroku-fetch": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@heroku/heroku-fetch/-/heroku-fetch-0.1.6.tgz", - "integrity": "sha512-gxwf5cr5gvy7HAu1bzJEkYyCYyl7IDFUsZHfpmAaru5L68J1QhXeE5rmhoHxSe0B2gSBNgRRl7Qh/nQFxiuPEQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@heroku/heroku-fetch/-/heroku-fetch-0.2.1.tgz", + "integrity": "sha512-UWEL4OVJfPhjW5wGCz6sV9o86O97DfLPW2P4KaB9EGIpPyZ5v7ahV+kgp4izmj5y2ZWPnP3oQN5aErqhe+NiGA==", "license": "Apache-2.0", "dependencies": { + "@heroku/js-blanket": "^1.2.0", "debug": "^4.3.4", - "ky": "^2.0.2", + "ky": "^2.2.0", "undici": "^8.11.2" }, "engines": { "node": ">=22" }, "optionalDependencies": { - "@heroku/heroku-cli-util": "^10.8.0", - "netrc-parser": "^3.1.6", - "open": "^11.0.0" + "@heroku/heroku-cli-util": "^10.8.0" } }, "node_modules/@heroku/http-call": { @@ -1147,24 +1140,21 @@ } }, "node_modules/@heroku/js-blanket": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@heroku/js-blanket/-/js-blanket-1.1.1.tgz", - "integrity": "sha512-1pmRbZnr/pBvLwStF6QqzbFQ+Ne8vWL4JYky76Yc5K/uYaJW2kmskaI7Ojl/TASDAabjmu7atY/H0t+dNUkRAA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@heroku/js-blanket/-/js-blanket-1.2.0.tgz", + "integrity": "sha512-jSKylwQg/c9sQmotMLrKrHewD0Cvg0G+KzhB8DCMsXwN0T9N0zE6xhrWYEjL1roARbmw5y+bEu3t99EjatvZCA==", "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.1" - }, "engines": { "node": ">=20.0.0" } }, "node_modules/@heroku/sdk": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@heroku/sdk/-/sdk-0.9.0.tgz", - "integrity": "sha512-QfE1T+rRdCpaxxzlc9L2MvntPegO1dBZtIrniRkm4jtFf5TEFQBZlzJSaORz1G+DjJah/9FBHDO74VCwBrZ0DA==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@heroku/sdk/-/sdk-0.11.0.tgz", + "integrity": "sha512-PdOaluMiYFZgAy0eXTgF52wxEOgFt9cxn5Nbf+SqH2Wy8GpErjS0NeF2Z8WRFGFFBxWm6rh/vHGwp8I5fkJcng==", "license": "Apache-2.0", "dependencies": { - "@heroku/heroku-fetch": "^0.1.4", + "@heroku/heroku-fetch": "^0.2.0", "@heroku/types": "^5.0.0", "debug": "^4.4.0" }, @@ -2537,17 +2527,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", - "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" - } - }, "node_modules/@types/hast": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", @@ -2572,16 +2551,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jsonfile": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", - "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/katex": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", @@ -2621,13 +2590,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", - "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/prop-types": { @@ -2637,13 +2606,6 @@ "license": "MIT", "optional": true }, - "node_modules/@types/proxyquire": { - "version": "1.3.31", - "resolved": "https://registry.npmjs.org/@types/proxyquire/-/proxyquire-1.3.31.tgz", - "integrity": "sha512-uALowNG2TSM1HNPMMOR0AJwv4aPYPhqB0xlEhkeRTMuto5hjoSPZkvgu1nbPUkz3gEPAHv4sy4DmKsurZiEfRQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", @@ -2672,13 +2634,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/supports-color": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@types/supports-color/-/supports-color-5.3.0.tgz", - "integrity": "sha512-WxwTXnHTIsk7srax1icjLgX+6w1MUAJbhyCpRP/45paEElsPDQUJZDgr1UpKuL2S3Tb+ZyX9MjWwmcSD4bUoOQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4314,16 +4269,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-html-parser": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/es-html-parser/-/es-html-parser-0.3.1.tgz", @@ -4952,16 +4897,6 @@ "eslint": ">=9.38.0" } }, - "node_modules/eslint-plugin-regexp/node_modules/@types/node": { - "version": "26.6.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", - "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.9.0" - } - }, "node_modules/eslint-plugin-regexp/node_modules/jsdoc-type-pratt-parser": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.2.tgz", @@ -4976,13 +4911,6 @@ "node": "^22.22.2 || >=24.15.0" } }, - "node_modules/eslint-plugin-regexp/node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, - "license": "MIT" - }, "node_modules/eslint-plugin-unicorn": { "version": "72.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-72.0.0.tgz", @@ -5359,20 +5287,6 @@ "flat-cache": "^6.1.23" } }, - "node_modules/fill-keys": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", - "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-object": "~1.0.1", - "merge-descriptors": "~1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5458,21 +5372,6 @@ "node": ">=0.4.x" } }, - "node_modules/fs-extra": { - "version": "11.4.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", - "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5488,16 +5387,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/function-timeout": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", @@ -5708,19 +5597,6 @@ "node": ">=20" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -6138,22 +6014,6 @@ "semver": "^7.7.1" } }, - "node_modules/is-core-module": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", - "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -6271,16 +6131,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-object": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", - "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -6502,19 +6352,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/just-extend": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", @@ -7006,16 +6843,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/micro-spelling-correcter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/micro-spelling-correcter/-/micro-spelling-correcter-1.1.1.tgz", @@ -7881,13 +7708,6 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, - "node_modules/module-not-found-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", - "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", - "dev": true, - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7904,9 +7724,9 @@ } }, "node_modules/nan": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", - "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.30.0.tgz", + "integrity": "sha512-P5mokDkSvCGBz6mZDD/2l+w9QoICRXdCyZ9ONGmTePr40oihJg3Kpn0YU3twKd6PJnwSlLBWNQeOT6yWEV2c4A==", "license": "MIT", "optional": true }, @@ -7943,169 +7763,6 @@ "node": ">=18" } }, - "node_modules/netrc-parser": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/netrc-parser/-/netrc-parser-3.1.6.tgz", - "integrity": "sha512-lY+fmkqSwntAAjfP63jB4z5p5WbuZwyMCD3pInT7dpHU/Gc6Vv90SAC6A0aNiqaRGHiuZFBtiwu+pu8W/Eyotw==", - "license": "ISC", - "optional": true, - "dependencies": { - "debug": "^3.1.0", - "execa": "^0.10.0" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/netrc-parser/node_modules/cross-spawn": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", - "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", - "license": "MIT", - "optional": true, - "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - }, - "engines": { - "node": ">=4.8" - } - }, - "node_modules/netrc-parser/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/netrc-parser/node_modules/execa": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.10.0.tgz", - "integrity": "sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==", - "license": "MIT", - "optional": true, - "dependencies": { - "cross-spawn": "^6.0.0", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/netrc-parser/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/netrc-parser/node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/netrc-parser/node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", - "license": "MIT", - "optional": true, - "dependencies": { - "path-key": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/netrc-parser/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/netrc-parser/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/netrc-parser/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "license": "MIT", - "optional": true, - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/netrc-parser/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/netrc-parser/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "optional": true - }, - "node_modules/netrc-parser/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "license": "MIT", - "optional": true - }, "node_modules/nise": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", @@ -8319,16 +7976,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8439,13 +8086,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -8603,18 +8243,6 @@ "node": ">= 8" } }, - "node_modules/proxyquire": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", - "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-keys": "^1.0.2", - "module-not-found-error": "^1.0.1", - "resolve": "^1.11.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8778,28 +8406,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -9339,16 +8945,6 @@ "node": ">=8" } }, - "node_modules/strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", @@ -9420,19 +9016,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -9579,6 +9162,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/tsheredoc/-/tsheredoc-1.0.1.tgz", "integrity": "sha512-aOyKWGdZSKEMFnzlccLu3wwB4VgpGhJ6bZON9uM+p8hgHSczfMvPdspxX+Dkr+q8/Dpgla1UwK6pFLmutUEUaw==", + "devOptional": true, "license": "MIT" }, "node_modules/tslib": { @@ -9709,18 +9293,18 @@ } }, "node_modules/undici": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", - "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "devOptional": true, "license": "MIT" }, @@ -9810,16 +9394,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", diff --git a/package.json b/package.json index ba3e2651..cb03892b 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,18 @@ "url": "https://github.com/heroku/heroku-cli-command/issues" }, "dependencies": { - "@heroku/heroku-credential-manager": "0.1.0", + "@heroku/heroku-credential-manager": "^0.2.1", + "@heroku/heroku-fetch": "^0.2.1", "@heroku/http-call": "5.5.2", "@heroku/js-blanket": "^1.0.0", - "@heroku/sdk": "^0.9.0", - "@heroku/types": "^5.4.0", + "@heroku/sdk": "^0.11.0", "@oclif/core": "^5.1.2", "@sentry/node": "^10.45.0", "ansis": "^4", "debug": "^4.4.0", - "execa": "^9.6.1", "inquirer": "^12.11.1", "open": "^11.0.0", - "tsheredoc": "^1.0.1" + "undici": "^8.11.2" }, "devDependencies": { "@heroku-cli/schema": "^1.0.25", @@ -27,22 +26,17 @@ "@types/chai": "^5.2.2", "@types/chai-as-promised": "^8.0.2", "@types/debug": "^4.1.12", - "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.6", - "@types/node": "^22", - "@types/proxyquire": "^1.3.31", + "@types/node": "26.6.4", "@types/sinon": "^21.0.0", - "@types/supports-color": "^5.3.0", "c8": "^12.0.0", "chai": "^6.2.2", "chai-as-promised": "^8.0.1", "eslint": "^10", "eslint-config-oclif": "^7", "fancy-test": "^3.0.16", - "fs-extra": "^11.3.0", "mocha": "^11", "nock": "^14.0.1", - "proxyquire": "^2.1.3", "rimraf": "^6.1.3", "sinon": "^21.0.1", "stdout-stderr": "^0.1.13", @@ -74,12 +68,6 @@ "test:watch": "mocha --watch \"test/**/*.test.ts\" --ignore \"test/**/acceptance/**/*.test.ts\"", "test:coverage": "c8 --all --check-coverage --reporter=text-summary --reporter=lcovonly mocha --forbid-only \"test/**/*.test.ts\" --ignore \"test/**/acceptance/**/*.test.ts\"", "test:file": "c8 mocha", - "test:ci:acceptance": "c8 mocha --forbid-only \"test/credential-manager/acceptance/index.acceptance.test.ts\"", - "verify:packed-consumer": "node scripts/verify-packed-consumer.mjs", - "verify:rollback": "node scripts/verify-credential-manager-rollback.mjs", - "verify:rollback:guards": "node scripts/verify-credential-manager-rollback.mjs --self-test-guards", - "verify:rollback:cleanup": "node scripts/verify-credential-manager-rollback.mjs --self-test-cleanup", - "verify:trusted": "npm run verify:packed-consumer && npm run verify:rollback:guards && npm run verify:rollback:cleanup && npm run verify:rollback", "prepare": "npm run build", "example": "sh examples/run.sh" }, diff --git a/scripts/verify-credential-manager-rollback.mjs b/scripts/verify-credential-manager-rollback.mjs deleted file mode 100644 index ddb65857..00000000 --- a/scripts/verify-credential-manager-rollback.mjs +++ /dev/null @@ -1,887 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict' -import {spawn} from 'node:child_process' -import {createHash} from 'node:crypto' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import {fileURLToPath, pathToFileURL} from 'node:url' -import {parseArgs} from 'node:util' -import {createPublicNpmEnvironment, publicNpmConfig, writePublicNpmConfig} from './public-npm-config.mjs' - -const scriptPath = fileURLToPath(import.meta.url) -const repositoryRoot = path.resolve(path.dirname(scriptPath), '..') -const rollbackPrefix = 'heroku-command-credential-rollback-' -const baselinePath = 'test/credential-manager/fixtures/v13.2.0-storage-baseline.json' -const archivedRoot = 'src/deprecated/credential-manager-core' -const externalPackage = '@heroku/heroku-credential-manager' -const baselinePackageLookup = "join(dir, '../../../package.json')" -const relocatedPackageLookup = "join(dir, '../../../../package.json')" -const expectedRepositoryReferences = new Set([ - 'git+https://github.com/heroku/heroku-cli-command.git', - 'https://github.com/heroku/heroku-cli-command', - 'github:heroku/heroku-cli-command', - 'heroku/heroku-cli-command', - 'https://github.com/heroku/heroku-cli-command.git', -]) -const signalExitCodes = {SIGHUP: 129, SIGINT: 130, SIGTERM: 143} -const supportedSignals = process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGHUP', 'SIGINT', 'SIGTERM'] -const {values: options} = parseArgs({ - options: { - 'internal-failure-marker': {type: 'string'}, - 'internal-hold-marker': {type: 'string'}, - 'self-test-cleanup': {type: 'boolean'}, - 'self-test-guards': {type: 'boolean'}, - }, - strict: true, -}) - -const copyEntries = [ - '.c8rc.json', - '.mocharc.json', - 'eslint.config.mjs', - 'scripts', - 'package-lock.json', - 'package.json', - 'src', - 'test', - 'tsconfig.json', -] - -const rollbackReplacements = new Map([ - ['src/credential-manager-core/index.ts', [ - ["from './lib/credential-manager-adapter.js'", "from '../deprecated/credential-manager-core/index.js'"], - ["from './lib/types.js'", "from '../deprecated/credential-manager-core/lib/types.js'"], - ["from '@heroku/heroku-credential-manager'", "from '../deprecated/credential-manager-core/index.js'"], - [' NativeCredentialNotFoundError,\n', ''], - ]], - ['src/credential-manager-core/lib/credential-manager-adapter.ts', [ - ["from '@heroku/heroku-credential-manager'", "from '../../deprecated/credential-manager-core/index.js'"], - ["from './cli-command-telemetry.js'", "from '../../deprecated/credential-manager-core/lib/cli-command-telemetry.js'"], - ]], - ['src/credential-manager-core/lib/credential-storage-selector.ts', [ - ["from '@heroku/heroku-credential-manager'", "from '../../deprecated/credential-manager-core/lib/credential-storage-selector.js'"], - ]], - ['src/credential-manager-core/lib/login-state.ts', [ - ["from '@heroku/heroku-credential-manager'", "from '../../deprecated/credential-manager-core/lib/login-state.js'"], - ]], - ['src/credential-manager-core/lib/netrc-parser.ts', [ - ["from '@heroku/heroku-credential-manager'", "from '../../deprecated/credential-manager-core/lib/netrc-parser.js'"], - ]], - ['src/credential-manager-core/lib/types.ts', [ - ["from '@heroku/heroku-credential-manager'", "from '../../deprecated/credential-manager-core/lib/types.js'"], - ]], - ['src/index.ts', [ - ["from './credential-manager-core/lib/credential-manager-adapter.js'", "from './deprecated/credential-manager-core/index.js'"], - ["from './credential-manager-core/lib/types.js'", "from './deprecated/credential-manager-core/lib/types.js'"], - ["from '@heroku/heroku-credential-manager'", "from './deprecated/credential-manager-core/index.js'"], - [' NativeCredentialNotFoundError,\n', ''], - ]], - ...['linux', 'macos', 'netrc', 'windows'].map(handler => [ - `src/credential-manager-core/credential-handlers/${handler}-handler.ts`, - [[ - `from '${externalPackage}'`, - `from '../../deprecated/credential-manager-core/credential-handlers/${handler}-handler.js'`, - ]], - ]), - ['src/credential-manager.ts', [ - ["from './credential-manager-core/lib/credential-manager-adapter.js'", "from './deprecated/credential-manager-core/index.js'"], - ]], -]) - -const focusedTests = [ - 'test/credential-manager/index.test.ts', - 'test/credential-manager/credential-handlers/linux-handler.test.ts', - 'test/credential-manager/credential-handlers/macos-handler.test.ts', - 'test/credential-manager/credential-handlers/netrc-handler.test.ts', - 'test/credential-manager/credential-handlers/windows-handler.test.ts', - 'test/credential-manager/lib/credential-storage-selector.test.ts', - 'test/credential-manager/lib/login-state.test.ts', -] - -let activeChild -let cleanupStarted = false -let dependencyIsolation = 'not installed' -let temporaryRoot -const confinedSecrets = [] - -function sha256(value) { - return createHash('sha256').update(value).digest('hex') -} - -function read(relativePath, root = repositoryRoot) { - return fs.readFileSync(path.join(root, relativePath), 'utf8') -} - -function listFiles(root, relativeRoot = '') { - return fs.readdirSync(path.join(root, relativeRoot), {withFileTypes: true}).flatMap(entry => { - const relativePath = path.join(relativeRoot, entry.name) - return entry.isDirectory() - ? listFiles(root, relativePath) - : [relativePath.split(path.sep).join('/')] - }) -} - -function cleanupTemporaryRoot() { - if (cleanupStarted) return - cleanupStarted = true - if (temporaryRoot) fs.rmSync(temporaryRoot, {force: true, recursive: true}) - temporaryRoot = undefined -} - -for (const signal of supportedSignals) { - process.on(signal, () => { - if (activeChild && !activeChild.killed) activeChild.kill(signal) - cleanupTemporaryRoot() - // Conventional signal status must be set synchronously after cleanup. - // eslint-disable-next-line n/no-process-exit - process.exit(signalExitCodes[signal]) - }) -} - -function redact(value) { - let redacted = value - for (const secret of confinedSecrets) { - if (secret.length >= 6) redacted = redacted.replaceAll(secret, '[redacted]') - } - - return redacted - .replaceAll(/((?:_authToken|_auth|password|token|api[_-]?key|session)\s*[=:]\s*)\S+/gi, '$1[redacted]') - .replaceAll(/(https?:\/\/)[^/@\s]+@/g, '$1[redacted]@') -} - -async function run(command, arguments_, runOptions = {}) { - const result = await new Promise((resolve, reject) => { - const child = spawn(command, arguments_, { - cwd: runOptions.cwd ?? repositoryRoot, - encoding: 'utf8', - env: runOptions.env, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }) - activeChild = child - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', chunk => { - stdout += chunk - }) - child.stderr.on('data', chunk => { - stderr += chunk - }) - child.on('error', reject) - child.on('close', (code, signal) => resolve({ - code, signal, stderr, stdout, - })) - }) - activeChild = undefined - - if (runOptions.printOutput) { - if (result.stdout) process.stdout.write(result.stdout) - if (result.stderr) process.stderr.write(result.stderr) - } - - if (result.code !== 0) { - const output = redact([result.stdout, result.stderr].filter(Boolean).join('\n').trim()) - throw new Error(`${command} ${arguments_.join(' ')} failed with ${result.signal ?? `exit ${result.code}`}${output ? `\n${output}` : ''}`) - } - - return runOptions.rawOutput ? result.stdout : result.stdout.trim() -} - -function fileKind(stat) { - if (stat.isFile()) return 'file' - if (stat.isDirectory()) return 'directory' - if (stat.isSymbolicLink()) return 'symlink' - if (stat.isBlockDevice()) return 'block-device' - if (stat.isCharacterDevice()) return 'character-device' - if (stat.isFIFO()) return 'fifo' - if (stat.isSocket()) return 'socket' - return 'unknown' -} - -async function captureRepositoryState() { - const rawOptions = {cwd: repositoryRoot, rawOutput: true} - const status = await run('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], rawOptions) - const diff = await run('git', ['diff', '--binary', '--no-ext-diff'], rawOptions) - const stagedDiff = await run('git', ['diff', '--binary', '--cached', '--no-ext-diff'], rawOptions) - const untracked = status.split('\0') - .filter(record => record.startsWith('?? ')) - .map(record => record.slice(3)) - .sort() - const untrackedMetadata = untracked.map(relativePath => { - const absolutePath = path.join(repositoryRoot, relativePath) - const stat = fs.lstatSync(absolutePath) - return { - content: stat.isFile() ? sha256(fs.readFileSync(absolutePath)) : undefined, - mode: stat.mode.toString(8), - path: relativePath, - size: stat.size, - target: stat.isSymbolicLink() ? fs.readlinkSync(absolutePath) : undefined, - type: fileKind(stat), - } - }) - - return sha256(JSON.stringify({ - diff, stagedDiff, status, untrackedMetadata, - })) -} - -function assertExternalSourceActive(root = repositoryRoot) { - const adapter = read('src/credential-manager-core/lib/credential-manager-adapter.ts', root) - const coreIndex = read('src/credential-manager-core/index.ts', root) - const commandIndex = read('src/index.ts', root) - - assert.match(adapter, /from '@heroku\/heroku-credential-manager'/, 'adapter must use the external credential-manager package') - assert.match(coreIndex, /from '@heroku\/heroku-credential-manager'/, 'core facade must use the external credential-manager package') - assert.match(commandIndex, /from '@heroku\/heroku-credential-manager'/, 'package facade must use the external credential-manager package') - for (const relativePath of listFiles(path.join(root, 'src')).filter(file => file.endsWith('.ts') && !file.startsWith('deprecated/'))) { - assert.doesNotMatch( - read(path.join('src', relativePath), root), - /(?:from|import\()\s*['"][^'"]*deprecated\//, - `real active source imports archived code: src/${relativePath}`, - ) - } -} - -function assertNegativePreconditions(root = repositoryRoot) { - const manifest = JSON.parse(read('package.json', root)) - const repository = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url - assert.equal(manifest.name, '@heroku-cli/command', 'run from the @heroku-cli/command checkout') - assert.equal(expectedRepositoryReferences.has(repository), true, 'run from the heroku/heroku-cli-command repository') - assertExternalSourceActive(root) - for (const [relativePath, replacements] of rollbackReplacements) { - const source = read(relativePath, root) - assert.doesNotMatch(source, /(?:from|import\()\s*['"][^'"]*deprecated\//, `${relativePath} is already rolled back`) - for (const [before] of replacements) { - assert.ok(source.includes(before), `${relativePath} must contain the rollback target: ${before}`) - } - } -} - -function verifyArchiveHashes(root) { - const baseline = JSON.parse(read(baselinePath, root)) - assert.equal(baseline.schemaVersion, 1) - assert.equal(baseline.packageVersion, '13.2.0') - assert.deepEqual(baseline.allowedRelocations, [{ - archived: relocatedPackageLookup, - baseline: baselinePackageLookup, - path: 'lib/cli-command-telemetry.ts', - }]) - - const archivedAbsoluteRoot = path.join(root, archivedRoot) - assert.deepEqual(listFiles(archivedAbsoluteRoot).sort(), baseline.files.map(file => file.path).sort()) - for (const file of baseline.files) { - const archivedSource = read(path.join(archivedRoot, file.path), root) - const normalizedSource = archivedSource.replace(relocatedPackageLookup, baselinePackageLookup) - assert.equal(Buffer.byteLength(normalizedSource), file.bytes, `${file.path} byte length`) - assert.equal(sha256(normalizedSource), file.sha256, `${file.path} SHA-256`) - if (file.path === 'lib/cli-command-telemetry.ts') { - assert.match(archivedSource, /join\(dir, '\.\.\/\.\.\/\.\.\/\.\.\/package\.json'\)/) - assert.doesNotMatch(archivedSource, /join\(dir, '\.\.\/\.\.\/\.\.\/package\.json'\)/) - } else { - assert.equal(archivedSource, normalizedSource, `${file.path} has an undocumented relocation`) - } - } - - return baseline.files.length -} - -function makeTemporaryCopy(root) { - for (const entry of copyEntries) { - const source = path.join(repositoryRoot, entry) - const destination = path.join(root, entry) - const stat = fs.lstatSync(source) - if (stat.isDirectory()) { - fs.mkdirSync(destination, {recursive: true}) - for (const relativePath of listFiles(source)) { - const target = path.join(destination, relativePath) - fs.mkdirSync(path.dirname(target), {recursive: true}) - fs.copyFileSync(path.join(source, relativePath), target, fs.constants.COPYFILE_EXCL) - } - } else { - fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL) - } - } - - for (const forbidden of ['.git', '.npmrc', '.env', 'coverage', 'lib', 'node_modules']) { - assert.equal(fs.existsSync(path.join(root, forbidden)), false, `${forbidden} must not be copied`) - } - - assert.equal(sha256(fs.readFileSync(path.join(root, 'package-lock.json'))), sha256(fs.readFileSync(path.join(repositoryRoot, 'package-lock.json')))) -} - -function applyRollback(root) { - for (const [relativePath, replacements] of rollbackReplacements) { - const absolutePath = path.join(root, relativePath) - let source = fs.readFileSync(absolutePath, 'utf8') - for (const [before, after] of replacements) { - assert.ok(source.includes(before), `${relativePath} rollback target drifted: ${before}`) - source = source.replaceAll(before, after) - } - - fs.writeFileSync(absolutePath, source) - } - - // The v14 adapter contains behavior and API additions that did not exist in v13.2.0. - // During rollback it remains as an active compatibility path but delegates wholly to the archive. - fs.writeFileSync(path.join(root, 'src/credential-manager-core/lib/credential-manager-adapter.ts'), `export { - getAuth, - listKeychainAccounts, - removeAuth, - saveAuth, -} from '../../deprecated/credential-manager-core/index.js' -`) - - for (const [relativePath] of rollbackReplacements) { - const source = read(relativePath, root) - assert.match(source, /deprecated\/credential-manager-core/, `${relativePath} was not repointed to the archive`) - assert.doesNotMatch(source, /from '@heroku\/heroku-credential-manager'/, `${relativePath} still imports the external package`) - } - - const archivedImporters = listFiles(path.join(root, 'src')) - .filter(relativePath => relativePath.endsWith('.ts') && !relativePath.startsWith('deprecated/')) - .filter(relativePath => /(?:from|import\()\s*['"][^'"]*deprecated\//.test(read(path.join('src', relativePath), root))) - .sort() - assert.deepEqual(archivedImporters, [...rollbackReplacements.keys()].map(relativePath => relativePath.slice(4)).sort()) -} - -function findExecutable(name) { - const suffixes = process.platform === 'win32' ? ['', '.cmd', '.exe'] : [''] - for (const directory of (process.env.PATH ?? '').split(path.delimiter)) { - for (const suffix of suffixes) { - const candidate = path.join(directory, `${name}${suffix}`) - try { - if (fs.statSync(candidate).isFile()) return candidate - } catch {} - } - } - - throw new Error(`${name} was not found on PATH`) -} - -function npmInvocation() { - if (process.env.npm_execpath?.endsWith('.js') && fs.existsSync(process.env.npm_execpath)) { - return {arguments_: [process.env.npm_execpath], command: process.execPath, directory: path.dirname(process.env.npm_execpath)} - } - - const npmExecutable = findExecutable('npm') - const realNpm = fs.realpathSync(npmExecutable) - if (realNpm.endsWith('.js')) { - return {arguments_: [realNpm], command: process.execPath, directory: path.dirname(npmExecutable)} - } - - const npmCli = path.resolve(path.dirname(npmExecutable), '../lib/node_modules/npm/bin/npm-cli.js') - assert.equal(fs.existsSync(npmCli), true, `npm CLI was not found for ${npmExecutable}`) - return {arguments_: [npmCli], command: process.execPath, directory: path.dirname(npmExecutable)} -} - -async function createConfinedNpmConfig(target) { - await writePublicNpmConfig(target) - // eslint-disable-next-line no-bitwise - assert.equal(fs.statSync(target).mode & 0o777, 0o600) - assert.doesNotMatch(fs.readFileSync(target, 'utf8'), /_authToken|NODE_AUTH_TOKEN/) -} - -async function createHermeticEnvironment(root, npm) { - const home = path.join(root, '.rollback-home') - const dataDir = path.join(home, '.local', 'share', 'heroku') - const bin = path.join(root, '.rollback-bin') - const temp = path.join(root, '.tmp') - const userConfig = path.join(root, '.rollback-npmrc') - for (const directory of [bin, dataDir, home, temp]) fs.mkdirSync(directory, {mode: 0o700, recursive: true}) - await createConfinedNpmConfig(userConfig) - - const unixStub = '#!/bin/sh\necho "native credential stores and GPG are disabled in the rollback drill" >&2\nexit 97\n' - const windowsStub = '@echo off\r\necho native credential stores and GPG are disabled in the rollback drill 1>&2\r\nexit /b 97\r\n' - if (process.platform === 'win32') { - for (const command of ['gpg.cmd', 'powershell.cmd', 'secret-tool.cmd', 'security.cmd']) { - fs.writeFileSync(path.join(bin, command), windowsStub, {mode: 0o700}) - } - } else { - for (const command of ['gpg', 'powershell', 'powershell.exe', 'secret-tool', 'security']) { - fs.writeFileSync(path.join(bin, command), unixStub, {mode: 0o700}) - } - } - - const executableDirectories = new Set([bin, npm.directory, path.dirname(process.execPath)]) - if (process.platform === 'win32') { - for (const name of ['SystemRoot', 'WINDIR']) { - if (process.env[name]) executableDirectories.add(path.join(process.env[name], 'System32')) - } - } else { - executableDirectories.add('/usr/bin') - executableDirectories.add('/bin') - } - - const allowedEnvironment = { - ACCEPTANCE_TESTS: 'true', - CI: 'true', - DISABLE_TELEMETRY: 'true', - GNUPGHOME: path.join(home, '.gnupg-disabled'), - HEROKU_DATA_DIR: dataDir, - HEROKU_NETRC_WRITE: 'true', - HOME: home, - NODE_ENV: 'test', - PATH: [...executableDirectories].join(path.delimiter), - TEMP: temp, - TMP: temp, - TMPDIR: temp, - USERPROFILE: home, - XDG_CONFIG_HOME: path.join(home, '.config'), - XDG_DATA_HOME: path.join(home, '.local', 'share'), - } - for (const name of ['COMSPEC', 'LANG', 'LC_ALL', 'PATHEXT', 'SystemRoot', 'WINDIR']) { - if (process.env[name]) allowedEnvironment[name] = process.env[name] - } - - const npmPaths = { - cachePath: path.join(root, '.npm-cache'), - configPath: userConfig, - globalConfigPath: path.join(root, '.rollback-global-npmrc'), - logsPath: path.join(root, '.npm-logs'), - } - for (const [name, value] of Object.entries(process.env)) { - if (/(?:_authToken|_auth|token|password|username)/i.test(name) && value && value.length >= 6) confinedSecrets.push(value) - } - fs.mkdirSync(npmPaths.cachePath, {mode: 0o700, recursive: true}) - fs.mkdirSync(npmPaths.logsPath, {mode: 0o700, recursive: true}) - await writePublicNpmConfig(npmPaths.globalConfigPath) - const environment = createPublicNpmEnvironment(allowedEnvironment, npmPaths) - Object.assign(environment, { - npm_config_audit: 'false', - NPM_CONFIG_AUDIT: 'false', - npm_config_fund: 'false', - NPM_CONFIG_FUND: 'false', - npm_config_update_notifier: 'false', - NPM_CONFIG_UPDATE_NOTIFIER: 'false', - }) - - assert.deepEqual( - Object.keys(environment).filter(name => /(?:AUTH|KEY|PROXY|SESSION|TOKEN)/i.test(name) && !['NODE_AUTH_TOKEN', 'NPM_TOKEN'].includes(name)), - [], - 'allowlisted environment contains a credential/proxy variable', - ) - assert.equal(environment.NODE_AUTH_TOKEN, '') - assert.equal(environment.NPM_TOKEN, '') - assert.equal(environment.NPM_CONFIG_USERCONFIG, userConfig) - assert.equal(environment.NPM_CONFIG_GLOBALCONFIG, npmPaths.globalConfigPath) - assert.equal(environment.NPM_CONFIG_REGISTRY, 'https://registry.npmjs.org/') - assert.equal(fs.readFileSync(userConfig, 'utf8'), publicNpmConfig) - assert.equal(fs.readFileSync(npmPaths.globalConfigPath, 'utf8'), publicNpmConfig) - if (process.platform !== 'win32') { - // eslint-disable-next-line no-bitwise - for (const directory of [root, npmPaths.cachePath, npmPaths.logsPath]) assert.equal(fs.statSync(directory).mode & 0o777, 0o700) - // eslint-disable-next-line no-bitwise - for (const configPath of [userConfig, npmPaths.globalConfigPath]) assert.equal(fs.statSync(configPath).mode & 0o777, 0o600) - } - return environment -} - -async function installDependencies(root, environment, npm) { - await run(npm.command, [...npm.arguments_, 'ci', '--ignore-scripts', '--no-audit', '--no-fund'], {cwd: root, env: environment}) - dependencyIsolation = 'anonymous npm ci --ignore-scripts from copied package-lock' - await run(npm.command, [...npm.arguments_, 'ls', '--all', '--json'], {cwd: root, env: environment}) - - const sourceLock = JSON.parse(read('package-lock.json', root)) - const installedLock = JSON.parse(read('node_modules/.package-lock.json', root)) - assert.equal(installedLock.lockfileVersion, sourceLock.lockfileVersion) - for (const [packagePath, metadata] of Object.entries(installedLock.packages)) { - const expected = sourceLock.packages[packagePath] - assert.ok(expected, `installed package is absent from copied lockfile: ${packagePath}`) - for (const field of ['integrity', 'resolved', 'version']) { - if (metadata[field] !== undefined) assert.equal(metadata[field], expected[field], `${packagePath} ${field}`) - } - } - - const manifest = JSON.parse(read('package.json', root)) - for (const name of Object.keys({...manifest.dependencies, ...manifest.devDependencies})) { - assert.ok(sourceLock.packages[`node_modules/${name}`]?.version, `${name} has no copied lockfile entry`) - assert.equal(fs.existsSync(path.join(root, 'node_modules', name, 'package.json')), true, `${name} was not installed`) - } -} - -async function selfTestAnonymousNpmFailure() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), `${rollbackPrefix}anonymous-npm-test-`)) - try { - const npm = npmInvocation() - const environment = await createHermeticEnvironment(root, npm) - const homeNpmrc = path.join(environment.HOME, '.npmrc') - fs.writeFileSync(homeNpmrc, '//registry.npmjs.org/:_authToken=must-not-be-read\n') - assert.equal(await run(npm.command, [...npm.arguments_, 'config', 'get', 'userconfig'], {cwd: root, env: environment}), environment.NPM_CONFIG_USERCONFIG) - assert.equal(await run(npm.command, [...npm.arguments_, 'config', 'get', 'registry'], {cwd: root, env: environment}), 'https://registry.npmjs.org/') - - const probeFile = path.join(root, '.tmp', 'npm-probe.json') - const fakeNpm = path.join(root, '.tmp', 'fail-npm.mjs') - fs.writeFileSync(fakeNpm, [ - "import {writeFileSync} from 'node:fs'", - "writeFileSync(process.env.ROLLBACK_NPM_PROBE_FILE, JSON.stringify({args: process.argv.slice(2), config: process.env.NPM_CONFIG_USERCONFIG, nodeAuthToken: process.env.NODE_AUTH_TOKEN, npmToken: process.env.NPM_TOKEN}))", - 'process.exit(42)', - '', - ].join('\n')) - environment.ROLLBACK_NPM_PROBE_FILE = probeFile - await assert.rejects( - installDependencies(root, environment, {arguments_: [fakeNpm], command: process.execPath}), - /failed with exit 42/, - ) - const probe = JSON.parse(fs.readFileSync(probeFile, 'utf8')) - assert.deepEqual(probe.args, ['ci', '--ignore-scripts', '--no-audit', '--no-fund']) - assert.equal(probe.config, environment.NPM_CONFIG_USERCONFIG) - assert.equal(probe.nodeAuthToken, '') - assert.equal(probe.npmToken, '') - assert.equal(fs.existsSync(path.join(root, 'node_modules')), false, 'failed anonymous install must not fall back to another dependency tree') - } finally { - fs.rmSync(root, {force: true, recursive: true}) - } -} - -async function buildRollbackCopy(root, environment) { - const outputRoot = path.join(root, 'lib') - await run(process.execPath, [ - path.join(root, 'node_modules/typescript/bin/tsc'), - '-p', - path.join(root, 'tsconfig.json'), - '--outDir', - outputRoot, - ], {cwd: root, env: environment}) - for (const relativePath of rollbackReplacements.keys()) { - const emittedBase = relativePath.replace(/^src\//, 'lib/').replace(/\.ts$/, '') - assert.equal(fs.existsSync(path.join(root, `${emittedBase}.js`)), true, `${emittedBase}.js was not emitted`) - assert.equal(fs.existsSync(path.join(root, `${emittedBase}.d.ts`)), true, `${emittedBase}.d.ts was not emitted`) - } - - assert.equal(fs.existsSync(path.join(outputRoot, 'deprecated/credential-manager-core/index.js')), true) -} - -function writeGeneratedSmoke(root) { - const generatedRoot = path.join(root, '.rollback-generated') - fs.mkdirSync(generatedRoot, {mode: 0o700, recursive: true}) - fs.writeFileSync(path.join(generatedRoot, 'runtime-smoke.mjs'), `import assert from 'node:assert/strict' -import fs from 'node:fs' -import path from 'node:path' - -import * as facade from '../lib/credential-manager.js' -import * as core from '../lib/credential-manager-core/index.js' -import * as adapter from '../lib/credential-manager-core/lib/credential-manager-adapter.js' -import * as selector from '../lib/credential-manager-core/lib/credential-storage-selector.js' -import * as loginState from '../lib/credential-manager-core/lib/login-state.js' -import netrcDefault, * as netrcParser from '../lib/credential-manager-core/lib/netrc-parser.js' -import * as typesModule from '../lib/credential-manager-core/lib/types.js' -import {LinuxHandler} from '../lib/credential-manager-core/credential-handlers/linux-handler.js' -import {MacOSHandler} from '../lib/credential-manager-core/credential-handlers/macos-handler.js' -import {NetrcHandler} from '../lib/credential-manager-core/credential-handlers/netrc-handler.js' -import {WindowsHandler} from '../lib/credential-manager-core/credential-handlers/windows-handler.js' -import * as packageRoot from '../lib/index.js' -import * as archive from '../lib/deprecated/credential-manager-core/index.js' -import * as archiveSelector from '../lib/deprecated/credential-manager-core/lib/credential-storage-selector.js' -import * as archiveLoginState from '../lib/deprecated/credential-manager-core/lib/login-state.js' -import archiveNetrcDefault, * as archiveNetrcParser from '../lib/deprecated/credential-manager-core/lib/netrc-parser.js' - -assert.deepEqual(Object.keys(typesModule), []) -for (const name of ['getAuth', 'listKeychainAccounts', 'removeAuth', 'saveAuth']) { - assert.equal(core[name], archive[name], \`core identity: \${name}\`) - assert.equal(adapter[name], archive[name], \`adapter identity: \${name}\`) - assert.equal(packageRoot[name], archive[name], \`package identity: \${name}\`) -} -assert.equal(selector.CredentialStore, archiveSelector.CredentialStore) -assert.equal(selector.getNativeCredentialStore, archiveSelector.getNativeCredentialStore) -assert.equal(selector.getStorageConfig, archiveSelector.getStorageConfig) -assert.equal(loginState.readLoginState, archiveLoginState.readLoginState) -assert.equal(loginState.writeLoginState, archiveLoginState.writeLoginState) -assert.equal(loginState.deleteLoginState, archiveLoginState.deleteLoginState) -assert.equal(netrcParser.Netrc, archiveNetrcParser.Netrc) -assert.equal(netrcParser.parse, archiveNetrcParser.parse) -assert.equal(netrcDefault.constructor, archiveNetrcDefault.constructor) -assert.equal(LinuxHandler, archive.LinuxHandler) -assert.equal(MacOSHandler, archive.MacOSHandler) -assert.equal(NetrcHandler, archive.NetrcHandler) -assert.equal(WindowsHandler, archive.WindowsHandler) -assert.deepEqual(selector.getStorageConfig(), {credentialStore: null, useNetrc: true}) - -const host = 'rollback-active.test.heroku.com' -const account = 'rollback-active@example.com' -const token = 'rollback-active-token' -await facade.saveAuth(account, token, [host], 'rollback-active-service') -assert.deepEqual(await packageRoot.getAuth(account, host), {account, token}) -assert.deepEqual(await core.getAuth(account, host), {account, token}) -assert.deepEqual(await adapter.getAuth(account, host), {account, token}) -assert.deepEqual(await packageRoot.listKeychainAccounts(), []) -await facade.removeAuth(account, [host]) -await assert.rejects(core.getAuth(account, host), /No auth found/) - -const explicitNetrc = path.join(process.env.HOME, 'handler.netrc') -const handler = new NetrcHandler(explicitNetrc) -await handler.saveAuthForHosts({login: account, password: token}, [host]) -assert.deepEqual(await handler.getAuth(host), {login: account, password: token}) -await handler.removeAuthForHosts([host]) -await assert.rejects(handler.getAuth(host), /No auth found/) - -const parsed = netrcParser.parse(\`machine \${host} login \${account} password \${token}\`) -assert.equal(parsed[host].password, token) -const explicitParserNetrc = new netrcParser.Netrc(path.join(process.env.HOME, 'parser.netrc')) -explicitParserNetrc.machines = parsed -await explicitParserNetrc.save() -await explicitParserNetrc.load() -assert.equal(explicitParserNetrc.machines[host].login, account) - -const dataDir = process.env.HEROKU_DATA_DIR -await loginState.writeLoginState(dataDir, account) -assert.deepEqual(await loginState.readLoginState(dataDir), {account}) -await loginState.deleteLoginState(dataDir) -assert.equal(fs.existsSync(path.join(dataDir, 'login.json')), false) - -console.log('active runtime smoke passed: 12 transformed paths, facade operations, handlers, selector, login-state, and netrc') -`) - - const declarationImports = [...rollbackReplacements.keys()] - .map(relativePath => `import * as p${Math.random().toString(36).slice(2)} from '../${relativePath.replace(/^src\//, 'lib/').replace(/\.ts$/, '.js')}'`) - .join('\n') - fs.writeFileSync(path.join(generatedRoot, 'declaration-smoke.ts'), `${declarationImports} -import type {AuthEntry as CoreAuthEntry} from '../lib/credential-manager-core/lib/types.js' -import type {AuthEntry as RootAuthEntry} from '../lib/index.js' - -const coreEntry: CoreAuthEntry = {account: undefined, token: undefined} -const rootEntry: RootAuthEntry = coreEntry -void rootEntry -`) -} - -async function runGeneratedSmoke(root, environment) { - writeGeneratedSmoke(root) - await run(process.execPath, [path.join(root, '.rollback-generated/runtime-smoke.mjs')], { - cwd: root, - env: environment, - printOutput: true, - }) - await run(process.execPath, [ - path.join(root, 'node_modules/typescript/bin/tsc'), - // TypeScript 6 errors (TS5112) when files are passed on the command line - // while a tsconfig.json is present; this generated smoke file is compiled - // in isolation with explicit flags, so ignore the repo config. - '--ignoreConfig', - '--noEmit', - '--strict', - '--skipLibCheck', - '--target', - 'es2022', - '--module', - 'NodeNext', - '--moduleResolution', - 'NodeNext', - path.join(root, '.rollback-generated/declaration-smoke.ts'), - ], {cwd: root, env: environment}) -} - -async function proveTelemetryPackageLookup(root) { - const telemetryPath = path.join(root, 'lib/deprecated/credential-manager-core/lib/cli-command-telemetry.js') - assert.match(fs.readFileSync(telemetryPath, 'utf8'), /join\(dir, '\.\.\/\.\.\/\.\.\/\.\.\/package\.json'\)/) - const telemetry = await import(`${pathToFileURL(telemetryPath).href}?rollback-drill=${Date.now()}`) - let initialization - telemetry.credentialSentrySdk.getClient = function () {} - telemetry.credentialSentrySdk.init = settings => { - initialization = settings - return {async close() {}} - } - - telemetry.credentialSentrySdk.captureException = function () {} - telemetry.credentialSentrySdk.flush = async () => true - - const saved = {CI: process.env.CI, DISABLE_TELEMETRY: process.env.DISABLE_TELEMETRY, NODE_ENV: process.env.NODE_ENV} - try { - delete process.env.CI - delete process.env.DISABLE_TELEMETRY - process.env.NODE_ENV = 'rollback-drill' - await telemetry.reportCredentialStoreError(new Error('rollback telemetry lookup probe'), { - credentialStore: 'macos-keychain', - operation: 'getAuth', - }) - } finally { - for (const [name, value] of Object.entries(saved)) { - if (value === undefined) delete process.env[name] - else process.env[name] = value - } - } - - const packageMetadata = JSON.parse(read('package.json', root)) - assert.equal(initialization?.release, `${packageMetadata.name}@${packageMetadata.version}`) -} - -async function runSupplementalTests(root, environment) { - const mocha = path.join(root, 'node_modules/mocha/bin/mocha.js') - await run(process.execPath, [mocha, '--forbid-only', ...focusedTests], { - cwd: root, - env: environment, - printOutput: true, - }) - await run(process.execPath, [ - mocha, - '--forbid-only', - '--grep', - String.raw`^credential-manager acceptance \.netrc only`, - 'test/credential-manager/acceptance/index.acceptance.test.ts', - ], {cwd: root, env: environment, printOutput: true}) -} - -async function selfTestGuards() { - const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${rollbackPrefix}guard-test-`)) - try { - makeTemporaryCopy(probeRoot) - assertNegativePreconditions(probeRoot) - const manifestPath = path.join(probeRoot, 'package.json') - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) - fs.writeFileSync(manifestPath, JSON.stringify({...manifest, repository: 'github:heroku/heroku-cli-command'})) - assertNegativePreconditions(probeRoot) - fs.writeFileSync(manifestPath, JSON.stringify({...manifest, repository: 'https://evil.example/github.com/heroku/heroku-cli-command.git'})) - assert.throws(() => assertNegativePreconditions(probeRoot), /heroku\/heroku-cli-command repository/) - fs.writeFileSync(manifestPath, JSON.stringify(manifest)) - const targetPath = [...rollbackReplacements.keys()][0] - const [rollbackTarget] = rollbackReplacements.get(targetPath)[0] - fs.writeFileSync(path.join(probeRoot, targetPath), read(targetPath, probeRoot).replace(rollbackTarget, 'rollback-target-drifted')) - assert.throws(() => applyRollback(probeRoot), /rollback target drifted/) - - fs.rmSync(probeRoot, {force: true, recursive: true}) - fs.mkdirSync(probeRoot) - makeTemporaryCopy(probeRoot) - fs.appendFileSync(path.join(probeRoot, archivedRoot, 'index.ts'), '\n// guard self-test mutation\n') - assert.throws(() => verifyArchiveHashes(probeRoot), /byte length|SHA-256/) - } finally { - fs.rmSync(probeRoot, {force: true, recursive: true}) - } - - await selfTestAnonymousNpmFailure() - - console.log('Credential-manager rollback guard self-test passed (anonymous npm config, auth absence, failed-install fail-closed behavior, rollback wiring, and archive integrity).') -} - -async function waitForMarker(marker) { - const deadline = Date.now() + 15_000 - /* eslint-disable no-await-in-loop */ - while (Date.now() < deadline) { - if (fs.existsSync(marker)) return fs.readFileSync(marker, 'utf8') - await new Promise(resolve => { - setTimeout(resolve, 25) - }) - } - /* eslint-enable no-await-in-loop */ - - throw new Error(`timed out waiting for cleanup self-test marker: ${marker}`) -} - -function startSelfTest(arguments_) { - let child - const completion = new Promise((resolve, reject) => { - child = spawn(process.execPath, [scriptPath, ...arguments_], { - cwd: repositoryRoot, - env: {PATH: process.env.PATH}, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', chunk => { - stderr += chunk - }) - child.on('error', reject) - child.on('close', (code, signal) => resolve({code, signal, stderr})) - }) - return {child, completion} -} - -async function selfTestCleanup() { - const selfTestRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${rollbackPrefix}cleanup-test-`)) - try { - /* eslint-disable no-await-in-loop */ - for (const signal of supportedSignals) { - const marker = path.join(selfTestRoot, `${signal}.marker`) - const {child, completion} = startSelfTest([`--internal-hold-marker=${marker}`]) - const childRoot = await waitForMarker(marker) - child.kill(signal) - const result = await completion - assert.equal(result.code, signalExitCodes[signal], `${signal} conventional exit code: ${result.stderr}`) - assert.equal(result.signal, null, `${signal} should be handled before exit`) - assert.equal(fs.existsSync(childRoot), false, `${signal} left temporary rollback copy behind`) - } - /* eslint-enable no-await-in-loop */ - - const failureMarker = path.join(selfTestRoot, 'failure.marker') - const {completion} = startSelfTest([`--internal-failure-marker=${failureMarker}`]) - const failureRoot = await waitForMarker(failureMarker) - const failure = await completion - assert.notEqual(failure.code, 0) - assert.equal(fs.existsSync(failureRoot), false, 'failure path left temporary rollback copy behind') - } finally { - fs.rmSync(selfTestRoot, {force: true, recursive: true}) - } - - const matrixNote = process.platform === 'win32' - ? ' SIGHUP requires the remote POSIX matrix.' - : ' SIGHUP, SIGINT, and SIGTERM were verified locally.' - console.log(`Credential-manager rollback cleanup self-test passed (failure and signals).${matrixNote}`) -} - -async function internalCleanupProbe(marker, fail) { - temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${rollbackPrefix}probe-`)) - fs.writeFileSync(path.join(temporaryRoot, 'probe'), 'cleanup probe') - fs.writeFileSync(marker, temporaryRoot) - if (fail) throw new Error('intentional cleanup self-test failure') - await new Promise(() => { - setInterval(() => {}, 1000) - }) -} - -async function runDrill() { - assertNegativePreconditions() - const repositoryStateBefore = await captureRepositoryState() - const archiveFileCount = verifyArchiveHashes(repositoryRoot) - temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), rollbackPrefix)) - console.log('Rollback drill: preparing isolated temporary copy...') - makeTemporaryCopy(temporaryRoot) - applyRollback(temporaryRoot) - assert.equal(verifyArchiveHashes(temporaryRoot), archiveFileCount) - - const npm = npmInvocation() - const environment = await createHermeticEnvironment(temporaryRoot, npm) - console.log('Rollback drill: installing copied lockfile with npm ci --ignore-scripts...') - await installDependencies(temporaryRoot, environment, npm) - console.log('Rollback drill: building and running active-path smoke...') - await buildRollbackCopy(temporaryRoot, environment) - await runGeneratedSmoke(temporaryRoot, environment) - await proveTelemetryPackageLookup(temporaryRoot) - console.log('Rollback drill: running supplemental storage and acceptance tests...') - await runSupplementalTests(temporaryRoot, environment) - - assertExternalSourceActive() - assert.equal(await captureRepositoryState(), repositoryStateBefore, 'tracked/staged/untracked Git state changed during the drill') - cleanupTemporaryRoot() - - console.log('\nCredential-manager rollback drill passed.') - console.log(`- Active rollback proof: ${rollbackReplacements.size} transformed active runtime/declaration paths loaded; identities and storage operations passed.`) - console.log(`- Archived source: ${archiveFileCount} v13.2.0 files matched SHA-256/byte baselines; telemetry lookup was the only relocation.`) - console.log(`- Dependencies: copied package-lock matched byte-for-byte; ${dependencyIsolation}; installed lock entries match it.`) - console.log(`- Supplemental tests: ${focusedTests.length} storage files plus the hermetic netrc acceptance suite passed.`) - const isolationSummary = '- Isolation: only an allowlisted environment was passed; credential/session/proxy variables were absent; ' - + 'HOME, USERPROFILE, XDG, TMP, PATH, npm config/cache, native stores, and GPG were isolated.' - console.log(isolationSummary) - console.log('- Cleanup: the temporary copy was removed; failure/signal behavior is covered by --self-test-cleanup.') - console.log('- Platform-dependent native credential-store and GPG success paths were not invoked; Windows behavior still requires its remote matrix.') - console.log('- Real tree: external-package source remained active; tracked, staged, and untracked Git state fingerprint was unchanged. Ignored files were not fingerprinted.') -} - -async function main() { - if (options['internal-hold-marker']) return internalCleanupProbe(options['internal-hold-marker'], false) - if (options['internal-failure-marker']) return internalCleanupProbe(options['internal-failure-marker'], true) - if (options['self-test-guards']) return selfTestGuards() - if (options['self-test-cleanup']) return selfTestCleanup() - return runDrill() -} - -try { - await main() -} catch (error) { - console.error(redact(error instanceof Error ? error.stack ?? error.message : String(error))) - process.exitCode = 1 -} finally { - cleanupTemporaryRoot() -} diff --git a/scripts/verify-packed-consumer.mjs b/scripts/verify-packed-consumer.mjs deleted file mode 100644 index 00ae7113..00000000 --- a/scripts/verify-packed-consumer.mjs +++ /dev/null @@ -1,820 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict' -import {execFile} from 'node:child_process' -import {randomUUID} from 'node:crypto' -import {createRequire} from 'node:module' -import { - chmod, - cp, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - stat, - writeFile, -} from 'node:fs/promises' -import {createServer} from 'node:http' -import {tmpdir} from 'node:os' -import {dirname, join, relative, resolve, sep} from 'node:path' -import {fileURLToPath, pathToFileURL} from 'node:url' -import {promisify} from 'node:util' -import {parseArgs} from 'node:util' -import {createPublicNpmEnvironment, publicNpmConfig, writePublicNpmConfig} from './public-npm-config.mjs' - -const require = createRequire(import.meta.url) -const ts = require('typescript') -const execFileAsync = promisify(execFile) -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const scriptPath = fileURLToPath(import.meta.url) -const npmCliPath = process.env.PACKED_VERIFY_NPM_CLI -const workspacePrefix = 'heroku-command-packed-' -const {values: options} = parseArgs({ - options: {'baseline-sha': {type: 'string'}}, - strict: true, -}) -const authoritativeBaselinePath = join(repositoryRoot, '.github', 'packed-consumer-baseline') -const authoritativeBaselineSha = (await readFile(authoritativeBaselinePath, 'utf8')).trim() -const requestedBaselineSha = options['baseline-sha'] ?? process.env.PACKED_CONSUMER_BASELINE_SHA -const baselineSha = authoritativeBaselineSha -const expectedDependencies = new Map([ - ['@heroku/heroku-credential-manager', { - integrity: 'sha512-jonzLCL5kt79oL3TZYxFsJ9d7eO6ZrGJmsjDkAjDb4CNFvC72DH5Ke6xZ+G9iN5HgQ36Y7Mji+V5h+QS0bEj/Q==', - tarball: 'https://registry.npmjs.org/@heroku/heroku-credential-manager/-/heroku-credential-manager-0.1.0.tgz', - version: '0.1.0', - }], - ['@heroku/http-call', { - integrity: 'sha512-FOOV9oK+kvcWu1KgBSdPBDS1b7dPeQORi/6IEC3Q7uNDhU7zhNR0fh5+p5p1BFMG/CmH2XNwbOeAL6YqmCrX1Q==', - tarball: 'https://registry.npmjs.org/@heroku/http-call/-/http-call-5.5.2.tgz', - version: '5.5.2', - }], -]) -// Dependency ranges allowed to differ from the authoritative baseline. The -// baseline pins @heroku/http-call exactly (asserted below); @oclif/core is an -// intentional major upgrade (v4 -> v5) performed in the toolchain modernization. -const intentionalDependencyChanges = new Set(['@heroku/http-call', '@oclif/core']) -const expectedCompatibilityPaths = new Set([ - 'lib/credential-manager-core/credential-handlers/linux-handler.js', - 'lib/credential-manager-core/credential-handlers/macos-handler.js', - 'lib/credential-manager-core/credential-handlers/netrc-handler.js', - 'lib/credential-manager-core/credential-handlers/windows-handler.js', - 'lib/credential-manager-core/index.js', - 'lib/credential-manager-core/lib/cli-command-telemetry.js', - 'lib/credential-manager-core/lib/credential-storage-selector.js', - 'lib/credential-manager-core/lib/login-state.js', - 'lib/credential-manager-core/lib/netrc-parser.js', - 'lib/credential-manager-core/lib/types.js', -]) -const intentionalAdditions = new Set([ - 'lib/completion-types.d.ts', - 'lib/completion-types.js', - 'lib/credential-manager-core/lib/credential-manager-adapter.d.ts', - 'lib/credential-manager-core/lib/credential-manager-adapter.js', - 'lib/credential-manager-login-adapters.d.ts', - 'lib/credential-manager-login-adapters.js', - 'lib/login-state-coordinator.d.ts', - 'lib/login-state-coordinator.js', -]) -const intentionalPackageAdditions = new Set([ - '@heroku/heroku-credential-manager', - // Completion value sources call the platform API: @heroku/sdk for the account/app - // list completions, @heroku/heroku-fetch + @heroku/types for the direct /stacks - // fetch that works around the SDK's app-scoped stacks route (see src/completions.ts). - '@heroku/heroku-fetch', - '@heroku/sdk', - '@heroku/types', -]) -// 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', - 'CI', - 'ComSpec', - 'HTTP_PROXY', - 'HTTPS_PROXY', - 'LANG', - 'LC_ALL', - 'NO_PROXY', - 'PATH', - 'PACKED_VERIFY_NPM_CLI', - 'PACKED_VERIFY_SKIP_MUTATIONS', - 'PATHEXT', - 'SystemDrive', - 'SystemRoot', - 'TERM', - 'WINDIR', - 'all_proxy', - 'http_proxy', - 'https_proxy', - 'no_proxy', -]) - -let workspace -let currentPhase = 'initialization' -let pacote -let npmTarContents -let npmTar -let npmPacklist - -function fail(message) { - throw new Error(`[${currentPhase}] ${message}`) -} - -function check(condition, message) { - if (!condition) fail(message) -} - -function normalizePath(path) { - return path.split(sep).join('/') -} - -function parseJsonOutput(output, label) { - const starts = [output.indexOf('['), output.indexOf('{')].filter(index => index >= 0) - check(starts.length > 0, `${label} did not produce JSON`) - const start = Math.min(...starts) - let depth = 0 - let inString = false - let escaped = false - for (let index = start; index < output.length; index++) { - const character = output[index] - if (inString) { - if (escaped) escaped = false - else if (character === '\\') escaped = true - else if (character === '"') inString = false - continue - } - - if (character === '"') inString = true - else if (character === '[' || character === '{') depth++ - else if (character === ']' || character === '}') { - depth-- - if (depth === 0) return JSON.parse(output.slice(start, index + 1)) - } - } - - fail(`${label} JSON was incomplete`) -} - -async function readJson(path) { - return JSON.parse(await readFile(path, 'utf8')) -} - -async function walk(path, root = path) { - const entries = await readdir(path, {withFileTypes: true}) - const files = [] - for (const entry of entries) { - const entryPath = join(path, entry.name) - if (entry.isDirectory()) files.push(...await walk(entryPath, root)) - else files.push(normalizePath(relative(root, entryPath))) - } - - return files.sort() -} - -function redact(value) { - let redacted = String(value) - for (const secret of workspace?.redactions ?? []) redacted = redacted.replaceAll(secret, '[redacted]') - return redacted - .replace(/((?:_authToken|_auth|password|token|api[_-]?key|session)\s*[=:]\s*)\S+/gi, '$1[redacted]') - .replace(/(https?:\/\/)[^/@\s]+@/gi, '$1[redacted]@') -} - -function sanitizedEnvironment(overrides = {}) { - const environment = {} - for (const name of safeEnvironmentNames) { - if (process.env[name] !== undefined) environment[name] = process.env[name] - } - - return {...environment, ...overrides} -} - -async function run(command, args, options = {}) { - const printableCommand = options.label ?? `${command} ${args.join(' ')}` - try { - return await execFileAsync(command, args, { - cwd: repositoryRoot, - encoding: 'utf8', - env: workspace?.baseEnvironment ?? sanitizedEnvironment(), - maxBuffer: 20 * 1024 * 1024, - ...options, - label: undefined, - }) - } catch (error) { - const stderr = typeof error.stderr === 'string' ? error.stderr.trim() : '' - const stdout = typeof error.stdout === 'string' ? error.stdout.trim() : '' - throw new Error(`${printableCommand} failed: ${redact(stderr || stdout || error.message)}`) - } -} - -async function createMinimalNpmConfig(target) { - await writePublicNpmConfig(target) -} - -function specifiersFromSource(source, fileName) { - const scriptKind = fileName.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS - const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKind) - const specifiers = new Set() - const createRequireFactories = new Set() - const requireAliases = new Set(['require']) - - function addLiteral(node) { - if (ts.isStringLiteralLike(node)) specifiers.add(node.text) - } - - function visit(node) { - if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { - if (node.moduleSpecifier) addLiteral(node.moduleSpecifier) - if (ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) { - for (const element of node.importClause.namedBindings.elements) { - if ((element.propertyName ?? element.name).text === 'createRequire') createRequireFactories.add(element.name.text) - } - } - } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { - addLiteral(node.moduleReference.expression) - } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && createRequireFactories.has(node.initializer.expression.text)) { - requireAliases.add(node.name.text) - } else if (ts.isCallExpression(node) && node.arguments.length > 0) { - if (node.expression.kind === ts.SyntaxKind.ImportKeyword) addLiteral(node.arguments[0]) - else if (ts.isIdentifier(node.expression) && requireAliases.has(node.expression.text)) addLiteral(node.arguments[0]) - } - - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return specifiers -} - -function runSelfChecks() { - const fixture = ` - import {createRequire as makeRequire} from 'node:module' - export {thing} from './exported.js' - const load = makeRequire(import.meta.url) - load('@heroku/http-call/lib/proxy.js') - require('./legacy.cjs') - import('./dynamic.js') - ` - assert.deepEqual([...specifiersFromSource(fixture, 'fixture.js')].sort(), [ - './dynamic.js', - './exported.js', - './legacy.cjs', - '@heroku/http-call/lib/proxy.js', - 'node:module', - ]) - assert.throws(() => check(undefined, 'authoritative baseline failed'), /authoritative baseline failed/) - assert.match('LoginHttpLegacy', /\bLoginHttp\w*/) -} - -async function runMutationChecks() { - const source = await readFile(scriptPath, 'utf8') - const baselineAnchor = [ - ' const baseline = await packOriginBaseline(baselinePackDirectory)', - ' // MUTATION_ANCHOR_AUTHORITATIVE_BASELINE', - ].join('') - const proxyAnchor = [ - " check(graph.externalSpecifiers.has('@heroku/http-call/lib/proxy.js'), ", - "'reachable AST graph/package resolution missing @heroku/http-call/lib/proxy.js') // MUTATION_ANCHOR_PROXY_GRAPH", - ].join('') - - function mutateUnique(anchor, replacement, label) { - const occurrences = source.split(anchor).length - 1 - check(occurrences === 1, `${label} mutation anchor occurrence count was ${occurrences}, expected 1`) - const mutated = source.replace(anchor, replacement) - check(mutated !== source && mutated.includes(replacement), `${label} mutation did not replace its exact production target`) - return mutated - } - - function mutationOutput(error) { - return `${error?.stdout ?? ''}\n${error?.stderr ?? ''}`.trim() - } - - function assertIntendedFailure(error, expected, label) { - check(error?.code !== 0, `${label} mutation did not fail the verifier`) - const output = mutationOutput(error) - check(!/SyntaxError/.test(output), `${label} mutation failed with SyntaxError instead of its intended assertion`) - check(!/Cannot find module ['"]typescript['"]|ERR_MODULE_NOT_FOUND[^\n]*typescript/i.test(output), `${label} mutation could not resolve TypeScript`) - check(output.includes(expected), `${label} mutation output did not contain expected failure: ${expected}\nActual mutation output:\n${redact(output)}`) - return expected - } - - async function runMutation(kind, contents) { - const mutationPath = join(dirname(scriptPath), `.verify-packed-${kind}-${process.pid}-${randomUUID()}.mjs`) - await writeFile(mutationPath, contents) - try { - return await execFileAsync(process.execPath, [mutationPath], { - cwd: repositoryRoot, - encoding: 'utf8', - env: sanitizedEnvironment({ - HOME: process.env.HOME, - PACKED_VERIFY_NPM_CLI: npmCliPath, - PACKED_CONSUMER_BASELINE_SHA: baselineSha, - PACKED_VERIFY_SKIP_MUTATIONS: '1', - USERPROFILE: process.env.USERPROFILE ?? process.env.HOME, - }), - }).then(() => undefined, error => error) - } finally { - await rm(mutationPath, {force: true}) - } - } - - const baselineExpected = 'Packed command consumer verification failed: self-test baseline failure' - const baselineMutation = mutateUnique( - baselineAnchor, - " throw new Error('self-test baseline failure') // MUTATED_AUTHORITATIVE_BASELINE", - 'baseline-failure', - ) - const baselineFailure = await runMutation('baseline', baselineMutation) - const baselineEvidence = assertIntendedFailure(baselineFailure, baselineExpected, 'baseline-failure') - - const proxyExpected = 'Packed command consumer verification failed: [TypeScript AST reachable graph] reachable AST graph/package resolution missing @heroku/http-call/lib/proxy.js' - const proxyMutation = mutateUnique( - proxyAnchor, - " check(graph.externalSpecifiers.has('@heroku/http-call/lib/missing-proxy.js'), 'reachable AST graph/package resolution missing @heroku/http-call/lib/proxy.js') // MUTATED_PROXY_GRAPH", - 'proxy/createRequire', - ) - const proxyFailure = await runMutation('proxy', proxyMutation) - const proxyEvidence = assertIntendedFailure(proxyFailure, proxyExpected, 'proxy/createRequire') - workspace.mutationEvidence = {baseline: baselineEvidence, proxy: proxyEvidence} -} - -function moduleCandidates(fromFile, specifier) { - const base = normalizePath(relative(workspace.packageRoot, resolve(dirname(fromFile), specifier))) - if (fromFile.endsWith('.d.ts')) { - const declarationBase = base.endsWith('.js') ? `${base.slice(0, -3)}.d.ts` : `${base}.d.ts` - return [declarationBase, base, `${base}/index.d.ts`, `${base}/index.js`] - } - - return [base, `${base}.js`, `${base}.cjs`, `${base}/index.js`, `${base}/index.cjs`] -} - -async function inspectReachableGraph(entryPaths, fileSet) { - const pending = [...entryPaths] - const visited = new Set() - const externalSpecifiers = new Set() - while (pending.length > 0) { - const relativePath = pending.pop() - if (visited.has(relativePath)) continue - visited.add(relativePath) - const absolutePath = join(workspace.packageRoot, relativePath) - const source = await readFile(absolutePath, 'utf8') - const sourceFile = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, relativePath.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS) - function inspectSymbols(node) { - if (ts.isIdentifier(node)) check(!node.text.startsWith('LoginHttp'), `found removed LoginHttp* symbol in ${relativePath}`) - ts.forEachChild(node, inspectSymbols) - } - inspectSymbols(sourceFile) - - for (const specifier of specifiersFromSource(source, relativePath)) { - check(!specifier.includes('/deprecated'), `active import reaches deprecated code from ${relativePath}`) - if (!specifier.startsWith('.')) { - externalSpecifiers.add(specifier) - continue - } - - const candidates = moduleCandidates(absolutePath, specifier) - const target = candidates.find(candidate => fileSet.has(candidate)) - check(target, `active import ${specifier} from ${relativePath} does not resolve inside the package`) - pending.push(target) - } - } - - return {externalSpecifiers, visited} -} - -async function copyBaseline(destination) { - const {stdout} = await run('git', ['ls-tree', '-r', '-z', '--name-only', baselineSha], {env: workspace.baseEnvironment}) - const paths = stdout.split('\0').filter(Boolean) - check(paths.length > 0, `baseline ${baselineSha} archive is empty`) - for (const path of paths) { - const target = join(destination, ...path.split('/')) - await mkdir(dirname(target), {recursive: true}) - const {stdout: contents} = await run('git', ['show', `${baselineSha}:${path}`], {encoding: 'buffer', env: workspace.baseEnvironment}) - await writeFile(target, contents) - } -} - -function isolatedPaths(name) { - const root = join(workspace.root, name) - return { - cache: join(root, 'cache'), - config: join(root, 'config'), - data: join(root, 'data'), - home: join(root, 'home'), - logs: join(root, 'logs'), - root, - temporary: join(root, 'tmp'), - } -} - -async function createIsolatedEnvironment(name, extra = {}) { - const paths = isolatedPaths(name) - await Promise.all(Object.values(paths).map(path => mkdir(path, {recursive: true}))) - if (process.platform !== 'win32') { - await Promise.all(Object.values(paths).map(path => chmod(path, 0o700))) - } - const configPath = join(paths.root, 'npmrc') - const globalConfigPath = join(paths.root, 'global.npmrc') - await createMinimalNpmConfig(configPath) - await createMinimalNpmConfig(globalConfigPath) - const npmEnvironmentSource = sanitizedEnvironment({ - HOME: paths.home, - TMP: paths.temporary, - TEMP: paths.temporary, - TMPDIR: paths.temporary, - USERPROFILE: paths.home, - XDG_CACHE_HOME: paths.cache, - XDG_CONFIG_HOME: paths.config, - XDG_DATA_HOME: paths.data, - npm_config_ignore_scripts: 'true', - ...extra, - }) - const environment = createPublicNpmEnvironment(npmEnvironmentSource, {cachePath: paths.cache, configPath, globalConfigPath, logsPath: paths.logs}) - for (const [name, value] of Object.entries(npmEnvironmentSource)) { - if (/(?:_authToken|_auth|token|password|username)/i.test(name) && value) { - for (const secret of [value, encodeURIComponent(value), Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url')]) { - if (secret.length >= 4) workspace.redactions.add(secret) - } - } - } - await assertAnonymousNpmEnvironment(environment, paths) - return {environment, paths} -} - -async function npmPack(directory, packDirectory, environment, label) { - const manifest = await readJson(join(directory, 'package.json')) - const previousEnvironment = process.env - try { - process.env = environment - const tree = await new workspace.Arborist({path: directory}).loadActual() - const files = await npmPacklist(tree, {path: directory}) - const chunks = [] - const tarOptions = {...pacote.DirFetcher.tarCreateOptions(manifest), cwd: directory} - for await (const chunk of npmTar.c(tarOptions, files)) chunks.push(chunk) - const tarball = Buffer.concat(chunks) - let timeout - const timeoutFailure = new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error('tarball metadata inspection timed out')), 30_000) - timeout.unref() - }) - const metadata = await Promise.race([ - npmTarContents.getContents({...manifest, _id: `${manifest.name}@${manifest.version}`}, tarball), - timeoutFailure, - ]).finally(() => clearTimeout(timeout)) - await writeFile(join(packDirectory, metadata.filename), tarball) - return metadata - } catch (error) { - throw new Error(`${label} failed: ${redact(error.message)}`) - } finally { - process.env = previousEnvironment - } -} - -async function packCurrentSource(packDirectory) { - const isolated = await createIsolatedEnvironment('current-build') - await run(process.execPath, [join(repositoryRoot, 'node_modules/typescript/bin/tsc'), '--project', join(repositoryRoot, 'tsconfig.json'), '--outDir', join(workspace.root, 'current-lib')], { - cwd: repositoryRoot, - env: isolated.environment, - label: 'sanitized current TypeScript build', - }) - const packageRoot = join(workspace.root, 'current-package') - await mkdir(packageRoot) - for (const name of ['LICENSE.md', 'README.md', 'package.json']) await cp(join(repositoryRoot, name), join(packageRoot, name)) - await cp(join(workspace.root, 'current-lib'), join(packageRoot, 'lib'), {recursive: true}) - return npmPack(packageRoot, packDirectory, isolated.environment, 'current npm pack --ignore-scripts') -} - -async function packOriginBaseline(packDirectory) { - const baselineRepository = join(workspace.root, 'baseline-source') - await mkdir(baselineRepository) - await copyBaseline(baselineRepository) - const baselineManifest = await readJson(join(baselineRepository, 'package.json')) - const isolated = await createIsolatedEnvironment('baseline-build') - await run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], { - cwd: baselineRepository, - env: isolated.environment, - label: `authoritative ${baselineSha} npm ci --ignore-scripts`, - }) - await run(process.execPath, [join(baselineRepository, 'node_modules/typescript/bin/tsc'), '--project', join(baselineRepository, 'tsconfig.json')], { - cwd: baselineRepository, - env: isolated.environment, - label: `token-free ${baselineSha} TypeScript build`, - }) - const metadata = await npmPack(baselineRepository, packDirectory, isolated.environment, `authoritative ${baselineSha} npm pack --ignore-scripts`) - return {manifest: baselineManifest, metadata} -} - -async function extractTarball(tarball, destination) { - await pacote.extract(tarball, destination, {cache: workspace.controlPaths.cache, preferOffline: true}) -} - -async function verifyPackManifest(packMetadata, baseline) { - const paths = packMetadata.files.map(file => file.path).sort() - const pathSet = new Set(paths) - check(pathSet.has('package.json'), 'packed manifest is missing package.json') - check(pathSet.has('lib/index.js') && pathSet.has('lib/index.d.ts'), 'packed manifest is missing package root artifacts') - for (const compatibilityPath of expectedCompatibilityPaths) check(pathSet.has(compatibilityPath), `packed manifest lost compatibility path ${compatibilityPath}`) - check(!paths.some(path => path.startsWith('lib/deprecated/')), 'packed manifest contains lib/deprecated files') - check(!paths.some(path => sensitivePathPattern.test(path)), 'packed manifest contains a sensitive config/cache/log/token/private tarball path') - - const baselinePaths = new Set(baseline.metadata.files.map(file => file.path)) - const added = paths.filter(path => !baselinePaths.has(path)) - check(added.every(path => intentionalAdditions.has(path)), `unexpected files added versus baseline: ${added.filter(path => !intentionalAdditions.has(path)).join(', ')}`) - const missingBaselineJs = [...baselinePaths].filter(path => path.endsWith('.js') && path.startsWith('lib/credential-manager-core/') && !pathSet.has(path)) - check(missingBaselineJs.length === 0, `historical credential-manager JS paths removed versus baseline: ${missingBaselineJs.join(', ')}`) - - const currentDependencies = (await readJson(join(workspace.packageRoot, 'package.json'))).dependencies ?? {} - const baselineDependencies = baseline.manifest.dependencies ?? {} - const addedDependencies = Object.keys(currentDependencies).filter(name => !(name in baselineDependencies)) - 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.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} -} - -async function resolvePackageManifest(name, commandRequire) { - const entry = commandRequire.resolve(name) - let directory = dirname(entry) - while (true) { - const manifestPath = join(directory, 'package.json') - try { - const manifest = await readJson(manifestPath) - if (manifest.name === name) return {directory, manifest, manifestPath} - } catch {} - const parent = dirname(directory) - check(parent !== directory, `could not resolve package manifest for ${name}`) - directory = parent - } -} - -async function verifyInstalledGraph(commandDirectory) { - const commandRequire = createRequire(join(commandDirectory, 'package.json')) - for (const [name, artifact] of expectedDependencies) { - const installed = await resolvePackageManifest(name, commandRequire) - assert.equal(installed.manifest.version, artifact.version, `${name} installed version mismatch`) - const lockfile = await readJson(join(workspace.consumerDirectory, 'package-lock.json')) - const directLockKey = normalizePath(relative(workspace.consumerDirectory, installed.directory)) - const entry = lockfile.packages?.[directLockKey] ?? Object.entries(lockfile.packages ?? {}) - .find(([key, candidate]) => (key === `node_modules/${name}` || key.endsWith(`/node_modules/${name}`)) && candidate.version === installed.manifest.version)?.[1] - check(entry, `${name} resolved package has no matching consumer lockfile entry`) - assert.equal(entry.version, artifact.version, `${name} lockfile version mismatch`) - assert.equal(entry.integrity, artifact.integrity, `${name} registry integrity mismatch`) - assert.equal(entry.resolved, artifact.tarball, `${name} registry tarball mismatch`) - } - - const proxyPath = commandRequire.resolve('@heroku/http-call/lib/proxy.js') - const proxy = commandRequire(proxyPath) - check(typeof proxy.default?.agent === 'function', '@heroku/http-call/lib/proxy.js did not load expected default export') -} - -async function snapshotOutsideWorkspace() { - const roots = [workspace.consumerPaths.home, workspace.consumerPaths.config] - return new Map((await Promise.all(roots.map(async root => [root, await walk(root)]))).map(([root, files]) => [root, files])) -} - -async function writeConsumerFiles(consumerDirectory, commandDirectory, deepPaths, loopbackUrl) { - const runtimeImports = [ - pathToFileURL(join(commandDirectory, 'lib/index.js')).href, - pathToFileURL(join(commandDirectory, 'lib/login.js')).href, - pathToFileURL(join(commandDirectory, 'lib/credential-manager.js')).href, - pathToFileURL(join(commandDirectory, 'lib/credential-manager-login-adapters.js')).href, - ...deepPaths.map(path => pathToFileURL(join(commandDirectory, path)).href), - ] - await writeFile(join(consumerDirectory, 'smoke.mjs'), ` -const modules = await Promise.all(${JSON.stringify(runtimeImports)}.map(specifier => import(specifier))) -const [root, login, credentials, adapters] = modules -if (typeof root.Command !== 'function' || typeof root.APIClient !== 'function') throw new Error('package root runtime shape is incomplete') -if (typeof login.Login !== 'function') throw new Error('login runtime shape is incomplete') -const calls = [] -credentials.setCredentialManagerProvider({ - async getAuth(account, host, service) { calls.push(['get', account, host, service]); return {account: 'smoke@example.com', token: 'smoke-token'} }, - async removeAuth(account, hosts, service, expectedToken) { calls.push(['remove', account, hosts, service, expectedToken]) }, - async saveAuth(account, token, hosts, service) { calls.push(['save', account, token, hosts, service]) }, -}) -await credentials.saveAuth('smoke@example.com', 'smoke-token', ['api.heroku.test'], 'packed-smoke') -const entry = await credentials.getAuth('smoke@example.com', 'api.heroku.test', 'packed-smoke') -await credentials.removeAuth(entry.account, ['api.heroku.test'], 'packed-smoke', entry.token) -if (JSON.stringify(calls.map(call => call[0])) !== JSON.stringify(['save', 'get', 'remove'])) throw new Error('injected credential provider lifecycle failed') - -const platformCalls = [] -const platform = adapters.createCredentialManagerPlatformAdapter({ - async delete(path, options) { platformCalls.push(['delete', path, options]); return {body: {ok: true}, headers: {}, statusCode: 200} }, - async get(path, options) { platformCalls.push(['get', path, options]); return {body: {ok: true}, headers: {}, statusCode: 200} }, -}, 'operation-token') -await platform.get('/account') -await platform.delete('/oauth/authorizations/1') -if (platformCalls.some(call => call[2].headers.Authorization !== 'Bearer operation-token' || call[2].retryAuth !== false)) throw new Error('platform adapter did not bind controlled operation token') - -const fetchAdapter = adapters.createCredentialManagerFetchAdapter() -const response = await fetchAdapter(${JSON.stringify(loopbackUrl)}, {headers: {'x-packed-smoke': 'yes'}, redirect: 'error'}) -if (response.status !== 200 || await response.text() !== 'loopback-ok') throw new Error('loopback fetch adapter smoke failed') -const config = {dataDir: process.env.HEROKU_DATA_DIR, platform: process.platform, version: 'packed-verifier'} -const api = new root.APIClient(config, {preauth: false, required: false}) -api.setAuthEntry({account: undefined, token: undefined}) -const loginInstance = new login.Login(config, api) -if (typeof loginInstance.login !== 'function' || typeof loginInstance.logout !== 'function') throw new Error('Login smoke instance is incomplete') -console.log(JSON.stringify({credentialCalls: calls.length, imports: modules.length, loopback: 'ok', platformCalls: platformCalls.length})) -`) - - await writeFile(join(consumerDirectory, 'contract.ts'), ` -import Command, {APIClient, type AuthEntry as RootAuthEntry, type IOptions} from '@heroku-cli/command' -import {Login, type Login as LoginTypes} from '@heroku-cli/command/lib/login.js' -import type {AuthEntry as FacadeAuthEntry} from '@heroku-cli/command/lib/credential-manager.js' -import type {AuthEntry as CoreAuthEntry} from '@heroku-cli/command/lib/credential-manager-core/index.js' -import type {AuthEntry as TypesAuthEntry} from '@heroku-cli/command/lib/credential-manager-core/lib/types.js' -type Assert = T -type HistoricalAuthEntry = {account: string | undefined; token: string | undefined} -type IsEqual = (() => T extends L ? 1 : 2) extends (() => T extends R ? 1 : 2) ? true : false -type Compatible = [Assert>, Assert>, Assert>, Assert>] -const historical: HistoricalAuthEntry = {account: undefined, token: undefined} -const entries: [RootAuthEntry, FacadeAuthEntry, CoreAuthEntry, TypesAuthEntry] = [historical, historical, historical, historical] -const options: IOptions = {preauth: false, required: false} -const client: APIClient = {} as APIClient -const login: Login = new Login({} as ConstructorParameters[0], client) -const method: LoginTypes.Method = 'browser' -const commandConstructor: typeof Command = Command -client.setAuthEntry(historical) -void client.getAuthEntry() -void login.login({method}) -export {type Compatible, commandConstructor, entries, options} -`) - await writeFile(join(consumerDirectory, 'tsconfig.json'), JSON.stringify({compilerOptions: {module: 'NodeNext', moduleResolution: 'NodeNext', noEmit: true, skipLibCheck: false, strict: true, target: 'ES2022', typeRoots: [join(repositoryRoot, 'node_modules/@types')], types: ['node']}, files: ['./contract.ts']}, null, 2)) -} - -async function listenLoopback() { - const server = createServer((request, response) => { - if (request.headers['x-packed-smoke'] !== 'yes') { - response.writeHead(400).end('missing-header') - return - } - response.writeHead(200, {'content-type': 'text/plain'}).end('loopback-ok') - }) - await new Promise((resolvePromise, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolvePromise) - }) - const address = server.address() - return {server, url: `http://127.0.0.1:${address.port}/smoke`} -} - -async function verifyConsumer(tarball, deepPaths) { - const isolated = await createIsolatedEnvironment('consumer') - workspace.consumerPaths = isolated.paths - workspace.consumerDirectory = join(workspace.root, 'consumer-project') - await mkdir(workspace.consumerDirectory) - await writeFile(join(workspace.consumerDirectory, 'package.json'), JSON.stringify({name: 'packed-command-consumer', private: true, type: 'module'}, null, 2)) - await run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], { - cwd: workspace.consumerDirectory, - env: isolated.environment, - label: 'fresh consumer npm install --ignore-scripts', - }) - const commandManifestPath = createRequire(join(workspace.consumerDirectory, 'package.json')).resolve('@heroku-cli/command/package.json') - const resolvedCommandDirectory = dirname(commandManifestPath) - check(resolvedCommandDirectory.endsWith(join('@heroku-cli', 'command')), 'installed command package path did not resolve') - await verifyInstalledGraph(resolvedCommandDirectory) - const before = await snapshotOutsideWorkspace() - const loopback = await listenLoopback() - try { - await writeConsumerFiles(workspace.consumerDirectory, resolvedCommandDirectory, deepPaths, loopback.url) - await run(process.execPath, ['smoke.mjs'], {cwd: workspace.consumerDirectory, env: {...isolated.environment, HEROKU_DATA_DIR: isolated.paths.data}, label: 'isolated runtime smoke'}) - } finally { - await new Promise(resolvePromise => loopback.server.close(resolvePromise)) - } - await run(process.execPath, [join(repositoryRoot, 'node_modules/typescript/bin/tsc'), '--project', 'tsconfig.json'], {cwd: workspace.consumerDirectory, env: isolated.environment, label: 'consumer declaration compile'}) - const after = await snapshotOutsideWorkspace() - assert.deepEqual(after, before, 'runtime smoke wrote to isolated home/config/data outside its dedicated data directory') -} - -async function initializeWorkspace() { - workspace = {redactions: new Set(), root: await mkdtemp(join(tmpdir(), workspacePrefix))} - workspace.redactions.add(workspace.root) - workspace.redactions.add(normalizePath(workspace.root)) - if (process.env.HOME) { - workspace.redactions.add(process.env.HOME) - workspace.redactions.add(normalizePath(process.env.HOME)) - } - workspace.npmUserConfig = join(workspace.root, 'npmrc') - await createMinimalNpmConfig(workspace.npmUserConfig) - const control = await createIsolatedEnvironment('control') - workspace.controlPaths = control.paths - workspace.baseEnvironment = control.environment - await assertAnonymousNpmEnvironment(workspace.baseEnvironment, workspace.controlPaths) - const npmPackagePath = npmCliPath ? resolve(dirname(npmCliPath), '..', 'package.json') : undefined - const resolvedNpmPackagePath = npmPackagePath ?? join((await run('npm', ['root', '--global'], {env: workspace.baseEnvironment})).stdout.trim(), 'npm', 'package.json') - const npmRequire = createRequire(resolvedNpmPackagePath) - pacote = npmRequire('pacote') - npmTarContents = npmRequire(resolve(dirname(resolvedNpmPackagePath), 'lib/utils/tar.js')) - npmTar = npmRequire('tar') - npmPacklist = npmRequire('npm-packlist') - workspace.Arborist = npmRequire('@npmcli/arborist') -} - -async function assertAnonymousNpmEnvironment(environment, paths) { - for (const [key, value] of Object.entries(environment)) { - if (/^(?:NODE_AUTH_TOKEN|NPM_TOKEN)$/i.test(key)) { - check(value === '', `npm verifier environment does not clear ${key}`) - continue - } - if (/^npm_config_/i.test(key) && /(?:auth|token|password|username)/i.test(key)) { - fail(`npm verifier environment includes credential setting ${key}`) - } - check(!/(?:_authToken|_auth|token|password|username)/i.test(key), `npm verifier environment includes credential setting ${key}`) - check(!/(?:_authToken|_auth|token|password|username)/i.test(value), `npm verifier environment contains credential value for ${key}`) - } - check(environment.NODE_AUTH_TOKEN === '' && environment.NPM_TOKEN === '', 'npm verifier environment does not explicitly clear token variables') - check(environment.NPM_CONFIG_USERCONFIG === join(paths.root, 'npmrc'), 'npm user config is not isolated') - check(environment.NPM_CONFIG_GLOBALCONFIG === join(paths.root, 'global.npmrc'), 'npm global config is not isolated') - check(environment.NPM_CONFIG_CACHE === paths.cache, 'npm cache is not isolated') - check(environment.NPM_CONFIG_LOGS_DIR === paths.logs, 'npm logs are not isolated') - check(environment.NPM_CONFIG_REGISTRY === 'https://registry.npmjs.org/', 'npm registry is not explicitly public') - check(await readFile(environment.NPM_CONFIG_USERCONFIG, 'utf8') === publicNpmConfig, 'npm config is not an empty public-registry config') - if (process.platform !== 'win32') { - for (const directory of [paths.root, paths.cache, paths.logs]) { - assert.equal((await stat(directory)).mode & 0o777, 0o700) - } - assert.equal((await stat(environment.NPM_CONFIG_USERCONFIG)).mode & 0o777, 0o600) - assert.equal((await stat(environment.NPM_CONFIG_GLOBALCONFIG)).mode & 0o777, 0o600) - } -} - -async function main() { - check(Number(process.versions.node.split('.')[0]) >= 20, 'Node 20 or newer is required') - check(/^[0-9a-f]{40}$/.test(authoritativeBaselineSha), '.github/packed-consumer-baseline must contain exactly one immutable 40-hex commit SHA') - if (requestedBaselineSha !== undefined) { - check(requestedBaselineSha === authoritativeBaselineSha, `requested baseline ${requestedBaselineSha} does not equal authoritative baseline ${authoritativeBaselineSha}`) // MUTATION_ANCHOR_BASELINE_EQUALITY - } - runSelfChecks() - await initializeWorkspace() - await run('git', ['cat-file', '-e', `${authoritativeBaselineSha}^{commit}`], {env: workspace.baseEnvironment, label: `authoritative baseline commit ${authoritativeBaselineSha}`}) // MUTATION_ANCHOR_BASELINE_EXISTENCE - if (process.env.PACKED_VERIFY_SKIP_MUTATIONS !== '1') await runMutationChecks() - currentPhase = 'sanitized build and authoritative pack baseline' - const packDirectory = join(workspace.root, 'pack') - const baselinePackDirectory = join(workspace.root, 'baseline-pack') - await Promise.all([mkdir(packDirectory), mkdir(baselinePackDirectory)]) - const packMetadata = await packCurrentSource(packDirectory) - const baseline = await packOriginBaseline(baselinePackDirectory) // MUTATION_ANCHOR_AUTHORITATIVE_BASELINE - const tarball = join(packDirectory, packMetadata.filename) - - currentPhase = 'packed manifest extraction and contract' - workspace.packageRoot = join(workspace.root, 'extracted-package') - await mkdir(workspace.packageRoot) - await extractTarball(tarball, workspace.packageRoot) - workspace.manifest = await readJson(join(workspace.packageRoot, 'package.json')) - assert.deepEqual(await readFile(join(workspace.packageRoot, 'package.json')), await readFile(join(repositoryRoot, 'package.json')), 'packed package.json bytes differ from the current source manifest') - for (const [name, artifact] of expectedDependencies) assert.equal(workspace.manifest.dependencies?.[name], artifact.version, `${name} must be exact ${artifact.version}`) - const manifestSummary = await verifyPackManifest(packMetadata, baseline) - - currentPhase = 'TypeScript AST reachable graph' - const packedFiles = await walk(workspace.packageRoot) - const packedSourceFiles = packedFiles.filter(path => /\.(?:js|cjs|mjs|d\.ts)$/.test(path)) - for (const path of packedSourceFiles) { - const source = await readFile(join(workspace.packageRoot, path), 'utf8') - check(!/\bLoginHttp\w*/.test(source), `found removed LoginHttp* symbol in packed file ${path}`) // MUTATION_ANCHOR_PACKED_LOGIN_HTTP - } - const packedFileSet = new Set(packedFiles) - const graph = await inspectReachableGraph(['lib/index.js', 'lib/login.js', 'lib/credential-manager.js', 'lib/index.d.ts', 'lib/login.d.ts', 'lib/credential-manager.d.ts'], packedFileSet) - check(graph.externalSpecifiers.has('@heroku/heroku-credential-manager'), 'reachable AST graph does not import credential-manager root') - check(graph.externalSpecifiers.has('@heroku/heroku-credential-manager/login'), 'reachable AST graph does not import credential-manager/login') - check(graph.externalSpecifiers.has('@heroku/http-call/lib/proxy.js'), 'reachable AST graph/package resolution missing @heroku/http-call/lib/proxy.js') // MUTATION_ANCHOR_PROXY_GRAPH - const packedText = (await Promise.all(packedSourceFiles.map(path => readFile(join(workspace.packageRoot, path), 'utf8')))).join('\n') - check(!/(?:_authToken|npm_config_userconfig|\buserconfig\b|registry\.npmjs\.org:[^@\s]+@)/i.test(packedText), 'packed artifact contains registry authentication or userconfig data') - for (const secret of workspace.redactions) if (secret.length >= 4) check(!packedText.includes(secret), 'packed artifact contains a sensitive environment/config value') - - currentPhase = 'fresh ESM consumer' - const deepPaths = [...expectedCompatibilityPaths].filter(path => path.startsWith('lib/credential-manager-core/')) - await verifyConsumer(tarball, deepPaths) - console.log('Packed command consumer verification passed.') - console.log(`Runtime: Node ${process.versions.node}`) - console.log(`Package: ${workspace.manifest.name}@${workspace.manifest.version}`) - console.log(`Tarball manifest: ${manifestSummary.paths.length} files; ${packMetadata.unpackedSize} unpacked bytes`) - console.log(`Authoritative baseline: ${authoritativeBaselineSha} (${baseline.manifest.version}); intentional additions observed: ${manifestSummary.added.length}`) - console.log(`Compatibility imports: ${deepPaths.length} historical JS paths plus root/login/credential-manager`) - console.log(`Dependencies: ${[...expectedDependencies].map(([name, artifact]) => `${name}@${artifact.version}`).join(', ')} (resolved manifests and integrity verified)`) - console.log('Lifecycle scope: current build and immutable baseline build were sanitized; both packs and both dependency installs used --ignore-scripts') - console.log('Security: AST graph resolved, proxy deep import loaded, credential/loopback smoke isolated, no deprecated or sensitive packed artifacts') - if (workspace.mutationEvidence) { - console.log(`Mutation baseline: ${workspace.mutationEvidence.baseline}`) - console.log(`Mutation proxy: ${workspace.mutationEvidence.proxy}`) - } -} - -try { - await main() -} catch (error) { - console.error(`Packed command consumer verification failed: ${redact(error.message)}`) - process.exitCode = 1 -} finally { - if (workspace?.root) { - try { - await rm(workspace.root, {force: true, recursive: true}) - } catch (error) { - console.error(`Warning: temporary verifier cleanup failed: ${redact(error.message)}`) - process.exitCode = 1 - } - } -} diff --git a/src/api-client.ts b/src/api-client.ts index 4155b566..170fa237 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,6 +1,6 @@ import type {Config} from '@oclif/core/interfaces' -import {NativeCredentialNotFoundError} from '@heroku/heroku-credential-manager' +import {getStorageConfig, NativeCredentialNotFoundError, readLoginState} from '@heroku/heroku-credential-manager' import {HTTP, HTTPError, type HTTPRequestOptions} from '@heroku/http-call' import {CLIError, warn} from '@oclif/core/errors' import {ux} from '@oclif/core/ux' @@ -8,8 +8,6 @@ import debug from 'debug' import {access} from 'node:fs/promises' import path from 'node:path' -import {getStorageConfig} from './credential-manager-core/lib/credential-storage-selector.js' -import {readLoginState} from './credential-manager-core/lib/login-state.js' import {type AuthEntry, getAuth as getStoredAuth, removeAuth} from './credential-manager.js' import { deleteLoginStateIf, diff --git a/src/credential-manager-core/lib/cli-command-telemetry.ts b/src/credential-error-reporter.ts similarity index 71% rename from src/credential-manager-core/lib/cli-command-telemetry.ts rename to src/credential-error-reporter.ts index e95deaf1..828a4066 100644 --- a/src/credential-manager-core/lib/cli-command-telemetry.ts +++ b/src/credential-error-reporter.ts @@ -1,3 +1,4 @@ +import type {CredentialErrorContext} from '@heroku/heroku-credential-manager' import type {ErrorEvent} from '@sentry/node' import { @@ -12,8 +13,6 @@ import {readFileSync} from 'node:fs' import path from 'node:path' import {fileURLToPath} from 'node:url' -import type {CredentialStore} from './credential-storage-selector.js' - const DSN = 'https://4eb3812769d649a09ae76ef3fcd03dbb@o4508609692368896.ingest.us.sentry.io/4511095245832192' @@ -39,7 +38,7 @@ function readPackageVersion(): string { } const dir = path.dirname(fileURLToPath(import.meta.url)) - const pkgPath = path.join(dir, '../../../package.json') + const pkgPath = path.join(dir, '../package.json') const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {version?: string} releaseCache = pkg.version ?? 'unknown' return releaseCache @@ -82,6 +81,15 @@ function ensureCredentialSentryInitialized(): void { scrubber.scrub(event as unknown as Record).data return scrubbed as unknown as ErrorEvent }, + // Report only the credential errors we explicitly capture. The default + // integrations install process-global uncaught-exception / unhandled- + // rejection handlers and console/http breadcrumbs; since this is the global + // client and we keep it open after the first credential failure (see + // reportCredentialStoreError), those would otherwise route any later + // unrelated host crash to the credential DSN and attach breadcrumbs (e.g. + // console output or HTTP activity carrying config values) that this + // scrubber wasn't built for. + defaultIntegrations: false, dsn: DSN, environment: isDev ? 'development' : 'production', release: `@heroku-cli/command@${readPackageVersion()}`, @@ -89,11 +97,9 @@ function ensureCredentialSentryInitialized(): void { }) } -export type CredentialSentryOperation = 'getAuth' | 'listKeychainAccounts' | 'removeAuth' | 'saveAuth' - export async function reportCredentialStoreError( error: unknown, - context: {credentialStore: CredentialStore; operation: CredentialSentryOperation}, + context: CredentialErrorContext, ): Promise { if (!shouldReportCredentialErrorsToSentry()) { return @@ -108,10 +114,12 @@ export async function reportCredentialStoreError( credential_store: context.credentialStore, }, }) + // Flush pending events but keep the client alive so a second credential + // failure in the same process is still reported. Closing the client here + // left it registered-but-disabled, which the getClient() guard in + // ensureCredentialSentryInitialized() then mistook for a live client, + // dropping every subsequent report. await credentialSentrySdk.flush(2000) - await credentialSentrySdk.getClient()?.close() - // Clear the cached client so it can be re-initialized on the next error - sentryClient = undefined } catch { // avoid impacting credential flows if Sentry fails } diff --git a/src/credential-manager-core/credential-handlers/linux-handler.ts b/src/credential-manager-core/credential-handlers/linux-handler.ts deleted file mode 100644 index e577db90..00000000 --- a/src/credential-manager-core/credential-handlers/linux-handler.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** @deprecated Import `LinuxHandler` from `@heroku/heroku-credential-manager`. */ - -export {LinuxHandler} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-core/credential-handlers/macos-handler.ts b/src/credential-manager-core/credential-handlers/macos-handler.ts deleted file mode 100644 index aa52aeb8..00000000 --- a/src/credential-manager-core/credential-handlers/macos-handler.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** @deprecated Import `MacOSHandler` from `@heroku/heroku-credential-manager`. */ - -export {MacOSHandler} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-core/credential-handlers/netrc-handler.ts b/src/credential-manager-core/credential-handlers/netrc-handler.ts deleted file mode 100644 index 1c5d5db6..00000000 --- a/src/credential-manager-core/credential-handlers/netrc-handler.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** @deprecated Import `NetrcHandler` from `@heroku/heroku-credential-manager`. */ - -export {NetrcHandler} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-core/credential-handlers/windows-handler.ts b/src/credential-manager-core/credential-handlers/windows-handler.ts deleted file mode 100644 index ba3f8229..00000000 --- a/src/credential-manager-core/credential-handlers/windows-handler.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** @deprecated Import `WindowsHandler` from `@heroku/heroku-credential-manager`. */ - -export {WindowsHandler} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-core/index.ts b/src/credential-manager-core/index.ts deleted file mode 100644 index 7c2dd20f..00000000 --- a/src/credential-manager-core/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -export { - getAuth, - listKeychainAccounts, - removeAuth, - saveAuth, -} from './lib/credential-manager-adapter.js' -export type {AuthEntry} from './lib/types.js' - -/** @deprecated Import credential storage APIs from `@heroku/heroku-credential-manager`. */ - -export { - CredentialStore, - deleteLoginState, - getCredentialHandler, - getNativeCredentialStore, - getStorageConfig, - type KeychainAuthEntry, - LinuxHandler, - type Machines, - type MachinesWithTokens, - type MachineToken, - MacOSHandler, - NativeCredentialNotFoundError, - Netrc, - type NetrcAuthEntry, - NetrcHandler, - parse, - readLoginState, - type StorageConfig, - type Token, - WindowsHandler, - writeLoginState, -} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-core/lib/credential-manager-adapter.ts b/src/credential-manager-core/lib/credential-manager-adapter.ts deleted file mode 100644 index 44ea84bd..00000000 --- a/src/credential-manager-core/lib/credential-manager-adapter.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - type CredentialStore, - type AuthEntry as ExternalAuthEntry, - getCredentialHandler, - getNativeCredentialStore, - getStorageConfig, - NativeCredentialNotFoundError, - NetrcHandler, -} from '@heroku/heroku-credential-manager' -import debug from 'debug' - -import {reportCredentialStoreError} from './cli-command-telemetry.js' - -const credDebug = debug('heroku-credential-manager') -const SERVICE_NAME = 'heroku-cli' - -export async function saveAuth( - account: string, - token: string, - hosts: string[], - service = SERVICE_NAME, -): Promise { - const config = getStorageConfig() - const netrcHandler = new NetrcHandler() - let nativeSuccess = false - - if (config.credentialStore) { - try { - const handler = getCredentialHandler(config.credentialStore) - handler.saveAuth({account, service, token}) - nativeSuccess = true - } catch (error) { - credDebug('native credential store failed during saveAuth; falling back to netrc') - await reportNativeCredentialStoreError(error, config.credentialStore, 'saveAuth') - } - } - - const shouldUseNetrc = config.useNetrc || !nativeSuccess - if (shouldUseNetrc) { - await netrcHandler.saveAuthForHosts({login: account, password: token}, hosts) - } else if (hosts.length > 0) { - await netrcHandler.removeAuthForHosts(hosts, account) - } -} - -export async function getAuth( - account: string | undefined, - host: string, - service = SERVICE_NAME, -): Promise { - const config = getStorageConfig() - const netrcHandler = new NetrcHandler() - - if (account && config.credentialStore) { - try { - const handler = getCredentialHandler(config.credentialStore) - return {account, token: handler.getAuth(account, service)} - } catch (error) { - if (!(error instanceof NativeCredentialNotFoundError)) { - await reportNativeCredentialStoreError(error, config.credentialStore, 'getAuth') - throw error - } - - credDebug('native credential was not found during getAuth; falling back to netrc') - } - } - - const auth = await netrcHandler.getAuth(host) - if (auth.login && auth.password) { - if (account && auth.login !== account) { - throw new Error('Netrc credential does not match the requested account for host') - } - - return {account: auth.login, token: auth.password} - } - - throw new Error('No auth found') -} - -export async function listKeychainAccounts(service = SERVICE_NAME): Promise { - const config = getStorageConfig() - - if (config.credentialStore) { - try { - return getCredentialHandler(config.credentialStore).listAccounts(service) - } catch (error) { - credDebug('native credential store failed during listKeychainAccounts') - await reportNativeCredentialStoreError(error, config.credentialStore, 'listKeychainAccounts') - } - } - - return [] -} - -export async function removeAuth( - account: string | undefined, - hosts: string[], - service = SERVICE_NAME, - expectedToken?: string, -): Promise { - const netrcHandler = new NetrcHandler() - const nativeStore = getNativeCredentialStore() - - if (nativeStore && account) { - try { - const handler = getCredentialHandler(nativeStore) - if (expectedToken === undefined || handler.getAuth(account, service) === expectedToken) { - handler.removeAuth(account, service) - } - } catch (error) { - if (!(error instanceof NativeCredentialNotFoundError)) { - credDebug('native credential store failed during removeAuth; continuing netrc cleanup') - await reportNativeCredentialStoreError(error, nativeStore, 'removeAuth') - } - } - } - - if (hosts.length > 0) { - await netrcHandler.removeAuthForHosts(hosts, account, expectedToken) - } -} - -async function reportNativeCredentialStoreError( - error: unknown, - credentialStore: CredentialStore, - operation: 'getAuth' | 'listKeychainAccounts' | 'removeAuth' | 'saveAuth', -): Promise { - await reportCredentialStoreError(error, {credentialStore, operation}) -} diff --git a/src/credential-manager-core/lib/credential-storage-selector.ts b/src/credential-manager-core/lib/credential-storage-selector.ts deleted file mode 100644 index 460f5b18..00000000 --- a/src/credential-manager-core/lib/credential-storage-selector.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** @deprecated Import credential storage selection APIs from `@heroku/heroku-credential-manager`. */ - -export { - CredentialStore, - getNativeCredentialStore, - getStorageConfig, - type StorageConfig, -} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-core/lib/login-state.ts b/src/credential-manager-core/lib/login-state.ts deleted file mode 100644 index 40a321b1..00000000 --- a/src/credential-manager-core/lib/login-state.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** @deprecated Import login-state APIs from `@heroku/heroku-credential-manager`. */ - -export { - deleteLoginState, - readLoginState, - writeLoginState, -} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-core/lib/netrc-parser.ts b/src/credential-manager-core/lib/netrc-parser.ts deleted file mode 100644 index 78a7ceb6..00000000 --- a/src/credential-manager-core/lib/netrc-parser.ts +++ /dev/null @@ -1,20 +0,0 @@ - -import {Netrc} from '@heroku/heroku-credential-manager' - -/** @deprecated Import netrc parser APIs from `@heroku/heroku-credential-manager`. */ -export { - Netrc, - parse, -} from '@heroku/heroku-credential-manager' - -/** @deprecated Import netrc parser types from `@heroku/heroku-credential-manager`. */ -export type { - Machines, - MachinesWithTokens, - MachineToken, - Token, -} from '@heroku/heroku-credential-manager' - -/** @deprecated Import and instantiate `Netrc` from `@heroku/heroku-credential-manager`. */ -const defaultNetrc = new Netrc() -export default defaultNetrc diff --git a/src/credential-manager-core/lib/types.ts b/src/credential-manager-core/lib/types.ts deleted file mode 100644 index 55deda20..00000000 --- a/src/credential-manager-core/lib/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** Backward-compatible shape exported before credential storage was extracted. */ -export type AuthEntry = { - account: string | undefined; - token: string | undefined; -} - -/** @deprecated Import credential storage types from `@heroku/heroku-credential-manager`. */ - -export type { - KeychainAuthEntry, - NetrcAuthEntry, -} from '@heroku/heroku-credential-manager' diff --git a/src/credential-manager-login-adapters.ts b/src/credential-manager-login-adapters.ts index 35ff9381..ee23d05d 100644 --- a/src/credential-manager-login-adapters.ts +++ b/src/credential-manager-login-adapters.ts @@ -4,12 +4,11 @@ import type { HerokuApiRequestOptions, HerokuApiResponse, } from '@heroku/heroku-credential-manager/login' -import type {IncomingHttpHeaders, OutgoingHttpHeaders} from 'node:http' +import type {IncomingHttpHeaders} from 'node:http' -import {HTTP, HTTPError, type HTTPRequestOptions} from '@heroku/http-call' -import {Agent as HttpsAgent} from 'node:https' -import {createRequire} from 'node:module' -import {Readable} from 'node:stream' +import {getDefaultDispatcher} from '@heroku/heroku-fetch/client/environment-defaults.js' +import {HTTPError, type HTTPRequestOptions} from '@heroku/http-call' +import {fetch as undiciFetch, type RequestInit as UndiciRequestInit} from 'undici' type CommandResponse = { body: T; @@ -27,26 +26,6 @@ export type CommandPlatformClient = { get(path: string, options?: CommandRequestOptions): Promise>; } -/** Declared command transport contract used by the FetchLike adapter. */ -export type CommandLoginTransport = typeof HTTP -// TypeScript supplies the fetch declarations used by FetchLike even though ESLint's globals table does not. -// eslint-disable-next-line no-undef -type FetchBody = RequestInit['body'] -type ReplayableBody = { - stream(): Readable; -} -const bodyFactory = Symbol('credential-manager-body-factory') -type ReplayableRequestOptions = HTTPRequestOptions & { - [bodyFactory]?: () => Readable; -} -const require = createRequire(import.meta.url) -type CommandProxyPolicy = { - agent(https: boolean, host?: string): unknown; - certs: Buffer[]; - env: NodeJS.ProcessEnv; -} -const commandProxyPolicy = (require('@heroku/http-call/lib/proxy.js') as {default: CommandProxyPolicy}).default - function authorizationHeaders(headers: HerokuApiRequestOptions['headers'], token: string): Record { const forwarded: Record = {} for (const [name, value] of Object.entries(headers ?? {})) { @@ -58,13 +37,20 @@ function authorizationHeaders(headers: HerokuApiRequestOptions['headers'], token } function platformOptions(options: HerokuApiRequestOptions | undefined, token: string): CommandRequestOptions { - return { + const platformRequestOptions: CommandRequestOptions = { headers: authorizationHeaders(options?.headers, token), partial: true, retryAuth: false, - signal: options?.signal, - timeout: options?.timeoutMs, } + + // Only set signal/timeout when the caller supplied them. http-call merges + // options over its defaults with Object.assign, so an explicit `undefined` + // here would overwrite http-call's default request timeout (60s) and leave + // the request able to hang indefinitely. + if (options?.signal !== undefined) platformRequestOptions.signal = options.signal + if (options?.timeoutMs !== undefined) platformRequestOptions.timeout = options.timeoutMs + + return platformRequestOptions } function normalizePlatformResponse(response: CommandResponse): HerokuApiResponse { @@ -94,155 +80,80 @@ export function createCredentialManagerPlatformAdapter(client: CommandPlatformCl } } -/* eslint-disable n/no-unsupported-features/node-builtins, no-undef -- Node 20+ provides these fetch globals at runtime; the lint engine's compatibility table predates that support */ -async function replayableBody(request: Request): Promise { - if (request.body === null) return - const bytes = new Uint8Array(await request.arrayBuffer()) - return {stream: () => Readable.from([bytes])} -} - -function requestHeaders(headers: Headers): OutgoingHttpHeaders { - return Object.fromEntries(headers.entries()) -} - -function environmentAgent(url: string): HTTPRequestOptions['agent'] { - const parsed = new URL(url) - const previousEnvironment = commandProxyPolicy.env - commandProxyPolicy.env = process.env - try { - const secure = parsed.protocol === 'https:' - const proxyAgent = commandProxyPolicy.agent(secure, parsed.hostname) - if (proxyAgent) return proxyAgent as HTTPRequestOptions['agent'] - const ca = commandProxyPolicy.certs - return secure && ca.length > 0 ? new HttpsAgent({ca}) : undefined - } finally { - commandProxyPolicy.env = previousEnvironment +/** + * Rebuilds a FetchLike call as plain `(url, init)` arguments that the installed + * undici's `fetch` accepts. + * + * A `Request` constructed from Node's global constructor is backed by Node's + * built-in (bundled) undici and is not recognized by the installed undici's + * `fetch` (the brand check is per-copy), so a `Request` must be decomposed into + * a URL + init rather than forwarded as-is. The body is buffered because the + * only `Request`s the login module sends through the proxy path carry small + * JSON payloads. + */ +async function toUndiciArgs( + input: Parameters[0], + init: Parameters[1], +): Promise<[string, UndiciRequestInit]> { + if (typeof input === 'string' || input instanceof URL) { + return [input.toString(), (init ?? {}) as UndiciRequestInit] } -} - -function responseHeaders(headers: IncomingHttpHeaders): Headers { - const normalized = new Headers() - for (const [name, value] of Object.entries(headers)) { - if (Array.isArray(value)) { - for (const item of value) normalized.append(name, item) - } else if (value !== undefined) { - normalized.append(name, value) - } - } - - return normalized -} - -function responseBody(body: unknown): FetchBody { - if (body === undefined || body === null) return null - if (typeof body === 'string') return body - if (body instanceof Uint8Array) return new Blob([Uint8Array.from(body)]) - return JSON.stringify(body) -} - -function responseBodyForStatus(status: number, body: unknown): FetchBody { - return [204, 205, 304].includes(status) ? null : responseBody(body) -} - -function redirectError(response: CommandResponse): TypeError { - return new TypeError(`Redirects are not allowed (${response.statusCode})`) -} - -function isRedirect(status: number): boolean { - return [301, 302, 303, 307, 308].includes(status) -} -async function commandFetch(transport: CommandLoginTransport, input: Request | string | URL, init?: RequestInit): Promise { - const request = new Request(input, init) - if (request.redirect !== 'error') throw new TypeError('Credential manager requests require redirect: error') - const {signal} = request - const abortReason = () => signal?.reason instanceof Error ? signal.reason : new DOMException('The operation was aborted', 'AbortError') - if (signal?.aborted) throw abortReason() - const replayable = await replayableBody(request) - const options: ReplayableRequestOptions = { - body: replayable?.stream(), - [bodyFactory]: replayable?.stream, - headers: requestHeaders(request.headers), - method: request.method, - partial: true, - signal, - timeout: 0, + const headers: Record = {} + input.headers.forEach((value, key) => { + headers[key] = value + }) + const hasBody = input.method !== 'GET' && input.method !== 'HEAD' + const requestInit: UndiciRequestInit = { + body: hasBody ? await input.arrayBuffer() : undefined, + headers, + method: input.method, + redirect: input.redirect, + signal: input.signal, + // An explicit `init` wins over the Request's own fields, matching fetch semantics. + ...(init as UndiciRequestInit), } - try { - const response = await transport.request(request.url, options) - if (isRedirect(response.statusCode)) throw redirectError(response) - return new Response(responseBodyForStatus(response.statusCode, response.body), { - headers: responseHeaders(response.headers), - status: response.statusCode, - }) - } catch (error) { - if (signal?.aborted) throw abortReason() - if (error instanceof HTTPError) { - const response = error.http - if (isRedirect(response.statusCode)) throw redirectError(response) - return new Response(responseBodyForStatus(response.statusCode, response.body), { - headers: responseHeaders(response.headers), - status: response.statusCode, + return [input.url, requestInit] +} + +/** + * Creates a FetchLike backed by the platform's fetch, routed through an + * HTTP(S)_PROXY / NO_PROXY-aware undici dispatcher when a proxy env var is set. + * + * The credential-manager login module sets `redirect: 'error'` and its own + * AbortSignal on every request and owns its poll retry, so this adapter only + * layers proxy support onto fetch: fetch already honors `redirect: 'error'` and + * the abort signal, and custom CAs are honored through the standard + * NODE_EXTRA_CA_CERTS. + * + * When a dispatcher is present the request runs on the installed undici's own + * `fetch` rather than Node's built-in global `fetch`: `getDefaultDispatcher()` + * builds the `EnvHttpProxyAgent` from the installed undici, and Node's built-in + * fetch bundles a different undici whose dispatcher handler interface is + * incompatible with it (it throws `invalid onRequestStart method`). Pairing the + * dispatcher with the matching `fetch` keeps both on the same undici copy. + * + * TODO: this is a bridge. Once heroku-fetch exports a matched fetch+dispatcher + * pair from `@heroku/heroku-fetch/client/environment-defaults.js`, consume that + * instead and drop both the direct `undici` dependency and `toUndiciArgs` below. + */ +export function createCredentialManagerFetchAdapter(): FetchLike { + // Resolve the proxy dispatcher once per adapter: EnvHttpProxyAgent owns a + // connection pool, so a fresh one must not be built on every request. + const dispatcher = getDefaultDispatcher() + return async (input, init) => { + const resolved = await dispatcher + if (resolved) { + const [url, undiciInit] = await toUndiciArgs(input, init) + const response = await undiciFetch(url, { + ...undiciInit, + dispatcher: resolved as UndiciRequestInit['dispatcher'], }) + return response as unknown as Response } - throw error + // No proxy configured: Node's built-in fetch handles the request directly. + // eslint-disable-next-line n/no-unsupported-features/node-builtins -- Node 20+ provides fetch at runtime; the lint engine's compatibility table predates that support + return fetch(input, init) } } - -/** Creates a FetchLike backed by the declared command transport rather than global fetch. */ -export function createCredentialManagerFetchAdapter(transport: CommandLoginTransport = HTTP): FetchLike { - class RedirectRejectingTransport extends transport { - constructor(url: string, options?: HTTPRequestOptions) { - const hasExplicitAgent = Object.hasOwn(options ?? {}, 'agent') - const agent = hasExplicitAgent ? options?.agent : environmentAgent(url) - super(url, {...options, agent}) - // HTTP's constructor uses `options.agent || staleGlobalFallback`; overwrite that synchronous fallback even when - // the current environment intentionally derived a direct/undefined agent. No request starts in `super()`. - this.options.agent = agent - } - - async _redirect(): Promise { - throw redirectError(this) - } - - // @heroku/http-call has no supported retry-body or redirect policy option, so this narrow subclass hook refreshes - // only the buffered body before each transport attempt while retaining its proxy/CA/retry implementation. - async _request(): Promise { - const {signal} = this.options - if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new DOMException('The operation was aborted', 'AbortError') - const factory = (this.options as ReplayableRequestOptions)[bodyFactory] - if (factory) this.options.body = factory() - return super._request() - } - - async abortableRetryWait(delay: number): Promise { - const {signal} = this.options - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener('abort', abort) - resolve() - }, delay) - timer.unref() - const abort = () => { - clearTimeout(timer) - reject(signal?.reason instanceof Error ? signal.reason : new DOMException('The operation was aborted', 'AbortError')) - } - - if (signal?.aborted) abort() - else signal?.addEventListener('abort', abort, {once: true}) - }) - } - } - - // Preserve http-call 5.5.2's complete _maybeRetry policy while replacing only its private timer primitive. - // There is no public wait/retry policy hook; runtime dispatch still routes the parent method through this override. - Object.defineProperty(RedirectRejectingTransport.prototype, '_wait', { - async value(this: RedirectRejectingTransport, delay: number) { - return this.abortableRetryWait(delay) - }, - }) - - return async (input, init) => commandFetch(RedirectRejectingTransport, input, init) -} -/* eslint-enable n/no-unsupported-features/node-builtins, no-undef */ diff --git a/src/credential-manager.ts b/src/credential-manager.ts index 21398cfa..03ce33ec 100644 --- a/src/credential-manager.ts +++ b/src/credential-manager.ts @@ -7,7 +7,7 @@ import { getAuth as realGetAuth, removeAuth as realRemoveAuth, saveAuth as realSaveAuth, -} from './credential-manager-core/lib/credential-manager-adapter.js' +} from '@heroku/heroku-credential-manager' /** Backward-compatible shape used by command consumers before storage extraction. */ export type AuthEntry = { diff --git a/src/deprecated/credential-manager-core/credential-handlers/linux-handler.ts b/src/deprecated/credential-manager-core/credential-handlers/linux-handler.ts deleted file mode 100644 index 729e9dec..00000000 --- a/src/deprecated/credential-manager-core/credential-handlers/linux-handler.ts +++ /dev/null @@ -1,191 +0,0 @@ -import {Scrubber} from '@heroku/js-blanket' -import childProcess from 'node:child_process' - -import {KeychainAuthEntry} from '../lib/types.js' - -/** - * Handles credential storage, removal, and retrieval using the Linux Secret Service API. - * Uses the secret-tool command-line utility (part of libsecret) to interact with desktop keyrings. - */ -export class LinuxHandler { - private readonly scrubber = new Scrubber({ - patterns: [ - /account\s+"[^"]*"/g, // Scrub account value - ], - }) - - /** - * Retrieves the authentication token from the Linux keyring. - * @param account - The account login to use (e.g. 'test@example.com') - * @param service - The service name to use - * @returns The stored authentication token. - * @throws Error if the token is not found or retrieval fails. - */ - public getAuth(account: string, service: string): string { - try { - const output = childProcess.execSync( - `secret-tool lookup service "${service}" account "${account}"`, - {encoding: 'utf8'}, - ) - const token = output.trim() - - if (!token) { - throw new Error('Token not found') - } - - return token - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to retrieve token from Linux keyring: ${this.scrubError(message)}`) - } - } - - /** - * Lists all accounts stored in the Linux keyring for a given service. - * @param service - The service name to search for - * @returns Array of account names found for the service - * @throws Error if the search operation fails - */ - public listAccounts(service: string): string[] { - try { - const spawnResult = childProcess.spawnSync( - 'secret-tool', - ['search', '--all', 'service', service], - {encoding: 'utf8'}, - ) - - if (spawnResult.error) { - throw spawnResult.error - } - - if (spawnResult.status !== 0) { - const stderr = spawnResult.stderr || 'Unknown error' - throw new Error(stderr) - } - - /* - * Expected output format: - * stdout: label, secret, created, modified, schema lines - * stderr: attribute.service / attribute.account lines - */ - - const accounts: string[] = [] - const lines = (spawnResult.stderr ?? '').split('\n') - - for (const line of lines) { - const match = line.trim().match(/^attribute\.account\s*=\s*(.+)$/) - if (match) { - const account = match[1].trim() - if (account) { - accounts.push(account) - } - } - } - - return accounts - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to list accounts in Linux keyring: ${this.scrubError(message)}`) - } - } - - /** - * Removes the authentication token from the Linux keyring. - * @param account - The account login to use (e.g. 'test@example.com') - * @param service - The service name to use - * @returns void - * @throws Error if the removal operation fails. - */ - public removeAuth(account: string, service: string): void { - const spawnResult = childProcess.spawnSync( - 'secret-tool', - ['clear', 'service', service, 'account', account], - {encoding: 'utf8', env: {...process.env, LC_ALL: 'C'}}, - ) - - if (spawnResult.error) { - const {message} = spawnResult.error - throw new Error(`Failed to remove token from Linux keyring: ${this.scrubError(message)}`) - } - - if (spawnResult.status === 0) { - return - } - - const status = spawnResult.status ?? -1 - - const stderr = (spawnResult.stderr ?? '').toString() - if (this.isMissingSecretClearFailure(status, stderr)) { - return - } - - throw new Error(`Failed to remove token from Linux keyring: ${this.scrubError(stderr || `exit ${status}`)}`) - } - - /** - * Saves an authentication entry to the Linux keyring. - * If a credential with the same attributes already exists, it is updated with the new token. - * @param auth - The authentication entry containing account and token information to store. - * @returns void - * @throws Error if the save operation fails. - */ - public saveAuth(auth: KeychainAuthEntry): void { - try { - const spawnResult = childProcess.spawnSync( - 'secret-tool', - [ - 'store', - '--label=Heroku CLI', - 'service', - auth.service, - 'account', - auth.account, - ], - { - encoding: 'utf8', - input: auth.token, - }, - ) - - if (spawnResult.error) { - throw spawnResult.error - } - - if (spawnResult.status !== 0) { - const stderr = spawnResult.stderr || 'Unknown error' - throw new Error(stderr) - } - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to store token in Linux keyring: ${this.scrubError(message)}`) - } - } - - /** - * secret-tool clear fails when no matching credential exists; treat as successful no-op for logout. - */ - private isMissingSecretClearFailure(status: number, stderr: string): boolean { - if (status === 0) { - return false - } - - // secret-tool clear exits 1 with no output when nothing matched (locale-independent). - if (status === 1 && stderr.trim() === '') { - return true - } - - const text = stderr.toLowerCase() - return /no matching|could not find|not found|unknown attribute|does not exist|cannot remove/i.test(text) - } - - /** - * Scrubs account names and passwords/tokens from error messages. - * - * @param message - The error message to scrub - * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" - */ - private scrubError(message: string): string { - const result = this.scrubber.scrub({message}) - return result.data.message - } -} diff --git a/src/deprecated/credential-manager-core/credential-handlers/macos-handler.ts b/src/deprecated/credential-manager-core/credential-handlers/macos-handler.ts deleted file mode 100644 index 0565669f..00000000 --- a/src/deprecated/credential-manager-core/credential-handlers/macos-handler.ts +++ /dev/null @@ -1,160 +0,0 @@ -import {Scrubber} from '@heroku/js-blanket' -import childProcess from 'node:child_process' - -import {KeychainAuthEntry} from '../lib/types.js' - -/** - * Handles credential storage, removal, and retrieval using the macOS Keychain. - * Uses the macOS security command-line tool to interact with the Keychain. - */ -export class MacOSHandler { - private readonly scrubber = new Scrubber({ - patterns: [ - /-a\s+"[^"]*"/g, // Scrub account (-a flag) - /-w\s+"[^"]*"/g, // Scrub password/token (-w flag) - ], - }) - - /** - * Retrieves the authentication token from macOS Keychain. - * @param account - The account login to use (e.g. 'test@example.com') - * @param service - The service name to use - * @returns The stored authentication token. - * @throws Error if the token is not found or retrieval fails. - */ - public getAuth(account: string, service: string): string { - try { - const output = childProcess.execSync( - `security find-generic-password -a "${account}" -s "${service}" -w`, - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }, - ) - const token = output.trim() - - if (!token) { - throw new Error('Token not found') - } - - return token - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to retrieve token from macOS Keychain: ${this.scrubError(message)}`) - } - } - - /** - * Lists all accounts stored in macOS Keychain for a given service. - * @param service - The service name to search for - * @returns Array of account names found for the service - * @throws Error if the search operation fails - */ - public listAccounts(service: string): string[] { - try { - const output = childProcess.execSync( - 'security dump-keychain', - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }, - ) - - // Expected output format: - // keychain: "/path/to/keychain" - // version: 512 - // class: "genp" - // attributes: - // 0x00000007 ="service-name" - // "acct"="account-name" - // "svce"="service-name" - // ... - - const accounts: string[] = [] - - // Split by keychain entry boundaries - const entries = output.split(/^keychain:/m) - - for (const entry of entries) { - // Only process generic password entries - if (!entry.includes('class: "genp"')) continue - - // Extract service name - const serviceMatch = entry.match(/"svce"="([^"]+)"/) - if (!serviceMatch || serviceMatch[1] !== service) continue - - // Extract account name - const accountMatch = entry.match(/"acct"="([^"]+)"/) - if (accountMatch) { - accounts.push(accountMatch[1]) - } - } - - return accounts - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to list accounts in macOS Keychain: ${this.scrubError(message)}`) - } - } - - /** - * Removes the authentication token from macOS Keychain. - * @param account - The account login to use (e.g. 'test@example.com') - * @param service - The service name to use - * @returns void - * @throws Error if the removal operation fails. - */ - public removeAuth(account: string, service: string): void { - try { - childProcess.execSync( - `security delete-generic-password -a "${account}" -s "${service}"`, - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }, - ) - } catch (error) { - const execError = error as Error & {status?: number} - // security exits 44 when the generic password does not exist (e.g. netrc-only login) - if (execError.status === 44) { - return - } - - const {message} = error as Error - throw new Error(`Failed to remove token from macOS Keychain: ${this.scrubError(message)}`) - } - } - - /** - * Saves an authentication entry to macOS Keychain. - * If a credential with the same name already exists, it is updated with the new token. - * @param auth - The authentication entry containing account and token information to store. - * @returns void - * @throws Error if the save operation fails. - */ - public saveAuth(auth: KeychainAuthEntry): void { - try { - childProcess.execSync( - `security add-generic-password -U -a "${auth.account}" -s "${auth.service}" -w "${auth.token}"`, - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }, - ) - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to store token in macOS Keychain: ${this.scrubError(message)}`) - } - } - - /** - * Scrubs account names and passwords/tokens from error messages. - * - * @param message - The error message to scrub - * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" - */ - private scrubError(message: string): string { - const result = this.scrubber.scrub({message}) - return result.data.message - } -} diff --git a/src/deprecated/credential-manager-core/credential-handlers/netrc-handler.ts b/src/deprecated/credential-manager-core/credential-handlers/netrc-handler.ts deleted file mode 100644 index 4b7a642d..00000000 --- a/src/deprecated/credential-manager-core/credential-handlers/netrc-handler.ts +++ /dev/null @@ -1,100 +0,0 @@ -import debug from 'debug' - -import {Netrc} from '../lib/netrc-parser.js' -import {NetrcAuthEntry} from '../lib/types.js' - -const credDebug = debug('heroku-credential-manager') - -export class NetrcHandler { - public readonly netrc: Netrc - - /** @param file - Optional netrc path; otherwise uses the default location. */ - constructor(file?: string) { - this.netrc = new Netrc(file) - } - - /** - * Retrieves authentication credentials for a given host. - * @param host - The hostname to retrieve credentials for. - * @returns The authentication entry for the host, or undefined if not found. - */ - public async getAuth(host: string) { - await this.netrc.load() - const auth = this.netrc.machines[host] - if (!auth) { - throw new Error(`No auth found for ${host}`) - } - - return auth - } - - /** - * Removes authentication credentials for a given host. - * @param host - The hostname to remove credentials for. - * @returns A promise that resolves when the credentials are removed. - */ - public async removeAuth(host: string) { - await this.removeAuthForHosts([host]) - } - - /** - * Removes credentials for multiple hosts with a single netrc load/save. - */ - public async removeAuthForHosts(hosts: string[]) { - if (hosts.length === 0) return - await this.netrc.load() - let changed = false - for (const host of hosts) { - if (!this.netrc.machines[host]) { - credDebug(`No credentials to logout for ${host}`) - continue - } - - delete this.netrc.machines[host] - changed = true - } - - if (changed) await this.netrc.save() - } - - /** - * Saves authentication credentials for a given host. - * @param auth - The authentication entry containing login and password. - * @param host - The hostname to save credentials for. - * @returns A promise that resolves when the credentials are saved. - */ - public async saveAuth(auth: NetrcAuthEntry, host: string) { - await this.saveAuthForHosts(auth, [host]) - } - - /** - * Saves the same credentials for multiple hosts with a single netrc load/save. - */ - public async saveAuthForHosts(auth: NetrcAuthEntry, hosts: string[]) { - if (hosts.length === 0) return - await this.netrc.load() - for (const host of hosts) { - this.applyAuthToHost(auth, host) - } - - await this.netrc.save() - } - - private applyAuthToHost(auth: NetrcAuthEntry, host: string) { - if (!this.netrc.machines[host]) this.netrc.machines[host] = {} - this.netrc.machines[host] = { - login: auth.login, - password: auth.password, - } - delete this.netrc.machines[host].method - delete this.netrc.machines[host].org - - if (this.netrc.machines._tokens) { - for (const token of this.netrc.machines._tokens) { - if (token.type === 'machine' && host === token.host) { - token.internalWhitespace = '\n ' - } - } - } - } -} diff --git a/src/deprecated/credential-manager-core/credential-handlers/windows-handler.ts b/src/deprecated/credential-manager-core/credential-handlers/windows-handler.ts deleted file mode 100644 index 43120bc3..00000000 --- a/src/deprecated/credential-manager-core/credential-handlers/windows-handler.ts +++ /dev/null @@ -1,172 +0,0 @@ -import {Scrubber} from '@heroku/js-blanket' -import childProcess from 'node:child_process' - -import {KeychainAuthEntry} from '../lib/types.js' - -/** - * Handles credential storage and retrieval using the Windows Credential Manager. - * Uses PowerShell commands to interact with the Windows.Security.Credentials.PasswordVault API. - */ -export class WindowsHandler { - private readonly scrubber = new Scrubber({ - patterns: [ - /Retrieve\("([^"]+)",\s*"([^"]+)"\)/g, // Scrub account in Retrieve("service", "account") - /PasswordCredential\("([^"]+)",\s*"([^"]+)",\s*"([^"]+)"\)/g, // Scrub account and token in PasswordCredential - ], - }) - - /** - * Retrieves the authentication token from Windows Credential Manager. - * @param account - The account login to use (e.g. 'test@example.com') - * @param service - The service name to use - * @returns The stored authentication token. - * @throws Error if the token is not found or retrieval fails. - */ - public getAuth(account: string, service: string): string { - try { - const psCommand = ` - $ErrorActionPreference = 'Stop' - [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = $vault.Retrieve("${service}", "${account}") - $credential.Password - ` - - const output = childProcess.execSync(psCommand, {encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore']}) - const token = output.trim() - - if (!token) { - throw new Error('Token not found') - } - - return token - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to retrieve token from Windows Credential Manager: ${this.scrubError(message)}`) - } - } - - /** - * Lists all accounts stored in Windows Credential Manager for a given service. - * @param service - The service name to search for - * @returns Array of account names found for the service - * @throws Error if the search operation fails - */ - public listAccounts(service: string): string[] { - try { - const psCommand = ` - $ErrorActionPreference = 'Stop' - [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - try { - $creds = $vault.FindAllByResource("${service}") - $creds | ForEach-Object { $_.UserName } - } catch { - # No credentials found for this resource - exit 0 - } - ` - - const output = childProcess.execSync(psCommand, {encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore']}) - - // Expected output format: - // user1@example.com - // user2@example.com - // ... - - const accounts = output - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - - return accounts - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to list accounts in Windows Credential Manager: ${this.scrubError(message)}`) - } - } - - /** - * Removes the authentication token from Windows Credential Manager. - * @param account - The account login to use (e.g. 'test@example.com') - * @param service - The service name to use - * @returns void - * @throws Error if the removal operation fails. - */ - public removeAuth(account: string, service: string): void { - try { - const psCommand = ` - $ErrorActionPreference = 'Stop' - [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = $vault.Retrieve("${service}", "${account}") - $vault.Remove($credential) - ` - childProcess.execSync(psCommand, {encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore']}) - } catch (error) { - const {message} = error as Error - if (this.isMissingVaultCredential(message)) { - return - } - - throw new Error(`Failed to remove token from Windows Credential Manager: ${this.scrubError(message)}`) - } - } - - /** - * Saves an authentication entry to Windows Credential Manager. - * If a credential with the same name already exists, it is removed before saving the new one. - * @param auth - The authentication entry containing account and token information to store. - * @returns void - * @throws Error if the save operation fails. - */ - public saveAuth(auth: KeychainAuthEntry): void { - try { - try { - const removeCommand = ` - $ErrorActionPreference = 'Stop' - [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = $vault.Retrieve("${auth.service}", "${auth.account}") - $vault.Remove($credential) - ` - childProcess.execSync(removeCommand, {encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore']}) - } catch { - // noop - item does not exist - } - - const addCommand = ` - $ErrorActionPreference = 'Stop' - [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] - $vault = New-Object Windows.Security.Credentials.PasswordVault - $credential = New-Object Windows.Security.Credentials.PasswordCredential("${auth.service}", "${auth.account}", "${auth.token}") - $vault.Add($credential) - ` - childProcess.execSync(addCommand, {encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore']}) - } catch (error) { - const {message} = error as Error - throw new Error(`Failed to store token in Windows Credential Manager: ${this.scrubError(message)}`) - } - } - - /** - * PasswordVault.Retrieve throws when the credential is absent (e.g. netrc-only login). - */ - private isMissingVaultCredential(message: string): boolean { - const lower = message.toLowerCase() - return lower.includes('element not found') - || lower.includes('specified credential could not be found') - || lower.includes('0x80070490') - } - - /** - * Scrubs account names and passwords/tokens from error messages. - * - * @param message - The error message to scrub - * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" - */ - private scrubError(message: string): string { - const result = this.scrubber.scrub({message}) - return result.data.message - } -} diff --git a/src/deprecated/credential-manager-core/index.ts b/src/deprecated/credential-manager-core/index.ts deleted file mode 100644 index 4314ff28..00000000 --- a/src/deprecated/credential-manager-core/index.ts +++ /dev/null @@ -1,196 +0,0 @@ -import debug from 'debug' - -import {LinuxHandler} from './credential-handlers/linux-handler.js' -import {MacOSHandler} from './credential-handlers/macos-handler.js' -import {NetrcHandler} from './credential-handlers/netrc-handler.js' -import {WindowsHandler} from './credential-handlers/windows-handler.js' -import {reportCredentialStoreError} from './lib/cli-command-telemetry.js' -import {CredentialStore, getNativeCredentialStore, getStorageConfig} from './lib/credential-storage-selector.js' -import {AuthEntry, NetrcAuthEntry} from './lib/types.js' - -const credDebug = debug('heroku-credential-manager') - -const SERVICE_NAME = 'heroku-cli' - -/** - * Saves authentication credentials to the native credential store (if available) or .netrc file. - * - * @param account - User's account (email) - * @param token - Authentication token - * @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com']) - * @param service - Service name (defaults to 'heroku-cli') - * @returns Promise that resolves when credentials are saved - */ -export async function saveAuth(account: string, token: string, hosts: string[], service = SERVICE_NAME): Promise { - const config = getStorageConfig() - const netrcHandler = new NetrcHandler() - let keychainSuccess = false - - if (config.credentialStore) { - try { - const handler = getCredentialHandler(config.credentialStore) - handler.saveAuth({account, service, token}) - keychainSuccess = true - } catch (error) { - const {message} = error as Error - credDebug(message) - - await reportCredentialStoreError(error, { - credentialStore: config.credentialStore, - operation: 'saveAuth', - }) - } - } - - const shouldUseNetrc = config.useNetrc || !keychainSuccess - if (shouldUseNetrc && hosts.length > 0) { - const netrcAuth: NetrcAuthEntry = { - login: account, - password: token, - } - await netrcHandler.saveAuthForHosts(netrcAuth, hosts) - } -} - -/** - * Retrieves authentication credentials from the native credential store (if available) or .netrc file. - * - * @param account - User's account (email), or undefined to search for account - * @param host - Hostname for netrc lookup (e.g., 'api.heroku.com') - * @param service - Service name (defaults to 'heroku-cli') - * @returns Promise that resolves with the authentication account and token. - * @throws Error if no credentials are found in either location. - */ -export async function getAuth(account: string | undefined, host: string, service = SERVICE_NAME): Promise { - const config = getStorageConfig() - const netrcHandler = new NetrcHandler() - let keychainSuccess = false - - if (config.credentialStore && account) { - try { - const handler = getCredentialHandler(config.credentialStore) - const token = handler.getAuth(account, service) - keychainSuccess = true - return {account, token} - } catch (error) { - const {message} = error as Error - credDebug(message) - - await reportCredentialStoreError(error, { - credentialStore: config.credentialStore, - operation: 'getAuth', - }) - } - } - - const shouldUseNetrc = config.useNetrc || !keychainSuccess - if (shouldUseNetrc) { - const auth = await netrcHandler.getAuth(host) - - if (auth.password) { - return {account: auth.login, token: auth.password} - } - } - - throw new Error('No auth found') -} - -/** - * Lists all accounts stored in the native credential store for a given service. - * - * @param service - Service name (defaults to 'heroku-cli') - * @returns Array of account names, or empty array if no native credential store is available - */ -export async function listKeychainAccounts(service = SERVICE_NAME): Promise { - const config = getStorageConfig() - - if (config.credentialStore) { - try { - const handler = getCredentialHandler(config.credentialStore) - return handler.listAccounts(service) - } catch (error) { - const {message} = error as Error - credDebug(message) - - await reportCredentialStoreError(error, { - credentialStore: config.credentialStore, - operation: 'listKeychainAccounts', - }) - - return [] - } - } - - return [] -} - -/** - * Removes authentication credentials from the platform native store (when present) and .netrc. - * Always attempts to remove from both stores to remove stale tokens when users switch between modes. - * - * @param account - User's account (email), or undefined when using HEROKU_API_KEY only (native removal is skipped) - * @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com']) - * @param service - Service name (defaults to 'heroku-cli') - * @returns Promise that resolves when credentials are removed - */ -export async function removeAuth(account: string | undefined, hosts: string[], service = SERVICE_NAME): Promise { - const netrcHandler = new NetrcHandler() - const nativeStore = getNativeCredentialStore() - - if (nativeStore && account) { - try { - const handler = getCredentialHandler(nativeStore) - handler.removeAuth(account, service) - } catch (error) { - const {message} = error as Error - credDebug(message) - - await reportCredentialStoreError(error, { - credentialStore: nativeStore, - operation: 'removeAuth', - }) - } - } - - if (hosts.length > 0) { - await netrcHandler.removeAuthForHosts(hosts) - } -} - -/** - * Factory function to create the appropriate credential handler based on platform. - * @private - * @param store - The type of credential store to use - * @returns A handler instance for the specified store - */ -export function getCredentialHandler(store: CredentialStore) { - switch (store) { - case CredentialStore.LinuxSecretService: { - return new LinuxHandler() - } - - case CredentialStore.MacOSKeychain: { - return new MacOSHandler() - } - - case CredentialStore.WindowsCredentialManager: { - return new WindowsHandler() - } - } -} - -export {LinuxHandler} from './credential-handlers/linux-handler.js' -export {MacOSHandler} from './credential-handlers/macos-handler.js' -export {NetrcHandler} from './credential-handlers/netrc-handler.js' -export {WindowsHandler} from './credential-handlers/windows-handler.js' -export {CredentialStore, getNativeCredentialStore, getStorageConfig} from './lib/credential-storage-selector.js' -export type {StorageConfig} from './lib/credential-storage-selector.js' -export {deleteLoginState, readLoginState, writeLoginState} from './lib/login-state.js' -export {Netrc, parse} from './lib/netrc-parser.js' -export type { - Machines, - MachinesWithTokens, - MachineToken, - Token, -} from './lib/netrc-parser.js' -export type {AuthEntry, KeychainAuthEntry, NetrcAuthEntry} from './lib/types.js' diff --git a/src/deprecated/credential-manager-core/lib/cli-command-telemetry.ts b/src/deprecated/credential-manager-core/lib/cli-command-telemetry.ts deleted file mode 100644 index 148c1018..00000000 --- a/src/deprecated/credential-manager-core/lib/cli-command-telemetry.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type {ErrorEvent} from '@sentry/node' - -import { - GDPR_FIELDS, - HEROKU_FIELDS, - PCI_FIELDS, - PII_PATTERNS, - Scrubber, -} from '@heroku/js-blanket' -import * as Sentry from '@sentry/node' -import {readFileSync} from 'node:fs' -import {dirname, join} from 'node:path' -import {fileURLToPath} from 'node:url' - -import type {CredentialStore} from './credential-storage-selector.js' - -const DSN - = 'https://4eb3812769d649a09ae76ef3fcd03dbb@o4508609692368896.ingest.us.sentry.io/4511095245832192' - -const scrubber = new Scrubber({ - fields: [...HEROKU_FIELDS, ...GDPR_FIELDS, ...PCI_FIELDS], - patterns: [...PII_PATTERNS], -}) - -/** Indirection so tests can `sinon.stub` without stubbing the ESM `@sentry/node` namespace. */ -export const credentialSentrySdk = { - captureException: Sentry.captureException.bind(Sentry), - flush: Sentry.flush.bind(Sentry), - getClient: Sentry.getClient.bind(Sentry), - init: Sentry.init.bind(Sentry), -} - -let releaseCache: string | undefined -let sentryClient: ReturnType | undefined - -function readPackageVersion(): string { - if (releaseCache !== undefined) { - return releaseCache - } - - const dir = dirname(fileURLToPath(import.meta.url)) - const pkgPath = join(dir, '../../../../package.json') - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {version?: string} - releaseCache = pkg.version ?? 'unknown' - return releaseCache -} - -export function shouldReportCredentialErrorsToSentry(): boolean { - if (process.env.CI === 'true') { - return false - } - - if (process.env.NODE_ENV === 'test') { - return false - } - - if (process.env.IS_HEROKU_TEST_ENV === 'true') { - return false - } - - if (process.env.DISABLE_TELEMETRY === 'true') { - return false - } - - return true -} - -function ensureCredentialSentryInitialized(): void { - if (!shouldReportCredentialErrorsToSentry()) { - return - } - - if (sentryClient || credentialSentrySdk.getClient()) { - return - } - - const isDev = process.env.IS_DEV_ENVIRONMENT === 'true' - - sentryClient = credentialSentrySdk.init({ - beforeSend(event) { - const scrubbed - = scrubber.scrub(event as unknown as Record).data - return scrubbed as unknown as ErrorEvent - }, - dsn: DSN, - environment: isDev ? 'development' : 'production', - release: `@heroku-cli/command@${readPackageVersion()}`, - skipOpenTelemetrySetup: true, - }) -} - -export type CredentialSentryOperation = 'getAuth' | 'listKeychainAccounts' | 'removeAuth' | 'saveAuth' - -export async function reportCredentialStoreError( - error: unknown, - context: {credentialStore: CredentialStore; operation: CredentialSentryOperation}, -): Promise { - if (!shouldReportCredentialErrorsToSentry()) { - return - } - - try { - ensureCredentialSentryInitialized() - credentialSentrySdk.captureException(error, { - tags: { - component: 'heroku-cli-command', - credential_operation: context.operation, - credential_store: context.credentialStore, - }, - }) - await credentialSentrySdk.flush(2000) - await credentialSentrySdk.getClient()?.close() - // Clear the cached client so it can be re-initialized on the next error - sentryClient = undefined - } catch { - // avoid impacting credential flows if Sentry fails - } -} diff --git a/src/deprecated/credential-manager-core/lib/credential-storage-selector.ts b/src/deprecated/credential-manager-core/lib/credential-storage-selector.ts deleted file mode 100644 index 4ddefda0..00000000 --- a/src/deprecated/credential-manager-core/lib/credential-storage-selector.ts +++ /dev/null @@ -1,93 +0,0 @@ -import childProcess from 'node:child_process' - -export const CredentialStore = { - LinuxSecretService: 'linux-secret-service', - MacOSKeychain: 'macos-keychain', - WindowsCredentialManager: 'windows-credential-manager', -} as const - -export type CredentialStore = typeof CredentialStore[keyof typeof CredentialStore] - -export type StorageConfig = { - credentialStore: CredentialStore | null - useNetrc: boolean -} - -/** - * Determines whether the secret-tool command is accessible. - * - * @returns True if secret-tool is installed and accessible, false otherwise - */ -function hasSecretTool(): boolean { - try { - childProcess.execSync('which secret-tool', { - stdio: 'ignore', - }) - return true - } catch { - return false - } -} - -/** - * Native credential backend for this platform (Keychain, Secret Service, Windows vault). - * Ignores HEROKU_NETRC_WRITE so logout can clear credentials written before that mode was used. - */ -export function getNativeCredentialStore(): CredentialStore | null { - const {platform} = process - - switch (platform) { - case 'darwin': { - return CredentialStore.MacOSKeychain - } - - case 'linux': { - return hasSecretTool() ? CredentialStore.LinuxSecretService : null - } - - case 'win32': { - return CredentialStore.WindowsCredentialManager - } - - default: { - return null - } - } -} - -/** - * Determines whether to use OS-native credential storage or .netrc file. - * - * By default, uses keychain exclusively (when available). - * `HEROKU_NETRC_WRITE=true` selects netrc-only mode, skipping keychain entirely. - * - * @returns Object containing storage configuration - * - * @example - * ```typescript - * const config = getStorageConfig() - * if (config.credentialStore === CredentialStore.MacOSKeychain) { - * // Use macOS handler - * } - * if (config.useNetrc) { - * // Use netrc handler (explicit override mode) - * } - * ``` - */ -export function getStorageConfig(): StorageConfig { - const netrcWriteLegacy = process.env.HEROKU_NETRC_WRITE?.toLowerCase() === 'true' - - if (netrcWriteLegacy) { - return { - credentialStore: null, - useNetrc: true, - } - } - - const nativeCredentialStore = getNativeCredentialStore() - - return { - credentialStore: nativeCredentialStore, - useNetrc: !nativeCredentialStore, - } -} diff --git a/src/deprecated/credential-manager-core/lib/login-state.ts b/src/deprecated/credential-manager-core/lib/login-state.ts deleted file mode 100644 index 84d02cbb..00000000 --- a/src/deprecated/credential-manager-core/lib/login-state.ts +++ /dev/null @@ -1,49 +0,0 @@ -import debug from 'debug' -import * as fs from 'node:fs' -import {join} from 'node:path' - -const credDebug = debug('heroku-credential-manager') - -const LOGIN_STATE_FILE = 'login.json' - -type LoginState = { - account: string -} - -export async function readLoginState(dataDir: string): Promise { - const filePath = join(dataDir, LOGIN_STATE_FILE) - - try { - const content = await fs.promises.readFile(filePath, 'utf8') - const parsed = JSON.parse(content) - - if (typeof parsed?.account === 'string' && parsed.account.length > 0) { - return {account: parsed.account} - } - - credDebug('login state file missing valid account field: %s', filePath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - credDebug('failed to read login state file: %s', (error as Error).message) - } - } -} - -export async function writeLoginState(dataDir: string, account: string): Promise { - const filePath = join(dataDir, LOGIN_STATE_FILE) - - await fs.promises.mkdir(dataDir, {mode: 0o700, recursive: true}) - await fs.promises.writeFile(filePath, JSON.stringify({account}) + '\n', {encoding: 'utf8', mode: 0o600}) -} - -export async function deleteLoginState(dataDir: string): Promise { - const filePath = join(dataDir, LOGIN_STATE_FILE) - - try { - await fs.promises.unlink(filePath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - credDebug('failed to delete login state file: %s', (error as Error).message) - } - } -} diff --git a/src/deprecated/credential-manager-core/lib/netrc-parser.ts b/src/deprecated/credential-manager-core/lib/netrc-parser.ts deleted file mode 100644 index 81b53345..00000000 --- a/src/deprecated/credential-manager-core/lib/netrc-parser.ts +++ /dev/null @@ -1,397 +0,0 @@ -import debug from 'debug' -import {execa, execaSync} from 'execa' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -export type Token = MachineToken | {content: string, type: 'other'} -export type MachineToken = { - comment?: string - host: string - internalWhitespace: string - pre?: string - props: {[key: string]: {comment?: string, value: string}} - type: 'machine' -} - -export type Machines = { - [key: string]: { - [key: string]: string | undefined - account?: string - login?: string - password?: string - } -} - -export type MachinesWithTokens = Machines & { - _tokens?: Token[] -} - -const credDebug = debug('heroku-credential-manager') - -/** - * Creates ES6 proxy objects from parsed tokens to allow easy modification by consumers. - * This is somewhat complicated, but it takes the array of parsed tokens from parse() - * and wraps them in proxies that intercept get/set/delete operations. - * @param tokens - Array of parsed tokens from the netrc file - * @returns A proxied MachinesWithTokens object that allows direct property access and modification - */ -function proxify(tokens: Token[]): MachinesWithTokens { - const proxifyProps = (t: MachineToken) => new Proxy(t.props as unknown as {[key: string]: string}, { - get(_, key: string) { - if (key === 'host') return t.host - if (typeof key !== 'string') return t.props[key] - const prop = t.props[key] - if (!prop) return - return prop.value - }, - set(_, key: string, value: string) { - if (key === 'host') { - t.host = value - } else if (value) { - t.props[key] = t.props[key] || (t.props[key] = {value: ''}) - t.props[key].value = value - } else { - delete t.props[key] - } - - return true - }, - }) - const machineTokens = tokens.filter((m): m is MachineToken => m.type === 'machine') - const machines = machineTokens.map(t => proxifyProps(t)) - const getWhitespace = () => { - if (machineTokens.length === 0) return ' ' - return machineTokens.at(-1)!.internalWhitespace - } - - const obj: MachinesWithTokens = {} - obj._tokens = tokens - for (const m of machines) obj[m.host] = m - return new Proxy(obj, { - deleteProperty(obj, host: string) { - delete obj[host] - const idx = tokens.findIndex(m => m.type === 'machine' && m.host === host) - if (idx === -1) return true - tokens.splice(idx, 1) - return true - }, - ownKeys() { - return machines.map(m => m.host) - }, - set(obj, host: string, props: {[key: string]: string}) { - if (!props) { - delete obj[host] - const idx = tokens.findIndex(m => m.type === 'machine' && m.host === host) - if (idx === -1) return true - tokens.splice(idx, 1) - return true - } - - let machine = machines.find(m => m.host === host) - if (!machine) { - const token: MachineToken = { - host, internalWhitespace: getWhitespace(), props: {}, type: 'machine', - } - tokens.push(token) - machine = proxifyProps(token) - machines.push(machine) - obj[host] = machine - } - - for (const [k, v] of Object.entries(props)) { - machine[k] = v - } - - return true - }, - }) -} - -/** - * Parses a netrc file body into a structured MachinesWithTokens object. - * Handles both inline and multiline machine definitions, including comments. - * @param body - The raw string content of a netrc file - * @returns A proxied MachinesWithTokens object containing all parsed machine entries - */ -export function parse(body: string): MachinesWithTokens { - const lines = body.split('\n') - let pre: string[] = [] - const machines: MachineToken[] = [] - while (lines.length > 0) { - const line = lines.shift()! - const match = line.match(/machine\s+((?:[^\s#]+\s*)+)(#.*)?$/) - if (!match) { - pre.push(line) - continue - } - - const [, body, comment] = match - const machine: MachineToken = { - comment, - host: body.split(' ')[0], - internalWhitespace: '\n ', - pre: pre.join('\n'), - props: {}, - type: 'machine', - } - pre = [] - // do not read other machines with same host - if (!machines.some(m => m.type === 'machine' && m.host === machine.host)) machines.push(machine) - if (body.trim().includes(' ')) { // inline machine - const [host, ...propStrings] = body.split(' ') - for (let a = 0; a < propStrings.length; a += 2) { - machine.props[propStrings[a]] = {value: propStrings[a + 1]} - } - - machine.host = host - machine.internalWhitespace = ' ' - } else { // multiline machine - while (lines.length > 0) { - const line = lines.shift()! - const match = line.match(/^(\s+)(\S+)\s+(\S+)(\s+#.*)?$/) - if (!match) { - lines.unshift(line) - break - } - - const [, ws, key, value, comment] = match - machine.props[key] = {comment, value} - machine.internalWhitespace = `\n${ws}` - } - } - } - - return proxify([...machines, {content: pre.join('\n'), type: 'other'}]) -} - -export class Netrc { - file: string - machines!: MachinesWithTokens - - /** - * Creates a new Netrc instance. - * @param file - Optional path to the netrc file. If not provided, uses the default location. - */ - constructor(file?: string) { - this.file = file || this.defaultFile - } - - /** - * Gets the default netrc file path based on the operating system. - * Checks for GPG-encrypted version first. - * @returns The path to the default netrc file - */ - private get defaultFile(): string { - let home: string | undefined - if (os.platform() === 'win32') { - const fromDrive - = process.env.HOMEDRIVE && process.env.HOMEPATH - ? path.join(process.env.HOMEDRIVE, process.env.HOMEPATH) - : undefined - home = process.env.HOME || fromDrive || process.env.USERPROFILE - } - - const resolved = home || os.homedir() || os.tmpdir() - const file = path.join( - resolved, - os.platform() === 'win32' ? '_netrc' : '.netrc', - ) - const gpgFile = `${file}.gpg` - return fs.existsSync(gpgFile) ? gpgFile : file - } - - /** - * Gets the GPG command arguments for decrypting the netrc file. - * @returns Array of GPG command-line arguments for decryption - */ - private get gpgDecryptArgs() { - const args = ['--batch', '--quiet', '--decrypt', this.file] - credDebug('running gpg with args %o', args) - return args - } - - /** - * Gets the GPG command arguments for encrypting the netrc file. - * @returns Array of GPG command-line arguments for encryption - */ - private get gpgEncryptArgs() { - const args = ['-a', '--batch', '--default-recipient-self', '-e'] - credDebug('running gpg with args %o', args) - return args - } - - /** - * Generates the string representation of all machines for writing to file. - * @returns The formatted netrc file content as a string - */ - private get output(): string { - const output: string[] = [] - if (this.machines._tokens) { - for (const t of this.machines._tokens as Token[]) { - if (t.type === 'other') { - output.push(t.content) - continue - } - - if (t.pre) { - output.push(t.pre + '\n') - } - - output.push(`machine ${t.host}`) - - if (t.internalWhitespace.includes('\n')) { - this.addCommentToOutput(t, output) - this.addPropsToOutput(t, output) - output.push('\n') - } else { - this.addPropsToOutput(t, output) - this.addCommentToOutput(t, output) - output.push('\n') - } - } - } - - return output.join('') - } - - /** - * Asynchronously loads and parses the netrc file. - * Handles both plain text and GPG-encrypted files. - * @returns A promise that resolves when loading is complete, or throws on error - */ - async load(): Promise { - try { - credDebug('load', this.file) - const decryptFile = async (): Promise => { - const {exitCode, stdout} = await execa('gpg', this.gpgDecryptArgs, {reject: false, stdio: ['inherit', 'pipe', 'inherit']}) - if (exitCode !== 0) throw new Error(`gpg exited with code ${exitCode}`) - return stdout - } - - const body = await (path.extname(this.file) === '.gpg' - ? decryptFile() - : new Promise((resolve, reject) => { - fs.readFile(this.file, {encoding: 'utf8'}, (err, data) => { - if (err && err.code !== 'ENOENT') reject(err) - debug('ENOENT') - resolve(data || '') - }) - })) - this.machines = parse(body) - credDebug('machines: %o', Object.keys(this.machines)) - } catch (error) { - return this.throw(error) - } - } - - /** - * Synchronously loads and parses the netrc file. - * Handles both plain text and GPG-encrypted files. - * @returns void, or throws on error - */ - loadSync(): never | void { - try { - credDebug('loadSync', this.file) - const decryptFile = (): string => { - const {exitCode, stdout} = execaSync('gpg', this.gpgDecryptArgs, {reject: false, stdio: ['inherit', 'pipe', 'inherit']}) - if (exitCode !== 0) throw new Error(`gpg exited with code ${exitCode}`) - return stdout - } - - let body = '' - if (path.extname(this.file) === '.gpg') { - body = decryptFile() - } else { - try { - body = fs.readFileSync(this.file, 'utf8') - } catch (error: unknown) { - if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') throw error - } - } - - this.machines = parse(body) - credDebug('machines: %o', Object.keys(this.machines)) - } catch (error) { - return this.throw(error) - } - } - - /** - * Asynchronously saves the current machines to the netrc file. - * Handles GPG encryption if the file has a .gpg extension. - * @returns A promise that resolves when saving is complete - */ - async save() { - credDebug('save', this.file) - let body = this.output - if (this.file.endsWith('.gpg')) { - const {exitCode, stdout} = await execa('gpg', this.gpgEncryptArgs, {input: body, reject: false, stdio: ['pipe', 'pipe', 'inherit']}) - if (exitCode !== 0) throw new Error(`gpg exited with code ${exitCode}`) - body = stdout - } - - return new Promise((resolve, reject) => { - fs.writeFile(this.file, body, {mode: 0o600}, err => (err ? reject(err) : resolve())) - }) - } - - /** - * Synchronously saves the current machines to the netrc file. - * Handles GPG encryption if the file has a .gpg extension. - * @returns void, or throws on error - */ - saveSync() { - credDebug('saveSync', this.file) - let body = this.output - if (this.file.endsWith('.gpg')) { - const {exitCode, stdout} = execaSync('gpg', this.gpgEncryptArgs, {input: body, reject: false, stdio: ['pipe', 'pipe', 'inherit']}) - if (exitCode !== 0) throw new Error(`gpg exited with code ${exitCode}`) - body = stdout - } - - fs.writeFileSync(this.file, body, {mode: 0o600}) - } - - /** - * Appends a machine token's comment to the output array. - * @param t - The machine token containing the comment - * @param output - The output string array to append to - * @returns void - */ - private addCommentToOutput(t: MachineToken, output: string[]) { - if (t.comment) output.push(' ' + t.comment) - } - - /** - * Appends a machine token's properties to the output array. - * Login and password are added first, followed by other properties. - * @param t - The machine token containing the properties - * @param output - The output string array to append to - * @returns void - */ - private addPropsToOutput(t: MachineToken, output: string[]) { - const addProp = (k: string) => output.push(`${t.internalWhitespace}${k} ${t.props[k].value}${t.props[k].comment || ''}`) - // do login/password first - if (t.props.login) addProp('login') - if (t.props.password) addProp('password') - for (const k of Object.keys(t.props).filter(k => !['login', 'password'].includes(k))) { - addProp(k) - } - } - - /** - * Wraps and throws an error with additional context about the netrc file. - * @param err - The original error - * @returns Never returns; always throws - */ - private throw(err: unknown): never { - const error = (err instanceof Error ? err : new Error(String(err))) as Error & {detail?: string} - if (error.detail) error.detail += '\n' - else error.detail = '' - error.detail += `Error occurred during reading netrc file: ${this.file}` - throw error - } -} - -export default new Netrc() diff --git a/src/deprecated/credential-manager-core/lib/types.ts b/src/deprecated/credential-manager-core/lib/types.ts deleted file mode 100644 index 8b57bb29..00000000 --- a/src/deprecated/credential-manager-core/lib/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type NetrcAuthEntry = { - login: string - password: string -} - -export type KeychainAuthEntry = { - account: string - service: string - token: string -} - -export type AuthEntry = { - account: string | undefined - token: string | undefined -} diff --git a/src/env-auth.ts b/src/env-auth.ts new file mode 100644 index 00000000..34f23369 --- /dev/null +++ b/src/env-auth.ts @@ -0,0 +1,23 @@ +import {resolveEnvAuth} from '@heroku/heroku-credential-manager' +import {warn as oclifWarn} from '@oclif/core/errors' + +/** + * Resolve an auth token from the process environment, independently of `APIClient`. + * + * The *precedence policy* — `HEROKU_API_KEY` provides the token, a bare + * `HEROKU_API_TOKEN` only yields an advisory warning, an empty key is treated as + * unset — lives in `@heroku/heroku-credential-manager`'s pure, environment-agnostic + * `resolveEnvAuth`. This function is only the Node-side adapter around it: it reads + * `process.env` and routes the advisory warning to the CLI's output. Keeping the + * policy in the credential manager and the `process.env` read + emission here is the + * same Node/web split the SDK relies on. + * + * @param warn - sink for the advisory warning; defaults to oclif's `warn`. Injectable + * so callers (and tests) can capture or suppress it. + * @returns the environment-provided token, or `undefined` when none is set. + */ +export function resolveEnvToken(warn: (message: string) => void = oclifWarn): string | undefined { + const {token, warning} = resolveEnvAuth(process.env) + if (warning) warn(warning.message) + return token +} diff --git a/src/file.ts b/src/file.ts deleted file mode 100644 index f5c30e0f..00000000 --- a/src/file.ts +++ /dev/null @@ -1,24 +0,0 @@ -import debugModule from 'debug' -import * as fs from 'node:fs' -import {promisify} from 'node:util' - -let _debug: any -function debug(...args: any[]) { - _debug ||= debugModule('@heroku-cli/command:file') - _debug(...args) -} - -export async function exists(f: string): Promise { - // eslint-disable-next-line n/no-deprecated-api - return promisify(fs.exists)(f) -} - -export async function readdir(f: string): Promise { - debug('readdir', f) - return promisify(fs.readdir)(f) -} - -export async function readFile(f: string) { - debug('readFile', f) - return promisify(fs.readFile)(f) -} diff --git a/src/flags/app.ts b/src/flags/app.ts index 3807d2ba..38186b1d 100644 --- a/src/flags/app.ts +++ b/src/flags/app.ts @@ -48,6 +48,8 @@ export const app = Flags.custom({ description: 'app to run command against', env: 'HEROKU_APP', + // the default depends on the local environment, so keep it out of generated docs and manifests + noCacheDefault: true, }) export const remote = Flags.custom({ diff --git a/src/flags/org.ts b/src/flags/org.ts index 445151e6..1d19d05f 100644 --- a/src/flags/org.ts +++ b/src/flags/org.ts @@ -5,4 +5,6 @@ export const org = Flags.custom({ default: () => process.env.HEROKU_ORGANIZATION, description: 'name of org', hidden: true, + // the default depends on the local environment, so keep it out of generated docs and manifests + noCacheDefault: true, }) diff --git a/src/flags/team.ts b/src/flags/team.ts index d12df491..f76a94bb 100644 --- a/src/flags/team.ts +++ b/src/flags/team.ts @@ -9,4 +9,6 @@ export const team = Flags.custom({ if (org) return org }, description: 'team to use', + // the default depends on the local environment, so keep it out of generated docs and manifests + noCacheDefault: true, }) diff --git a/src/index.ts b/src/index.ts index 6e0fab37..c2e90a62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ +import {setCredentialErrorReporter} from '@heroku/heroku-credential-manager' + +import {reportCredentialStoreError} from './credential-error-reporter.js' + export * from './api-client.js' export {Command, Command as default} from './command.js' export type * from './completion-types.js' @@ -9,15 +13,11 @@ export type * from './completion-types.js' // CLI and this package share the same Completion object identities (the host keys // behavior off reference identity). That holds only because package.json has NO // `exports` field, so the root barrel and any deep import co-resolve to one ESM -// instance. Do not add an `exports` map. (Guarded by test/package-contract.test.ts.) +// instance. Do not add an `exports` map. (Guarded by the no-`exports` test in +// test/autocomplete/completion-sources.test.ts.) export {accountCacheKey, completionCatalog, hostCacheKey} from './completions.js' -export { - getAuth, - listKeychainAccounts, - removeAuth, - saveAuth, -} from './credential-manager-core/lib/credential-manager-adapter.js' -export type {AuthEntry} from './credential-manager-core/lib/types.js' +export type {AuthEntry} from './credential-manager.js' +export * from './env-auth.js' export * as flags from './flags/index.js' export * from './git.js' export {NONINTERACTIVE_LOGIN_ERROR_CODE} from './login.js' @@ -26,29 +26,17 @@ export * from './particleboard-client.js' export * from './prompter.js' export * from './vars.js' export * from './yubikey.js' - -/** @deprecated Import credential storage APIs from `@heroku/heroku-credential-manager`. */ - export { - CredentialStore, - deleteLoginState, - getCredentialHandler, - getNativeCredentialStore, - getStorageConfig, - type KeychainAuthEntry, - LinuxHandler, - type Machines, - type MachinesWithTokens, - type MachineToken, - MacOSHandler, + getAuth, + listKeychainAccounts, NativeCredentialNotFoundError, - Netrc, - type NetrcAuthEntry, - NetrcHandler, - parse, - readLoginState, - type StorageConfig, - type Token, - WindowsHandler, - writeLoginState, + removeAuth, + saveAuth, } from '@heroku/heroku-credential-manager' + +// Register the CLI's credential-error reporter with the published credential +// manager, which invokes it on native credential-store failures. The reporter +// self-gates (no-op in CI/test) and lazily initializes Sentry only on an actual +// failure, so registering eagerly at barrel load is cheap and side-effect-free. +// eslint-disable-next-line unicorn/no-top-level-side-effects -- the reporter must be registered when this package loads; the call is idempotent and gated +setCredentialErrorReporter(reportCredentialStoreError) diff --git a/src/login-state-coordinator.ts b/src/login-state-coordinator.ts index 8097c332..5339e131 100644 --- a/src/login-state-coordinator.ts +++ b/src/login-state-coordinator.ts @@ -1,11 +1,10 @@ -import {access} from 'node:fs/promises' -import path from 'node:path' - import { deleteLoginState, readLoginState, writeLoginState, -} from './credential-manager-core/lib/login-state.js' +} from '@heroku/heroku-credential-manager' +import {access} from 'node:fs/promises' +import path from 'node:path' type LoginState = Awaited> diff --git a/src/login.ts b/src/login.ts index 499d8332..3dc8f538 100644 --- a/src/login.ts +++ b/src/login.ts @@ -1,6 +1,7 @@ import type {Config} from '@oclif/core/interfaces' import type {ChildProcess} from 'node:child_process' +import {getStorageConfig, readLoginState} from '@heroku/heroku-credential-manager' import { Login as CredentialManagerLogin, type LoginOptions as CredentialManagerLoginOptions, @@ -17,10 +18,6 @@ import os from 'node:os' import * as readline from 'node:readline' import {type APIClient, HerokuAPIError, type IHerokuAPIErrorOptions} from './api-client.js' -import {getStorageConfig} from './credential-manager-core/lib/credential-storage-selector.js' -import { - readLoginState, -} from './credential-manager-core/lib/login-state.js' import { createCredentialManagerFetchAdapter, createCredentialManagerPlatformAdapter, diff --git a/test/api-client.test.ts b/test/api-client.test.ts index 167eb224..bdbbd7f8 100644 --- a/test/api-client.test.ts +++ b/test/api-client.test.ts @@ -1,3 +1,4 @@ +import {readLoginState, writeLoginState} from '@heroku/heroku-credential-manager' import {Config} from '@oclif/core/config' import {ux} from '@oclif/core/ux' import {expect as chaiExpect, use} from 'chai' @@ -13,7 +14,6 @@ import * as sinon from 'sinon' import {stderr} from 'stdout-stderr' import {Command as CommandBase} from '../src/command.js' -import {readLoginState, writeLoginState} from '../src/credential-manager-core/lib/login-state.js' import {setCredentialManagerProvider} from '../src/credential-manager.js' import {writeLoginStateCoordinated} from '../src/login-state-coordinator.js' import {prompter} from '../src/prompter.js' diff --git a/test/autocomplete/completion-sources.test.ts b/test/autocomplete/completion-sources.test.ts index d63f59c1..9d7abe57 100644 --- a/test/autocomplete/completion-sources.test.ts +++ b/test/autocomplete/completion-sources.test.ts @@ -1,7 +1,12 @@ import {Config} from '@oclif/core' import {expect} from 'chai' import {execFileSync} from 'node:child_process' -import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import {tmpdir} from 'node:os' import path from 'node:path' import {fileURLToPath} from 'node:url' @@ -297,4 +302,16 @@ describe('autocomplete completion sources', function () { expect(completion.options, `${id} must expose options`).to.be.a('function') } }) + + it('this package publishes without an `exports` map so the catalog resolves to one module instance', function () { + // The host CLI keys completion behavior off the reference identity of the + // `completionCatalog` singletons: an entry must be the SAME object the host + // sees however it imports this package. That holds only while package.json + // has NO `exports` field — without one, the root barrel and any deep import + // co-resolve to a single ESM instance; an `exports` map would fork them into + // two instances and break identity. See the note in src/index.ts. + const manifestPath = path.resolve(__dirname, '..', '..', 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record + expect(manifest).to.not.have.property('exports') + }) }) diff --git a/test/credential-manager/lib/cli-command-telemetry.test.ts b/test/credential-error-reporter.test.ts similarity index 87% rename from test/credential-manager/lib/cli-command-telemetry.test.ts rename to test/credential-error-reporter.test.ts index fc216dce..799c98f9 100644 --- a/test/credential-manager/lib/cli-command-telemetry.test.ts +++ b/test/credential-error-reporter.test.ts @@ -1,3 +1,4 @@ +import {CredentialStore} from '@heroku/heroku-credential-manager' import {expect, use} from 'chai' import chaiAsPromised from 'chai-as-promised' import {restore, stub} from 'sinon' @@ -6,12 +7,11 @@ import { credentialSentrySdk, reportCredentialStoreError, shouldReportCredentialErrorsToSentry, -} from '../../../src/credential-manager-core/lib/cli-command-telemetry.js' -import {CredentialStore} from '../../../src/credential-manager-core/lib/credential-storage-selector.js' +} from '../src/credential-error-reporter.js' use(chaiAsPromised) -describe('cli-command-telemetry', function () { +describe('credential-error-reporter', function () { afterEach(function () { restore() delete process.env.CI @@ -105,7 +105,7 @@ describe('cli-command-telemetry', function () { }) }) - it('calls close on Sentry client after flush', async function () { + it('flushes but keeps the Sentry client open so later errors still report', async function () { delete process.env.CI process.env.NODE_ENV = 'development' delete process.env.IS_HEROKU_TEST_ENV @@ -115,7 +115,7 @@ describe('cli-command-telemetry', function () { stub(credentialSentrySdk, 'getClient').returns({close: closeStub} as unknown as NonNullable>) stub(credentialSentrySdk, 'init') stub(credentialSentrySdk, 'captureException') - stub(credentialSentrySdk, 'flush').resolves(true) + const flushStub = stub(credentialSentrySdk, 'flush').resolves(true) const err = new Error('Test error') await reportCredentialStoreError(err, { @@ -123,7 +123,10 @@ describe('cli-command-telemetry', function () { operation: 'saveAuth', }) - expect(closeStub.calledOnce).to.equal(true) + expect(flushStub.calledOnce).to.equal(true) + // Closing the client left it registered-but-disabled, which blocked every + // subsequent report; it must stay open. + expect(closeStub.called).to.equal(false) }) it('can report multiple errors in sequence', async function () { @@ -136,7 +139,7 @@ describe('cli-command-telemetry', function () { stub(credentialSentrySdk, 'getClient').returns({close: closeStub} as unknown as NonNullable>) stub(credentialSentrySdk, 'init') const captureStub = stub(credentialSentrySdk, 'captureException') - stub(credentialSentrySdk, 'flush').resolves(true) + const flushStub = stub(credentialSentrySdk, 'flush').resolves(true) const err1 = new Error('First error') await reportCredentialStoreError(err1, { @@ -150,13 +153,12 @@ describe('cli-command-telemetry', function () { operation: 'saveAuth', }) - // Both errors should be captured + // Both errors should be captured and flushed, with the client kept open. expect(captureStub.calledTwice).to.equal(true) expect(captureStub.firstCall.args[0]).to.equal(err1) expect(captureStub.secondCall.args[0]).to.equal(err2) - - // Close should be called for each error - expect(closeStub.calledTwice).to.equal(true) + expect(flushStub.calledTwice).to.equal(true) + expect(closeStub.called).to.equal(false) }) }) }) diff --git a/test/credential-manager-login-adapters.test.ts b/test/credential-manager-login-adapters.test.ts index 1bc4690d..af18b45b 100644 --- a/test/credential-manager-login-adapters.test.ts +++ b/test/credential-manager-login-adapters.test.ts @@ -1,3 +1,5 @@ +import type {AddressInfo} from 'node:net' + import { type HerokuApiResponse, Login, @@ -9,10 +11,7 @@ import {Config} from '@oclif/core/config' import * as chai from 'chai' import chaiAsPromised from 'chai-as-promised' import nock from 'nock' -import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' import {createServer} from 'node:http' -import {createRequire} from 'node:module' -import {tmpdir} from 'node:os' import path from 'node:path' import {fileURLToPath} from 'node:url' import * as sinon from 'sinon' @@ -27,9 +26,6 @@ import { chai.use(chaiAsPromised) const {expect} = chai const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const require = createRequire(import.meta.url) -type ProxyPolicy = {env: NodeJS.ProcessEnv} -const proxyPolicy = (require('@heroku/http-call/lib/proxy.js') as {default: ProxyPolicy}).default const httpError = (method: 'DELETE' | 'GET') => { const response = new HTTP(`https://api.heroku.com/${method.toLowerCase()}-http-error`, {method}) @@ -274,7 +270,8 @@ describe('credential manager login adapters', () => { redirect: 'error', }) - expect(response.constructor.name).to.equal('Response') + // eslint-disable-next-line n/no-unsupported-features/node-builtins + expect(response).to.be.instanceOf(Response) expect(response.status).to.equal(201) expect(response.headers.get('x-result')).to.equal('created') expect(await response.json()).to.deep.equal({browser_url: '/browser'}) @@ -376,7 +373,10 @@ describe('credential manager login adapters', () => { it('passes null bodies to native Response for bodyless statuses', async () => { const fetch = createCredentialManagerFetchAdapter() for (const status of [204, 205, 304]) { - nock('https://api.heroku.com').get(`/bodyless-${status}`).reply(status, 'must-not-reach-Response') + // Reply with a body the server must not send on a bodyless status: the + // empty-text assertion below only proves the adapter drops it if there + // was a body on the wire to drop. + nock('https://api.heroku.com').get(`/bodyless-${status}`).reply(status, 'must-not-reach-body') // eslint-disable-next-line no-await-in-loop const response = await fetch(`https://api.heroku.com/bodyless-${status}`, {redirect: 'error'}) expect(response.status).to.equal(status) @@ -385,45 +385,6 @@ describe('credential manager login adapters', () => { } }) - it('replays identical complete POST bytes after a real socket reset', async () => { - const bodies: string[] = [] - nock.enableNetConnect('127.0.0.1') - const server = createServer((request, response) => { - void (async () => { - const chunks: Buffer[] = [] - for await (const chunk of request) chunks.push(Buffer.from(chunk)) - bodies.push(Buffer.concat(chunks).toString()) - if (bodies.length === 1) { - request.socket.destroy() - } else { - response.setHeader('content-type', 'application/json') - response.end('{"ok":true}') - } - })() - }) - await new Promise(resolve => { - server.listen(0, '127.0.0.1', resolve) - }) - const address = server.address() - if (!address || typeof address === 'string') throw new Error('test server did not bind') - try { - const response = await createCredentialManagerFetchAdapter()(`http://127.0.0.1:${address.port}/retry-body`, { - body: 'complete-post-body', - method: 'POST', - redirect: 'error', - }) - expect(await response.json()).to.deep.equal({ok: true}) - expect(bodies).to.deep.equal(['complete-post-body', 'complete-post-body']) - } finally { - await new Promise((resolve, reject) => { - server.close(error => { - error ? reject(error) : resolve() - }) - }) - nock.disableNetConnect() - } - }) - it('returns non-2xx poll Responses and leaves status retries to the package', async () => { const fetch = createCredentialManagerFetchAdapter() const single = nock('https://cli-auth.heroku.com').get('/single-poll').reply(503, {message: 'wait'}) @@ -462,410 +423,169 @@ describe('credential manager login adapters', () => { expect(polls).to.equal(2) }) - it('rejects redirects instead of following them with the default transport', async () => { - nock('https://cli-auth.heroku.com') - .post('/auth') - .reply(302, undefined, {location: 'https://elsewhere.example.test/auth'}) - - await expect(createCredentialManagerFetchAdapter()('https://cli-auth.heroku.com/auth', { - body: '{}', - headers: {'content-type': 'application/json'}, - method: 'POST', - redirect: 'error', - })).to.be.rejectedWith(TypeError, 'Redirects are not allowed') - }) - - it('rejects redirects from an injected command transport', async () => { - class InjectedTransport extends HTTP { - static async request(url: string, options?: HTTPRequestOptions): Promise> { - const request = new this(url, options) - request.body = 'redirect body' as T - Object.defineProperty(request, 'response', { - value: {headers: {location: 'https://elsewhere.example.test'}, statusCode: 302}, - }) - await request._redirect() - return request - } - } - - await expect(createCredentialManagerFetchAdapter(InjectedTransport)('https://cli-auth.heroku.com/auth', { - method: 'POST', - redirect: 'error', - })).to.be.rejectedWith(TypeError, 'Redirects are not allowed') - }) - - it('leaves transient network retries to the command transport', async () => { - let attempts = 0 - class RetryingTransport extends HTTP { - static async request(url: string, options?: HTTPRequestOptions): Promise> { - attempts++ - // Models command transport-owned retry rather than any retry in the adapter. - if (attempts === 1) return this.request(url, options) - return {body: {ok: true} as T, headers: {}, statusCode: 200} as HTTP - } - } - - const response = await createCredentialManagerFetchAdapter(RetryingTransport)('https://cli-auth.heroku.com/retry', { - method: 'GET', - redirect: 'error', + it('rejects redirects instead of following them', async () => { + const restoreProxyEnv = clearProxyEnv() + nock.enableNetConnect() + // Real servers rather than nock: nock neither reproduces undici's redirect + // error shape nor actually follows redirects, so it can't tell a refused + // redirect from a followed one. A redirect target that returns 200 makes the + // two outcomes diverge — a refused redirect rejects, a followed one resolves + // (and trips followedHit). + let followedHit = false + const target = createServer((_req, res) => { + followedHit = true + res.writeHead(200, {'content-type': 'application/json'}) + res.end('{}') + }) + const redirector = createServer((_req, res) => { + res.writeHead(302, {location: `http://127.0.0.1:${(target.address() as AddressInfo).port}/landing`}) + res.end() + }) + await new Promise(resolve => { + target.listen(0, '127.0.0.1', () => { + resolve(); + }) + }) + await new Promise(resolve => { + redirector.listen(0, '127.0.0.1', () => { + resolve(); + }) }) - - expect(await response.json()).to.deep.equal({ok: true}) - expect(attempts).to.equal(2) - }) - - it('preserves http-call 5.5.2 retry count and error policy', async () => { - const cases: Array<{attempts: number; code: string}> = [ - {attempts: 6, code: 'ECONNRESET'}, - {attempts: 6, code: 'ENOTFOUND'}, - {attempts: 1, code: 'ENETUNREACH'}, - {attempts: 1, code: 'CERT_HAS_EXPIRED'}, - ] - const timer = {unref() {}} - const timeout = sinon.stub(globalThis, 'setTimeout').callsFake(((handler: (...arguments_: unknown[]) => void) => { - queueMicrotask(handler) - return timer - }) as unknown as typeof setTimeout) - try { - for (const expected of cases) { - let attempts = 0 - class PolicyTransport extends HTTP { - async _request(): Promise { - attempts++ - await this._maybeRetry(Object.assign(new Error(expected.code), {code: expected.code})) - } - } - - // eslint-disable-next-line no-await-in-loop - await expect(createCredentialManagerFetchAdapter(PolicyTransport)('https://api.heroku.com/retry-policy', { - redirect: 'error', - })).to.be.rejectedWith(expected.code) - expect(attempts).to.equal(expected.attempts) - } - } finally { - timeout.restore() - } - }) - - it('preserves http-call 5.5.2 exponential jitter delays', async () => { - const delays: number[] = [] - let attempts = 0 - const random = sinon.stub(Math, 'random').returns(0.5) - const timer = {unref() {}} - const timeout = sinon.stub(globalThis, 'setTimeout').callsFake(((handler: (...arguments_: unknown[]) => void, delay?: number) => { - delays.push(delay ?? 0) - queueMicrotask(handler) - return timer - }) as unknown as typeof setTimeout) - class JitterTransport extends HTTP { - async _request(): Promise { - attempts++ - if (attempts < 4) await this._maybeRetry(Object.assign(new Error('socket reset'), {code: 'ECONNRESET'})) - else { - this.body = {ok: true} as T - Object.defineProperty(this, 'response', {value: {headers: {}, statusCode: 200}}) - } - } - } - try { - await createCredentialManagerFetchAdapter(JitterTransport)('https://api.heroku.com/retry-jitter', {redirect: 'error'}) - expect(delays).to.deep.equal([250, 450, 850]) - } finally { - random.restore() - timeout.restore() - } - }) - - it('uses the command transport proxy and NO_PROXY path', async () => { - const previousHttpsProxy = process.env.HTTPS_PROXY - const previousNoProxy = process.env.NO_PROXY - process.env.HTTPS_PROXY = 'http://proxy.example.test:8080' - process.env.NO_PROXY = 'direct.example.test' - class InspectingTransport extends HTTP { - static agents: unknown[] = [] - - static async request(url: string, options?: HTTPRequestOptions): Promise> { - const request = new this(url, options) - this.agents.push(request.options.agent) - return {body: {ok: true} as T, headers: {}, statusCode: 200} as HTTP - } - } - - try { - const fetch = createCredentialManagerFetchAdapter(InspectingTransport) - await fetch('https://proxied.example.test/path', {redirect: 'error'}) - await fetch('https://direct.example.test/path', {redirect: 'error'}) - expect(InspectingTransport.agents[0]).to.exist - expect(InspectingTransport.agents[1]).to.equal(undefined) - } finally { - if (previousHttpsProxy === undefined) delete process.env.HTTPS_PROXY - else process.env.HTTPS_PROXY = previousHttpsProxy - if (previousNoProxy === undefined) delete process.env.NO_PROXY - else process.env.NO_PROXY = previousNoProxy - } - }) - - it('configures custom CA for direct, proxied, and NO_PROXY HTTPS requests', async () => { - const previousHttpsProxy = process.env.HTTPS_PROXY - const previousNoProxy = process.env.NO_PROXY - const previousCertFile = process.env.SSL_CERT_FILE - process.env.HTTPS_PROXY = 'http://proxy.example.test:8080' - process.env.NO_PROXY = 'direct.example.test' - const directory = mkdtempSync(`${tmpdir()}/login-adapter-ca-`) - const certificate = path.resolve(directory, 'custom-ca.pem') - writeFileSync(certificate, 'custom-ca') - process.env.SSL_CERT_FILE = certificate - class InspectingTransport extends HTTP { - static options: HTTPRequestOptions[] = [] - - static async request(url: string, options?: HTTPRequestOptions): Promise> { - const request = new this(url, options) - this.options.push(request.options) - return {body: {ok: true} as T, headers: {}, statusCode: 200} as HTTP - } - } - try { - const fetch = createCredentialManagerFetchAdapter(InspectingTransport) - await fetch('https://proxied.example.test/path', {redirect: 'error'}) - await fetch('https://direct.example.test/path', {redirect: 'error'}) - expect(InspectingTransport.options[0].agent).to.exist - expect(InspectingTransport.options[1].agent).to.exist - expect((InspectingTransport.options[0].agent as {options?: {ca?: Buffer[]}}).options?.ca?.[0].toString()).to.equal('custom-ca') - expect((InspectingTransport.options[1].agent as {options?: {ca?: Buffer[]}}).options?.ca?.[0].toString()).to.equal('custom-ca') + // With `redirect: 'error'`, undici rejects with TypeError('fetch failed') + // whose `cause` is 'unexpected redirect'. Asserting the cause — not a bare + // TypeError — is what makes this discriminating: fetch reports every + // network failure as TypeError, so a followed redirect whose follow-up + // failed would look identical otherwise. + const error = await createCredentialManagerFetchAdapter()(`http://127.0.0.1:${(redirector.address() as AddressInfo).port}/auth`, { + body: '{}', + headers: {'content-type': 'application/json'}, + method: 'POST', + redirect: 'error', + }).then( + () => expect.fail('expected the redirect to be rejected, but the request resolved'), + (error_: unknown) => error_, + ) + + expect(error).to.be.instanceOf(TypeError) + expect((error as {cause?: {message?: string}}).cause?.message).to.match(/redirect/i) + expect(followedHit).to.equal(false) } finally { - rmSync(directory, {force: true, recursive: true}) - if (previousHttpsProxy === undefined) delete process.env.HTTPS_PROXY - else process.env.HTTPS_PROXY = previousHttpsProxy - if (previousNoProxy === undefined) delete process.env.NO_PROXY - else process.env.NO_PROXY = previousNoProxy - if (previousCertFile === undefined) delete process.env.SSL_CERT_FILE - else process.env.SSL_CERT_FILE = previousCertFile + await new Promise(resolve => { + target.close(() => { + resolve(); + }) + }) + await new Promise(resolve => { + redirector.close(() => { + resolve(); + }) + }) + restoreProxyEnv() } }) - it('reevaluates proxy, NO_PROXY, and CA settings after process.env object replacement', async () => { - const originalEnvironment = process.env - const directory = mkdtempSync(`${tmpdir()}/login-adapter-replaced-env-`) - const certificate = path.resolve(directory, 'custom-ca.pem') - writeFileSync(certificate, 'replacement-ca') - process.env = { - ...originalEnvironment, - HTTPS_PROXY: 'http://replacement-proxy.example.test:8080', - NO_PROXY: 'replacement-direct.example.test', - SSL_CERT_FILE: certificate, - } - class InspectingTransport extends HTTP { - static options: HTTPRequestOptions[] = [] - - static async request(url: string, options?: HTTPRequestOptions): Promise> { - const request = new this(url, options) - this.options.push(request.options) - return {body: {ok: true} as T, headers: {}, statusCode: 200} as HTTP - } - } - - try { - const fetch = createCredentialManagerFetchAdapter(InspectingTransport) - await fetch('https://replacement-proxied.example.test/path', {redirect: 'error'}) - await fetch('https://replacement-direct.example.test/path', {redirect: 'error'}) - expect(InspectingTransport.options[0].agent).to.exist - expect(InspectingTransport.options[1].agent).to.exist - expect((InspectingTransport.options[1].agent as {options?: {ca?: Buffer[]}}).options?.ca?.[0].toString()).to.equal('replacement-ca') - } finally { - process.env = originalEnvironment - rmSync(directory, {force: true, recursive: true}) - proxyPolicy.env = originalEnvironment - } - }) + const proxyEnvKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy'] as const - it('restores proxy policy identity before unrelated plain HTTP construction', async () => { - const originalEnvironment = process.env - const originalProxyEnvironment = proxyPolicy.env - process.env = { - ...originalEnvironment, - HTTPS_PROXY: 'http://temporary-adapter-proxy.example.test:8080', - NO_PROXY: '', - } - class InspectingTransport extends HTTP { - static async request(url: string, options?: HTTPRequestOptions): Promise> { - const request = new this(url, options) - request.body = {ok: true} as T - Object.defineProperty(request, 'response', {value: {headers: {}, statusCode: 200}}) - return request + const clearProxyEnv = () => { + const saved = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])) + for (const key of proxyEnvKeys) Reflect.deleteProperty(process.env, key) + return () => { + for (const key of proxyEnvKeys) { + if (saved[key] === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = saved[key] } } + } + it('uses native fetch with no dispatcher when no proxy env var is set', async () => { + const restoreProxyEnv = clearProxyEnv() + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('{}', {status: 200})) try { - await createCredentialManagerFetchAdapter(InspectingTransport)('https://adapter.example.test/path', {redirect: 'error'}) + await createCredentialManagerFetchAdapter()('https://api.heroku.com/no-proxy', {redirect: 'error'}) + expect(fetchStub.lastCall.args[1]).to.not.have.property('dispatcher') } finally { - process.env = originalEnvironment + fetchStub.restore() + restoreProxyEnv() } - - expect(proxyPolicy.env).to.equal(originalProxyEnvironment, `proxy policy still points at ${proxyPolicy.env.HTTPS_PROXY}`) - const plain = new HTTP('https://unrelated.example.test/path') - const proxyOptions = (plain.options.agent as undefined | {proxyOptions?: {host?: string}})?.proxyOptions - expect(proxyOptions?.host).to.not.equal('temporary-adapter-proxy.example.test') }) - it('keeps current direct HTTPS direct when prior proxy policy is stale', async () => { - const originalEnvironment = process.env - const originalProxyEnvironment = proxyPolicy.env - const staleEnvironment = {HTTPS_PROXY: 'http://stale-proxy.example.test:8080'} - proxyPolicy.env = staleEnvironment - process.env = { - ...originalEnvironment, HTTP_PROXY: '', HTTPS_PROXY: '', NO_PROXY: '*', - } - class InspectingTransport extends HTTP { - static agent: unknown - - static async request(url: string, options?: HTTPRequestOptions): Promise> { - const request = new this(url, options) - this.agent = request.options.agent - return {body: {ok: true} as T, headers: {}, statusCode: 200} as HTTP - } - } - + it('drives a real proxied GET through the installed undici (regression: cross-undici dispatcher)', async () => { + const restoreProxyEnv = clearProxyEnv() + nock.enableNetConnect() + let proxiedUrl: string | undefined + const proxy = createServer((req, res) => { + proxiedUrl = req.url + res.writeHead(200, {'content-type': 'application/json'}) + res.end(JSON.stringify({proxied: true})) + }) + await new Promise(resolve => { + proxy.listen(0, '127.0.0.1', () => { + resolve(); + }) + }) try { - await createCredentialManagerFetchAdapter(InspectingTransport)('https://direct-current.example.test/path', {redirect: 'error'}) - expect(InspectingTransport.agent).to.equal(undefined) - expect(proxyPolicy.env).to.equal(staleEnvironment) + process.env.HTTP_PROXY = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` + // Before the fix this threw `InvalidArgumentError: invalid onRequestStart + // method`: the installed undici's EnvHttpProxyAgent was handed to Node's + // built-in (different-copy) fetch, which can't drive it. + const response = await createCredentialManagerFetchAdapter()('http://api.heroku.example/account', {redirect: 'error'}) + expect(response.status).to.equal(200) + expect(await response.json()).to.deep.equal({proxied: true}) + expect(proxiedUrl).to.equal('http://api.heroku.example/account') } finally { - process.env = originalEnvironment - proxyPolicy.env = originalProxyEnvironment + await new Promise(resolve => { + proxy.close(() => { + resolve(); + }) + }) + restoreProxyEnv() } }) - it('uses current HTTP proxy instead of stale prior HTTP proxy', async () => { - const originalEnvironment = process.env - const originalProxyEnvironment = proxyPolicy.env - const staleEnvironment = {HTTP_PROXY: 'http://stale-http-proxy.example.test:8080'} - proxyPolicy.env = staleEnvironment - process.env = { - ...originalEnvironment, - HTTP_PROXY: 'http://current-http-proxy.example.test:8181', - } - delete process.env.HTTPS_PROXY - delete process.env.https_proxy - delete process.env.http_proxy - delete process.env.NO_PROXY - delete process.env.no_proxy - const observations: Array<{agent: unknown; url: string}> = [] - class InspectingTransport extends HTTP { - static async request(url: string, options?: HTTPRequestOptions): Promise> { - const request = new this(url, options) - observations.push({agent: request.options.agent, url}) - return {body: {ok: true} as T, headers: {}, statusCode: 200} as HTTP - } - } - + it('proxies a body-bearing Request by decomposing it for the installed undici', async () => { + const restoreProxyEnv = clearProxyEnv() + nock.enableNetConnect() + let received: {body: string; method?: string; url?: string} = {body: ''} + const proxy = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', chunk => { + chunks.push(chunk as Buffer) + }) + req.on('end', () => { + received = {body: Buffer.concat(chunks).toString(), method: req.method, url: req.url} + res.writeHead(200, {'content-type': 'application/json'}) + res.end(JSON.stringify({ok: true})) + }) + }) + await new Promise(resolve => { + proxy.listen(0, '127.0.0.1', () => { + resolve(); + }) + }) try { - await createCredentialManagerFetchAdapter(InspectingTransport)('http://current-http.example.test/path', { - method: 'GET', + process.env.HTTP_PROXY = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const request = new Request('http://api.heroku.example/oauth/authorizations', { + body: JSON.stringify({scope: ['global']}), + headers: {authorization: 'Basic abc', 'content-type': 'application/json'}, + method: 'POST', redirect: 'error', }) - expect(observations).to.have.length(1) - expect(observations[0].url).to.equal('http://current-http.example.test/path') - expect(observations[0].agent, `current HTTP_PROXY=${process.env.HTTP_PROXY}`).to.exist - const {proxyOptions} = (observations[0].agent as {proxyOptions?: {host?: string; port?: string}}) - expect(proxyOptions).to.deep.include({host: 'current-http-proxy.example.test', port: '8181'}) - expect(proxyPolicy.env).to.equal(staleEnvironment) + const response = await createCredentialManagerFetchAdapter()(request) + expect(response.status).to.equal(200) + expect(received.method).to.equal('POST') + expect(received.url).to.equal('http://api.heroku.example/oauth/authorizations') + expect(JSON.parse(received.body)).to.deep.equal({scope: ['global']}) } finally { - process.env = originalEnvironment - proxyPolicy.env = originalProxyEnvironment - } - }) - - it('disables the command transport timeout so package cancellation owns timeouts', async () => { - class InspectingTransport extends HTTP { - static timeout: number | undefined - - async _request(): Promise { - InspectingTransport.timeout = this.options.timeout - this.body = {ok: true} as T - Object.defineProperty(this, 'response', {value: {headers: {}, statusCode: 200}}) - } - } - await createCredentialManagerFetchAdapter(InspectingTransport)('https://api.heroku.com/timeout-owner', {redirect: 'error'}) - expect(InspectingTransport.timeout).to.equal(0) - }) - - it('aborts promptly while the command transport is in retry backoff', async () => { - const controller = new AbortController() - nock('https://api.heroku.com').get('/backoff').replyWithError({code: 'ECONNRESET', message: 'socket reset'}) - const started = Date.now() - const pending = createCredentialManagerFetchAdapter()('https://api.heroku.com/backoff', { - redirect: 'error', - signal: controller.signal, - }) - setTimeout(() => { - controller.abort(new Error('abort during backoff')) - }, 10) - await expect(pending).to.be.rejectedWith('abort during backoff') - expect(Date.now() - started).to.be.lessThan(100) - }) - - it('unrefs the retry backoff timer', async () => { - let unrefCalls = 0 - const timeout = sinon.stub(globalThis, 'setTimeout').callsFake(((() => ({ - unref() { - unrefCalls++ - }, - })) as unknown) as typeof setTimeout) - class BackoffTransport extends HTTP { - async _request(): Promise { - await this._maybeRetry(Object.assign(new Error('socket reset'), {code: 'ECONNRESET'})) - } - } - const controller = new AbortController() - const pending = createCredentialManagerFetchAdapter(BackoffTransport)('https://api.heroku.com/backoff-unref', { - redirect: 'error', - signal: controller.signal, - }) - await Promise.resolve() - controller.abort(new Error('stop unref test')) - await expect(pending).to.be.rejectedWith('stop unref test') - expect(unrefCalls).to.equal(1) - timeout.restore() - }) - - it('cancels retry backoff so no request callback runs after abort', async () => { - let requests = 0 - const scheduled = new Set>() - const originalSetTimeout = setTimeout - const originalClearTimeout = clearTimeout - sinon.stub(globalThis, 'setTimeout').callsFake(((handler: (...arguments_: unknown[]) => void, timeout?: number, ...arguments_: unknown[]) => { - const timer = originalSetTimeout(handler, timeout, ...arguments_) - scheduled.add(timer) - return timer - }) as typeof setTimeout) - sinon.stub(globalThis, 'clearTimeout').callsFake((timer => { - scheduled.delete(timer as ReturnType) - originalClearTimeout(timer) - })) - class BackoffTransport extends HTTP { - async _request(): Promise { - requests++ - if (requests === 1) { - await this._maybeRetry(Object.assign(new Error('socket reset'), {code: 'ECONNRESET'})) - } - } + await new Promise(resolve => { + proxy.close(() => { + resolve(); + }) + }) + restoreProxyEnv() } - const controller = new AbortController() - const pending = createCredentialManagerFetchAdapter(BackoffTransport)('https://api.heroku.com/backoff-leak', { - redirect: 'error', - signal: controller.signal, - }) - await new Promise(resolve => { - originalSetTimeout(resolve, 0) - }) - controller.abort(new Error('cancel retry timer')) - await expect(pending).to.be.rejectedWith('cancel retry timer') - expect(scheduled.size).to.equal(0) - await new Promise(resolve => { - originalSetTimeout(resolve, 300) - }) - expect(requests).to.equal(1) }) it('honors package request timeout aborts', async () => { diff --git a/test/credential-manager/acceptance/index.acceptance.test.ts b/test/credential-manager/acceptance/index.acceptance.test.ts deleted file mode 100644 index c0738506..00000000 --- a/test/credential-manager/acceptance/index.acceptance.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import {expect, use} from 'chai' -import chaiAsPromised from 'chai-as-promised' - -import { - getAuth, - listKeychainAccounts, - Netrc, - removeAuth, - saveAuth, -} from '../../../src/credential-manager-core/index.js' -import { - cleanupCredentialStore, - cleanupDefaultNetrc, - CREDENTIAL_FIXTURES, - setupFakeCredentialStore, - skipUnlessAcceptance, - snapshotDefaultNetrc, -} from '../helpers/acceptance-utils.js' - -use(chaiAsPromised) - -const CREDENTIAL = CREDENTIAL_FIXTURES['account-default'] -const CREDENTIAL_MULTIPLE_HOSTS = CREDENTIAL_FIXTURES['account-multiple-hosts'] -const CREDENTIAL_ALTERNATE_SERVICE = CREDENTIAL_FIXTURES['account-alternate-service'] - -describe('credential-manager acceptance', function () { - let restoreNetrc: (() => void) | undefined - let originalNetrcWrite: string | undefined - - before(function () { - skipUnlessAcceptance(this) - - originalNetrcWrite = process.env.HEROKU_NETRC_WRITE - - const netrcSnapshot = snapshotDefaultNetrc() - restoreNetrc = netrcSnapshot.restore - }) - - after(function () { - if (originalNetrcWrite === undefined) { - delete process.env.HEROKU_NETRC_WRITE - } else { - process.env.HEROKU_NETRC_WRITE = originalNetrcWrite - } - - if (restoreNetrc) { - restoreNetrc() - } - }) - - describe('.netrc only', function () { - before(function () { - process.env.HEROKU_NETRC_WRITE = 'TRUE' - }) - - afterEach(async function () { - await cleanupDefaultNetrc() - }) - - after(function () { - delete process.env.HEROKU_NETRC_WRITE - }) - - it('saves/retrieves a single host', async function () { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - - const auth = await getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service) - expect(auth).to.deep.equal({account: CREDENTIAL.account, token: CREDENTIAL.token}) - }) - - it('saves/retrieves multiple hosts', async function () { - await saveAuth(CREDENTIAL_MULTIPLE_HOSTS.account, CREDENTIAL_MULTIPLE_HOSTS.token, CREDENTIAL_MULTIPLE_HOSTS.hosts, CREDENTIAL_MULTIPLE_HOSTS.service) - - const auth1 = await getAuth(CREDENTIAL_MULTIPLE_HOSTS.account, CREDENTIAL_MULTIPLE_HOSTS.hosts[0], CREDENTIAL_MULTIPLE_HOSTS.service) - const auth2 = await getAuth(CREDENTIAL_MULTIPLE_HOSTS.account, CREDENTIAL_MULTIPLE_HOSTS.hosts[1], CREDENTIAL_MULTIPLE_HOSTS.service) - - const expected = {account: CREDENTIAL_MULTIPLE_HOSTS.account, token: CREDENTIAL_MULTIPLE_HOSTS.token} - expect(auth1).to.deep.equal(expected) - expect(auth2).to.deep.equal(expected) - }) - - it('removes a single host', async function () { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - await removeAuth(CREDENTIAL.account, CREDENTIAL.hosts, CREDENTIAL.service) - - await expect(getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service)) - .to.be.rejectedWith(/No auth found|No credentials found/) - }) - - it('removes multiple hosts', async function () { - await saveAuth(CREDENTIAL_MULTIPLE_HOSTS.account, CREDENTIAL_MULTIPLE_HOSTS.token, CREDENTIAL_MULTIPLE_HOSTS.hosts, CREDENTIAL_MULTIPLE_HOSTS.service) - await removeAuth(CREDENTIAL_MULTIPLE_HOSTS.account, CREDENTIAL_MULTIPLE_HOSTS.hosts, CREDENTIAL_MULTIPLE_HOSTS.service) - - await expect(getAuth(CREDENTIAL_MULTIPLE_HOSTS.account, CREDENTIAL_MULTIPLE_HOSTS.hosts[0], CREDENTIAL_MULTIPLE_HOSTS.service)) - .to.be.rejectedWith(/No auth found|No credentials found/) - await expect(getAuth(CREDENTIAL_MULTIPLE_HOSTS.account, CREDENTIAL_MULTIPLE_HOSTS.hosts[1], CREDENTIAL_MULTIPLE_HOSTS.service)) - .to.be.rejectedWith(/No auth found|No credentials found/) - }) - - it('updates entry when host already has credentials', async function () { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - await saveAuth(CREDENTIAL.account, 'new-token', CREDENTIAL.hosts, CREDENTIAL.service) - - const auth = await getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service) - expect(auth).to.deep.equal({account: CREDENTIAL.account, token: 'new-token'}) - }) - - it('skips credential store', async function () { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - - const accounts = await listKeychainAccounts(CREDENTIAL.service) - expect(accounts).to.deep.equal([]) - }) - - it('errors when password is empty', async function () { - await saveAuth(CREDENTIAL.account, '', CREDENTIAL.hosts, CREDENTIAL.service) - - await expect(getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service)) - .to.be.rejectedWith(/No auth found|No credentials found/) - }) - }) - - describe('native credential store', function () { - before(function () { - delete process.env.HEROKU_NETRC_WRITE - }) - - afterEach(async function () { - await cleanupCredentialStore() - }) - - it('saves and retrieves an entry', async function () { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - - const auth = await getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service) - expect(auth).to.deep.equal({account: CREDENTIAL.account, token: CREDENTIAL.token}) - }) - - it('removes an entry', async function () { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - await removeAuth(CREDENTIAL.account, CREDENTIAL.hosts, CREDENTIAL.service) - - await expect(getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service)).to.be.rejectedWith(/No auth found|No credentials found/) - }) - - it('updates entry when account already has credentials', async function () { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - await saveAuth(CREDENTIAL.account, 'new-token', CREDENTIAL.hosts, CREDENTIAL.service) - - const auth = await getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service) - expect(auth).to.deep.equal({account: CREDENTIAL.account, token: 'new-token'}) - }) - - it('lists accounts for a single service', async function () { - const accountA = CREDENTIAL.account - const accountB = `second-${accountA}` - - await saveAuth(accountA, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - await saveAuth(accountB, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - await saveAuth(CREDENTIAL_ALTERNATE_SERVICE.account, CREDENTIAL_ALTERNATE_SERVICE.token, CREDENTIAL_ALTERNATE_SERVICE.hosts, CREDENTIAL_ALTERNATE_SERVICE.service) - - const accounts = await listKeychainAccounts(CREDENTIAL.service) - expect(accounts).to.have.lengthOf(2) - expect(accounts).to.include(accountA) - expect(accounts).to.include(accountB) - expect(accounts).to.not.include(CREDENTIAL_ALTERNATE_SERVICE.account) - }) - - it('skips netrc', async function () { - let netrcHasCredentials = false - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - - const netrc = new Netrc() - await netrc.load() - for (const host of CREDENTIAL.hosts) { - if (Object.hasOwn(netrc.machines, host)) { - netrcHasCredentials = true - } - } - - expect(netrcHasCredentials).to.be.false - }) - }) - - describe('native credential store + netrc fallback', function () { - before(function () { - delete process.env.HEROKU_NETRC_WRITE - }) - - afterEach(async function () { - await cleanupCredentialStore() - await cleanupDefaultNetrc() - }) - - it('removes from both stores when switching from netrc mode to keychain mode', async function () { - // Login in netrc mode - process.env.HEROKU_NETRC_WRITE = 'TRUE' - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - delete process.env.HEROKU_NETRC_WRITE - - // Login in keychain mode (writes to keychain, old netrc credential remains) - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - - // Logout should clean both stores - await removeAuth(CREDENTIAL.account, CREDENTIAL.hosts, CREDENTIAL.service) - - // Verify both stores are cleaned - await expect(getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service)) - .to.be.rejectedWith(/No auth found|No credentials found/) - }) - - it('saves to netrc when credential store fails', async function () { - // Set up fake credential store command for the current platform - const fakeSetup = setupFakeCredentialStore() - - if (!fakeSetup) { - // Skip if credential store not available on this platform - this.skip() - } - - try { - await saveAuth(CREDENTIAL.account, CREDENTIAL.token, CREDENTIAL.hosts, CREDENTIAL.service) - - const netrc = new Netrc() - await netrc.load() - for (const host of CREDENTIAL.hosts) { - expect(netrc.machines[host]).to.deep.equal({login: CREDENTIAL.account, password: CREDENTIAL.token}) - } - } finally { - fakeSetup?.cleanup() - } - }) - - it('errors when credentials are missing from credential store and netrc', async function () { - await expect(getAuth(CREDENTIAL.account, CREDENTIAL.hosts[0], CREDENTIAL.service)) - .to.be.rejectedWith(/No auth found|No credentials found/) - }) - }) -}) diff --git a/test/credential-manager/compatibility.test.ts b/test/credential-manager/compatibility.test.ts deleted file mode 100644 index 26bfe381..00000000 --- a/test/credential-manager/compatibility.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import * as externalCredentialManager from '@heroku/heroku-credential-manager' -import {expect} from 'chai' -import {execFileSync} from 'node:child_process' -import {createHash} from 'node:crypto' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import {restore, stub} from 'sinon' - -import * as credentialManagerCore from '../../src/credential-manager-core/index.js' -import {credentialSentrySdk} from '../../src/credential-manager-core/lib/cli-command-telemetry.js' -import * as credentialStorageSelector from '../../src/credential-manager-core/lib/credential-storage-selector.js' -import * as loginState from '../../src/credential-manager-core/lib/login-state.js' -import netrc, * as netrcParser from '../../src/credential-manager-core/lib/netrc-parser.js' -import { - getAuth as getFacadeAuth, - removeAuth, - setCredentialManagerProvider, -} from '../../src/credential-manager.js' -import * as commandRoot from '../../src/index.js' - -const activeRoot = path.resolve('src/credential-manager-core') -const archivedRoot = path.resolve('src/deprecated/credential-manager-core') -const fixtureRoot = path.resolve('test/credential-manager/fixtures') - -const compatibilityModules = [ - 'credential-handlers/linux-handler.ts', - 'credential-handlers/macos-handler.ts', - 'credential-handlers/netrc-handler.ts', - 'credential-handlers/windows-handler.ts', - 'index.ts', - 'lib/credential-manager-adapter.ts', - 'lib/credential-storage-selector.ts', - 'lib/login-state.ts', - 'lib/netrc-parser.ts', - 'lib/types.ts', -] - -const externalRuntimeExports = [ - 'CredentialStore', - 'LinuxHandler', - 'MacOSHandler', - 'Netrc', - 'NetrcHandler', - 'WindowsHandler', - 'deleteLoginState', - 'getCredentialHandler', - 'getNativeCredentialStore', - 'getStorageConfig', - 'parse', - 'readLoginState', - 'writeLoginState', -] as const - -const packageJsonLookup = "join(dir, '../../../package.json')" -const relocatedPackageJsonLookup = "join(dir, '../../../../package.json')" - -type StorageBaseline = { - allowedRelocations: Array<{archived: string; baseline: string; path: string}>; - files: Array<{bytes: number; path: string; sha256: string}>; - packageVersion: string; - provenance: {commit: string; derivation: string; ref: string; sourceRoot: string}; - schemaVersion: number; -} - -function listFiles(root: string, relativeRoot = ''): string[] { - return fs.readdirSync(path.join(root, relativeRoot), {withFileTypes: true}).flatMap(entry => { - const relativePath = path.join(relativeRoot, entry.name) - return entry.isDirectory() ? listFiles(root, relativePath) : [relativePath.split(path.sep).join('/')] - }) -} - -function sha256(source: string): string { - return createHash('sha256').update(source).digest('hex') -} - -function enableCredentialTelemetry() { - delete process.env.CI - process.env.NODE_ENV = 'development' - delete process.env.IS_HEROKU_TEST_ENV - delete process.env.DISABLE_TELEMETRY - - const close = stub().resolves() - stub(credentialSentrySdk, 'getClient').returns({close} as unknown as NonNullable>) - const captureException = stub(credentialSentrySdk, 'captureException') - stub(credentialSentrySdk, 'flush').resolves(true) - return captureException -} - -function expectCredentialTelemetry( - captureException: ReturnType, - error: Error, - operation: 'getAuth' | 'listKeychainAccounts' | 'removeAuth' | 'saveAuth', -): void { - expect(captureException.calledOnce).to.equal(true) - expect(captureException.firstCall.args).to.deep.equal([ - error, - { - tags: { - component: 'heroku-cli-command', - credential_operation: operation, - credential_store: externalCredentialManager.CredentialStore.MacOSKeychain, - }, - }, - ]) -} - -describe('credential-manager compatibility', function () { - afterEach(function () { - setCredentialManagerProvider({ - getAuth: credentialManagerCore.getAuth, - removeAuth: credentialManagerCore.removeAuth, - saveAuth: credentialManagerCore.saveAuth, - }) - restore() - process.env.NODE_ENV = 'test' - }) - - it('reports saveAuth native backend failures and preserves netrc fallback behavior', async function () { - stub(process, 'platform').value('darwin') - const error = new Error('native save failed') - stub(externalCredentialManager.MacOSHandler.prototype, 'saveAuth').throws(error) - const netrcSave = stub(externalCredentialManager.NetrcHandler.prototype, 'saveAuthForHosts').resolves() - const captureException = enableCredentialTelemetry() - - await credentialManagerCore.saveAuth('user@example.com', 'token', ['api.heroku.com'], 'custom-service') - - expect(netrcSave.calledOnceWithExactly( - {login: 'user@example.com', password: 'token'}, - ['api.heroku.com'], - )).to.equal(true) - expectCredentialTelemetry(captureException, error, 'saveAuth') - }) - - it('reports surfaced getAuth native backend failures without falling back', async function () { - stub(process, 'platform').value('darwin') - const error = new Error('native read failed') - stub(externalCredentialManager.MacOSHandler.prototype, 'getAuth').throws(error) - const netrcGet = stub(externalCredentialManager.NetrcHandler.prototype, 'getAuth') - const captureException = enableCredentialTelemetry() - setCredentialManagerProvider({ - getAuth: credentialManagerCore.getAuth, - removeAuth: credentialManagerCore.removeAuth, - saveAuth: credentialManagerCore.saveAuth, - }) - - let caught: unknown - try { - await getFacadeAuth('user@example.com', 'api.heroku.com', 'custom-service') - } catch (error_) { - caught = error_ - } - - expect(caught).to.equal(error) - expect(netrcGet.called).to.equal(false) - expectCredentialTelemetry(captureException, error, 'getAuth') - }) - - it('does not report NativeCredentialNotFoundError during getAuth fallback', async function () { - stub(process, 'platform').value('darwin') - const missing = new externalCredentialManager.NativeCredentialNotFoundError('missing') - stub(externalCredentialManager.MacOSHandler.prototype, 'getAuth').throws(missing) - stub(externalCredentialManager.NetrcHandler.prototype, 'getAuth').resolves({ - login: 'user@example.com', - password: 'netrc-token', - }) - const captureException = enableCredentialTelemetry() - - const auth = await credentialManagerCore.getAuth('user@example.com', 'api.heroku.com') - - expect(auth).to.deep.equal({account: 'user@example.com', token: 'netrc-token'}) - expect(captureException.called).to.equal(false) - }) - - it('reports listKeychainAccounts backend failures and preserves the empty result', async function () { - stub(process, 'platform').value('darwin') - const error = new Error('native list failed') - stub(externalCredentialManager.MacOSHandler.prototype, 'listAccounts').throws(error) - const captureException = enableCredentialTelemetry() - - const accounts = await commandRoot.listKeychainAccounts('custom-service') - - expect(accounts).to.deep.equal([]) - expectCredentialTelemetry(captureException, error, 'listKeychainAccounts') - }) - - it('reports removeAuth backend failures and preserves netrc cleanup arguments', async function () { - stub(process, 'platform').value('darwin') - const error = new Error('native remove failed') - stub(externalCredentialManager.MacOSHandler.prototype, 'getAuth').throws(error) - const nativeRemove = stub(externalCredentialManager.MacOSHandler.prototype, 'removeAuth') - const netrcRemove = stub(externalCredentialManager.NetrcHandler.prototype, 'removeAuthForHosts').resolves() - const captureException = enableCredentialTelemetry() - - await commandRoot.removeAuth( - 'user@example.com', - ['api.heroku.com'], - 'custom-service', - 'expected-token', - ) - - expect(nativeRemove.called).to.equal(false) - expect(netrcRemove.calledOnceWithExactly( - ['api.heroku.com'], - 'user@example.com', - 'expected-token', - )).to.equal(true) - expectCredentialTelemetry(captureException, error, 'removeAuth') - }) - - it('preserves external runtime identities except command-owned storage adapters', async function () { - for (const name of externalRuntimeExports) { - expect(credentialManagerCore[name], `credential-manager-core ${name}`).to.equal(externalCredentialManager[name]) - expect(commandRoot[name], `command root ${name}`).to.equal(externalCredentialManager[name]) - } - - for (const name of ['getAuth', 'listKeychainAccounts', 'removeAuth', 'saveAuth'] as const) { - expect(commandRoot[name], `command root ${name}`).to.equal(credentialManagerCore[name]) - expect(commandRoot[name], `${name} is command-owned`).to.not.equal(externalCredentialManager[name]) - } - - const handlers = await Promise.all([ - import('../../src/credential-manager-core/credential-handlers/linux-handler.js'), - import('../../src/credential-manager-core/credential-handlers/macos-handler.js'), - import('../../src/credential-manager-core/credential-handlers/netrc-handler.js'), - import('../../src/credential-manager-core/credential-handlers/windows-handler.js'), - ]) - - expect(handlers[0].LinuxHandler).to.equal(externalCredentialManager.LinuxHandler) - expect(handlers[1].MacOSHandler).to.equal(externalCredentialManager.MacOSHandler) - expect(handlers[2].NetrcHandler).to.equal(externalCredentialManager.NetrcHandler) - expect(handlers[3].WindowsHandler).to.equal(externalCredentialManager.WindowsHandler) - expect(credentialStorageSelector.CredentialStore).to.equal(externalCredentialManager.CredentialStore) - expect(credentialStorageSelector.getStorageConfig).to.equal(externalCredentialManager.getStorageConfig) - expect(loginState.readLoginState).to.equal(externalCredentialManager.readLoginState) - expect(netrcParser.Netrc).to.equal(externalCredentialManager.Netrc) - expect(netrcParser.parse).to.equal(externalCredentialManager.parse) - expect(netrc).to.be.instanceOf(externalCredentialManager.Netrc) - }) - - it('compiles the historical optional AuthEntry declarations', function () { - const compiler = path.resolve('node_modules/typescript/bin/tsc') - execFileSync(process.execPath, [ - compiler, - // TypeScript 6 errors (TS5112) when files are passed on the command line - // while a tsconfig.json is present; this fixture is compiled in isolation - // with explicit flags, so ignore the repo config. - '--ignoreConfig', - '--noEmit', - '--strict', - '--skipLibCheck', - '--target', - 'es2022', - '--module', - 'NodeNext', - '--moduleResolution', - 'NodeNext', - path.join(fixtureRoot, 'source-declaration-compatibility.ts'), - ], {stdio: 'pipe'}) - }) - - it('forwards expectedToken exactly as the provider removeAuth fourth argument', async function () { - const provider = { - getAuth: stub().resolves({account: 'user@example.com', token: 'token'}), - removeAuth: stub().resolves(), - saveAuth: stub().resolves(), - } - setCredentialManagerProvider(provider) - - await removeAuth('user@example.com', ['api.heroku.com'], 'custom-service', 'expected-token') - - expect(provider.removeAuth.calledOnceWithExactly( - 'user@example.com', - ['api.heroku.com'], - 'custom-service', - 'expected-token', - )).to.equal(true) - }) - - it('archives the exact command-v13.2.0 storage sources except the documented lookup relocation', function () { - const baseline = JSON.parse(fs.readFileSync( - path.join(fixtureRoot, 'v13.2.0-storage-baseline.json'), - 'utf8', - )) as StorageBaseline - - expect(baseline).to.include({packageVersion: '13.2.0', schemaVersion: 1}) - expect(baseline.provenance).to.deep.equal({ - commit: '7c77d2c15600bfc2a5c0087b9bf7651c0fc1cc17', - derivation: 'SHA-256 and UTF-8 byte length from each file at command-v13.2.0', - ref: 'command-v13.2.0', - sourceRoot: 'src/credential-manager-core', - }) - expect(baseline.allowedRelocations).to.deep.equal([{ - archived: relocatedPackageJsonLookup, - baseline: packageJsonLookup, - path: 'lib/cli-command-telemetry.ts', - }]) - expect(listFiles(archivedRoot).toSorted()).to.deep.equal(baseline.files.map(file => file.path).toSorted()) - - for (const file of baseline.files) { - const archivedSource = fs.readFileSync(path.join(archivedRoot, file.path), 'utf8') - const normalizedSource = archivedSource.replace(relocatedPackageJsonLookup, () => packageJsonLookup) - expect(Buffer.byteLength(normalizedSource), `${file.path} byte length`).to.equal(file.bytes) - expect(sha256(normalizedSource), `${file.path} SHA-256`).to.equal(file.sha256) - - if (file.path === 'lib/cli-command-telemetry.ts') { - expect(archivedSource).to.include(relocatedPackageJsonLookup) - expect(archivedSource).to.not.include(packageJsonLookup) - } else { - expect(archivedSource).to.equal(normalizedSource) - } - } - }) - - it('keeps the active graph dormant from archived implementation imports', function () { - for (const relativePath of compatibilityModules) { - const source = fs.readFileSync(path.join(activeRoot, relativePath), 'utf8') - expect(source, relativePath).to.include('@heroku/heroku-credential-manager') - expect(source, relativePath).to.not.include('deprecated/credential-manager-core') - } - - const activeSources = listFiles(path.resolve('src')) - .filter(relativePath => relativePath.endsWith('.ts') && !relativePath.startsWith('deprecated/')) - for (const relativePath of activeSources) { - const source = fs.readFileSync(path.resolve('src', relativePath), 'utf8') - expect(source, relativePath).to.not.match(/(?:from|import\()\s*['"][^'"]*deprecated\//) - } - }) - - it('emits all historical paths without emitting lib/deprecated', function () { - this.timeout(20_000) - const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'heroku-command-compatibility-build-')) - const outputRoot = path.join(temporaryRoot, 'lib') - const compiler = path.resolve('node_modules/typescript/bin/tsc') - - try { - execFileSync(process.execPath, [ - compiler, - '-p', - path.resolve('tsconfig.json'), - '--outDir', - outputRoot, - ], {stdio: 'pipe'}) - - for (const relativePath of [...compatibilityModules, 'lib/cli-command-telemetry.ts']) { - const emittedPath = relativePath.replace(/\.ts$/, '.js') - const declarationPath = emittedPath.replace(/\.js$/, '.d.ts') - expect(fs.existsSync(path.join(outputRoot, 'credential-manager-core', emittedPath)), emittedPath).to.equal(true) - expect(fs.existsSync(path.join(outputRoot, 'credential-manager-core', declarationPath)), emittedPath).to.equal(true) - } - - expect(fs.existsSync(path.join(outputRoot, 'deprecated'))).to.equal(false) - } finally { - fs.rmSync(temporaryRoot, {force: true, recursive: true}) - } - }) -}) diff --git a/test/credential-manager/credential-handlers/linux-handler.test.ts b/test/credential-manager/credential-handlers/linux-handler.test.ts deleted file mode 100644 index e0d698d5..00000000 --- a/test/credential-manager/credential-handlers/linux-handler.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import {expect} from 'chai' -import childProcess from 'node:child_process' -import sinon from 'sinon' - -import {LinuxHandler} from '../../../src/deprecated/credential-manager-core/credential-handlers/linux-handler.js' - -describe('LinuxHandler', function () { - let execSyncStub: sinon.SinonStub - let spawnSyncStub: sinon.SinonStub - let handler: LinuxHandler - - beforeEach(function () { - execSyncStub = sinon.stub(childProcess, 'execSync') - spawnSyncStub = sinon.stub(childProcess, 'spawnSync') - handler = new LinuxHandler() - }) - - afterEach(function () { - execSyncStub.restore() - spawnSyncStub.restore() - }) - - describe('getAuth', function () { - it('should call execSync with the correct arguments to retrieve the token', function () { - execSyncStub.returns('my-secret-token') - const token = handler.getAuth('test@example.com', 'heroku-cli') - expect(execSyncStub.args[0][0]).to.contain('secret-tool lookup service "heroku-cli" account "test@example.com"') - expect(token).to.equal('my-secret-token') - }) - - it('should throw an error when token is empty', function () { - execSyncStub.returns('') - expect(() => handler.getAuth('test@example.com', 'heroku-cli')).to.throw('Failed to retrieve token from Linux keyring: Token not found') - }) - - it('should throw an error when retrieval fails', function () { - execSyncStub.throws(new Error('Permission denied')) - expect(() => handler.getAuth('test@example.com', 'heroku-cli')).to.throw('Failed to retrieve token from Linux keyring: Permission denied') - }) - - it('should scrub sensitive data from error messages', function () { - const err = new Error('Command failed: secret-tool lookup service "heroku-cli" account "test@example.com"') - execSyncStub.throws(err) - - try { - handler.getAuth('test@example.com', 'heroku-cli') - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to retrieve token from Linux keyring') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('test@example.com') - } - }) - }) - - describe('listAccounts', function () { - it('should call spawnSync with the correct arguments to list accounts', function () { - spawnSyncStub.returns({ - error: undefined, - status: 0, - stderr: '', - }) - handler.listAccounts('heroku-cli') - expect(spawnSyncStub.calledOnce).to.be.true - expect(spawnSyncStub.args[0][0]).to.equal('secret-tool') - expect(spawnSyncStub.args[0][1]).to.deep.equal(['search', '--all', 'service', 'heroku-cli']) - }) - - it('should return an array of accounts when multiple credentials are found', function () { - const mockStderr = ` -attribute.account = user1@example.com -attribute.service = heroku-cli -attribute.account = user2@example.com -attribute.service = heroku-cli -` - spawnSyncStub.returns({ - error: undefined, - status: 0, - stderr: mockStderr, - }) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['user1@example.com', 'user2@example.com']) - }) - - it('should return a single account when only one credential is found', function () { - const mockStderr = ` -attribute.account = test@example.com -attribute.service = heroku-cli -` - spawnSyncStub.returns({ - error: undefined, - status: 0, - stderr: mockStderr, - }) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['test@example.com']) - }) - - it('should return an empty array when no credentials are found', function () { - spawnSyncStub.returns({ - error: undefined, - status: 0, - stderr: '', - }) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal([]) - }) - - it('should throw an error when the search command fails', function () { - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: 'Permission denied', - }) - expect(() => handler.listAccounts('heroku-cli')).to.throw('Failed to list accounts in Linux keyring: Permission denied') - }) - - it('should throw an error when spawnSync encounters a system error', function () { - spawnSyncStub.returns({ - error: new Error('ENOENT: secret-tool command not found'), - status: null, - stderr: '', - }) - expect(() => handler.listAccounts('heroku-cli')).to.throw('Failed to list accounts in Linux keyring: ENOENT: secret-tool command not found') - }) - }) - - describe('removeAuth', function () { - it('should call spawnSync with the correct arguments to remove the token', function () { - spawnSyncStub.returns({ - error: undefined, - status: 0, - stderr: '', - }) - handler.removeAuth('test@example.com', 'heroku-cli') - expect(spawnSyncStub.calledOnce).to.be.true - expect(spawnSyncStub.args[0][0]).to.equal('secret-tool') - expect(spawnSyncStub.args[0][1]).to.deep.equal(['clear', 'service', 'heroku-cli', 'account', 'test@example.com']) - }) - - it('should throw an error when removal fails', function () { - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: 'Permission denied', - }) - expect(() => { - handler.removeAuth('test@example.com', 'heroku-cli') - }).to.throw('Failed to remove token from Linux keyring: Permission denied') - }) - - it('should return when no matching credential exists', function () { - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: 'No matching credentials\n', - }) - expect(() => { - handler.removeAuth('missing@example.com', 'heroku-cli') - }).to.not.throw() - }) - - it('should scrub sensitive data from error messages', function () { - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: 'secret-tool clear service "heroku-cli" account "user@example.com" failed', - }) - - try { - handler.removeAuth('user@example.com', 'heroku-cli') - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to remove token from Linux keyring') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('user@example.com') - } - }) - - it('should return when secret-tool exits 1 with empty stderr', function () { - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: '', - }) - expect(() => { - handler.removeAuth('missing@example.com', 'heroku-cli') - }).to.not.throw() - }) - - it('should return when secret-tool exits 1 with whitespace-only stderr', function () { - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: ' \n', - }) - expect(() => { - handler.removeAuth('missing@example.com', 'heroku-cli') - }).to.not.throw() - }) - - it('should pass LC_ALL=C in the environment', function () { - spawnSyncStub.returns({ - error: undefined, - status: 0, - stderr: '', - }) - handler.removeAuth('test@example.com', 'heroku-cli') - const options = spawnSyncStub.args[0][2] - expect(options.env.LC_ALL).to.equal('C') - }) - }) - - describe('saveAuth', function () { - it('should call spawnSync with the correct arguments to save/update the token', function () { - spawnSyncStub.returns({ - error: undefined, - status: 0, - stderr: '', - }) - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - handler.saveAuth(authMock) - - expect(spawnSyncStub.calledOnce).to.be.true - expect(spawnSyncStub.args[0][0]).to.equal('secret-tool') - expect(spawnSyncStub.args[0][1]).to.deep.equal([ - 'store', - '--label=Heroku CLI', - 'service', - 'heroku-cli', - 'account', - 'test@example.com', - ]) - expect(spawnSyncStub.args[0][2].input).to.equal('mytoken') - }) - - it('should throw an error when save command fails', function () { - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: 'error communicating with Secret Service', - }) - expect(() => { - handler.saveAuth(authMock) - }).to.throw('Failed to store token in Linux keyring: error communicating with Secret Service') - }) - - it('should throw an error when spawnSync encounters a system error', function () { - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - spawnSyncStub.returns({ - error: new Error('ENOENT: secret-tool command not found'), - status: null, - stderr: '', - }) - expect(() => { - handler.saveAuth(authMock) - }).to.throw('Failed to store token in Linux keyring: ENOENT: secret-tool command not found') - }) - - it('should use fallback error message when stderr is empty', function () { - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: '', // Empty stderr triggers fallback - }) - expect(() => { - handler.saveAuth(authMock) - }).to.throw('Failed to store token in Linux keyring: Unknown error') - }) - - it('should scrub sensitive data from error messages', function () { - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - spawnSyncStub.returns({ - error: undefined, - status: 1, - stderr: 'Command failed: secret-tool store service "heroku-cli" account "user@example.com"', - }) - - try { - handler.saveAuth(authMock) - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to store token in Linux keyring') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('user@example.com') - } - }) - }) -}) diff --git a/test/credential-manager/credential-handlers/macos-handler.test.ts b/test/credential-manager/credential-handlers/macos-handler.test.ts deleted file mode 100644 index 850ff61c..00000000 --- a/test/credential-manager/credential-handlers/macos-handler.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import {expect} from 'chai' -import childProcess from 'node:child_process' -import sinon from 'sinon' - -import {MacOSHandler} from '../../../src/deprecated/credential-manager-core/credential-handlers/macos-handler.js' - -describe('MacOSHandler', function () { - let execSyncStub: sinon.SinonStub - let handler: MacOSHandler - - beforeEach(function () { - execSyncStub = sinon.stub(childProcess, 'execSync') - handler = new MacOSHandler() - }) - - afterEach(function () { - execSyncStub.restore() - }) - - describe('getAuth', function () { - it('should call execSync with the correct arguments to retrieve the token', function () { - execSyncStub.returns('my-secret-token') - const token = handler.getAuth('test@example.com', 'heroku-cli') - expect(execSyncStub.args[0][0]).to.contain('find-generic-password -a "test@example.com" -s "heroku-cli"') - expect(token).to.equal('my-secret-token') - }) - - it('should throw an error when token is empty', function () { - execSyncStub.returns('') - expect(() => handler.getAuth('test@example.com', 'heroku-cli')).to.throw('Failed to retrieve token from macOS Keychain: Token not found') - }) - - it('should throw an error when retrieval fails', function () { - execSyncStub.throws(new Error('Permission denied')) - expect(() => handler.getAuth('test@example.com', 'heroku-cli')).to.throw('Failed to retrieve token from macOS Keychain: Permission denied') - }) - - it('should scrub sensitive data from error messages', function () { - const err = new Error('Command failed: security find-generic-password -a "test@example.com" -s "heroku-cli" -w') - execSyncStub.throws(err) - - try { - handler.getAuth('test@example.com', 'heroku-cli') - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to retrieve token from macOS Keychain') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('test@example.com') - } - }) - }) - - describe('listAccounts', function () { - it('should call execSync with the correct arguments to list accounts', function () { - execSyncStub.returns('') - handler.listAccounts('heroku-cli') - - expect(execSyncStub.args[0][0]).to.equal('security dump-keychain') - }) - - it('should return an array of accounts when multiple credentials are found', function () { - const mockOutput = ` -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - 0x00000007 ="heroku-cli" - "acct"="user1@example.com" - "svce"="heroku-cli" -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - 0x00000007 ="heroku-cli" - "acct"="user2@example.com" - "svce"="heroku-cli" -` - execSyncStub.returns(mockOutput) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['user1@example.com', 'user2@example.com']) - }) - - it('should filter by service name when multiple services exist', function () { - const mockOutput = ` -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - "acct"="test@example.com" - "svce"="heroku-cli" -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - "acct"="wrong@example.com" - "svce"="other-service" -` - execSyncStub.returns(mockOutput) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['test@example.com']) - }) - - it('should only process generic password entries', function () { - const mockOutput = ` -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "cer" -attributes: - "acct"="test1@example.com" - "svce"="" -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - "acct"="test2@example.com" - "svce"="heroku-cli" -` - execSyncStub.returns(mockOutput) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['test2@example.com']) - }) - - it('should return a single account when only one credential is found', function () { - const mockOutput = ` -keychain: "/Users/test/Library/Keychains/login.keychain-db" -version: 512 -class: "genp" -attributes: - 0x00000007 ="heroku-cli" - "acct"="test@example.com" - "svce"="heroku-cli" -` - execSyncStub.returns(mockOutput) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['test@example.com']) - }) - - it('should return an empty array when no credentials are found', function () { - execSyncStub.returns('') - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal([]) - }) - - it('should throw an error when the search command fails', function () { - execSyncStub.throws(new Error('Permission denied')) - expect(() => handler.listAccounts('heroku-cli')).to.throw('Failed to list accounts in macOS Keychain: Permission denied') - }) - }) - - describe('removeAuth', function () { - it('should call execSync with the correct arguments to remove the token', function () { - execSyncStub.returns('') - handler.removeAuth('test@example.com', 'heroku-cli') - expect(execSyncStub.args[0][0]).to.contain('delete-generic-password -a "test@example.com" -s "heroku-cli"') - }) - - it('should throw an error when removal fails', function () { - execSyncStub.throws(new Error('Permission denied')) - expect(() => { - handler.removeAuth('test@example.com', 'heroku-cli') - }).to.throw('Failed to remove token from macOS Keychain: Permission denied') - }) - - it('should return when the generic password does not exist (exit 44)', function () { - const err = new Error('Command failed') as Error & {status?: number} - err.status = 44 - execSyncStub.throws(err) - expect(() => { - handler.removeAuth('missing@example.com', 'heroku-cli') - }).to.not.throw() - }) - - it('should scrub sensitive data from error messages', function () { - const err = new Error('Command failed: security delete-generic-password -a "user@example.com" -s "heroku-cli"') - execSyncStub.throws(err) - - try { - handler.removeAuth('user@example.com', 'heroku-cli') - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to remove token from macOS Keychain') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('user@example.com') - } - }) - }) - - describe('saveAuth', function () { - it('should call execSync with the correct arguments to save/update the token', function () { - execSyncStub.returns('') - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - handler.saveAuth(authMock) - expect(execSyncStub.args[0][0]).to.contain('add-generic-password -U -a "test@example.com" -s "heroku-cli" -w "mytoken"') - }) - - it('should throw an error when add command fails', function () { - execSyncStub.throws(new Error('Permission denied')) - - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - expect(() => { - handler.saveAuth(authMock) - }).to.throw('Failed to store token in macOS Keychain: Permission denied') - }) - - it('should scrub sensitive data from error messages', function () { - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - const err = new Error(`Command failed: security add-generic-password -U -a "${authMock.account}" -s "${authMock.service}" -w "${authMock.token}"`) - execSyncStub.throws(err) - - try { - handler.saveAuth(authMock) - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to store token in macOS Keychain') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('test@example.com') - expect((error as Error).message).to.not.include('mytoken') - } - }) - }) -}) diff --git a/test/credential-manager/credential-handlers/netrc-handler.test.ts b/test/credential-manager/credential-handlers/netrc-handler.test.ts deleted file mode 100644 index 8d598d96..00000000 --- a/test/credential-manager/credential-handlers/netrc-handler.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import {expect, use} from 'chai' -import chaiAsPromised from 'chai-as-promised' -import fs from 'fs-extra' -import path from 'node:path' - -import {NetrcHandler} from '../../../src/deprecated/credential-manager-core/credential-handlers/netrc-handler.js' -import {type MachineToken} from '../../../src/deprecated/credential-manager-core/lib/netrc-parser.js' -import {restoreNetrcStub, stubNetrc} from '../helpers/netrc-stub.js' - -use(chaiAsPromised) - -describe('NetrcHandler', function () { - beforeEach(stubNetrc) - - afterEach(restoreNetrcStub) - - describe('get auth', function () { - it('should get auth for a specified host', async function () { - const handler = new NetrcHandler() - const auth = await handler.getAuth('api.heroku.com') - expect(auth).to.deep.equal({login: 'test@example.com', password: 'mypass'}) - }) - - it('should error if no auth is saved for the specified host', async function () { - const handler = new NetrcHandler() - await expect(handler.getAuth('fake.heroku.com')).to.be.rejectedWith('No auth found for fake.heroku.com') - }) - }) - - describe('remove auth', function () { - it('should remove auth for a specified host', async function () { - const handler = new NetrcHandler() - await handler.removeAuth('api.heroku.com') - expect(handler.netrc.machines['api.heroku.com']).to.be.undefined - }) - - it('should do nothing if no auth is saved for the specified host', async function () { - const handler = new NetrcHandler() - await handler.removeAuth('fake.heroku.com') - expect(handler.netrc.machines['fake.heroku.com']).to.be.undefined - }) - }) - - describe('save auth', function () { - it('should save auth for a specified host', async function () { - const handler = new NetrcHandler() - await handler.saveAuth({login: 'test@example.com', password: 'mypass'}, 'new.heroku.com') - expect(handler.netrc.machines['new.heroku.com']).to.deep.equal({login: 'test@example.com', password: 'mypass'}) - }) - - it('should remove method and org entries for the specified host if present', async function () { - const handler = new NetrcHandler() - await handler.saveAuth({login: 'test@example.com', password: 'mypass'}, 'api.heroku.com') - handler.netrc.machines['api.heroku.com'].method = 'gpg' - handler.netrc.machines['api.heroku.com'].org = 'test' - await handler.saveAuth({login: 'test@example.com', password: 'mypass'}, 'api.heroku.com') - expect(handler.netrc.machines['api.heroku.com']).to.deep.equal({login: 'test@example.com', password: 'mypass'}) - expect(handler.netrc.machines['api.heroku.com'].method).to.be.undefined - expect(handler.netrc.machines['api.heroku.com'].org).to.be.undefined - }) - - it('adds internal-whitespace value if _tokens array is present for specified host', async function () { - const handler = new NetrcHandler() - await handler.saveAuth({login: 'test@example.com', password: 'mypass'}, 'api.heroku.com') - handler.netrc.machines._tokens = [{host: 'api.heroku.com', props: {}, type: 'machine'}] as MachineToken[] - await handler.saveAuth({login: 'test@example.com', password: 'mypass'}, 'api.heroku.com') - expect((handler.netrc.machines._tokens[0] as MachineToken).internalWhitespace).to.equal('\n ') - }) - }) - - describe('batch netrc (temp file, no prototype stub)', function () { - const tmpDir = path.resolve('tmp/netrc-handler-batch') - let netrcPath: string - - beforeEach(async function () { - await fs.mkdirp(tmpDir) - netrcPath = path.resolve(tmpDir, `n-${Date.now()}-${Math.random().toString(36).slice(2)}`) - await fs.writeFile(netrcPath, '', 'utf8') - }) - - afterEach(async function () { - await fs.remove(tmpDir) - }) - - it('saveAuthForHosts writes multiple hosts with a single save', async function () { - let saveCalls = 0 - const handler = new NetrcHandler(netrcPath) - const origSave = handler.netrc.save.bind(handler.netrc) - handler.netrc.save = async () => { - saveCalls++ - return origSave() - } - - await handler.saveAuthForHosts({login: 'u@e.com', password: 'tok'}, ['a.com', 'b.com']) - expect(saveCalls).to.equal(1) - expect(handler.netrc.machines['a.com']).to.deep.equal({login: 'u@e.com', password: 'tok'}) - expect(handler.netrc.machines['b.com']).to.deep.equal({login: 'u@e.com', password: 'tok'}) - }) - - it('removeAuthForHosts removes multiple hosts with a single save', async function () { - let saveCalls = 0 - const handler = new NetrcHandler(netrcPath) - await handler.saveAuthForHosts({login: 'u@e.com', password: 'tok'}, ['api.heroku.com', 'git.heroku.com']) - - const origSave = handler.netrc.save.bind(handler.netrc) - handler.netrc.save = async () => { - saveCalls++ - return origSave() - } - - await handler.removeAuthForHosts(['api.heroku.com', 'git.heroku.com']) - expect(saveCalls).to.equal(1) - expect(handler.netrc.machines['api.heroku.com']).to.be.undefined - expect(handler.netrc.machines['git.heroku.com']).to.be.undefined - }) - }) -}) diff --git a/test/credential-manager/credential-handlers/windows-handler.test.ts b/test/credential-manager/credential-handlers/windows-handler.test.ts deleted file mode 100644 index 447be340..00000000 --- a/test/credential-manager/credential-handlers/windows-handler.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import {expect} from 'chai' -import childProcess from 'node:child_process' -import sinon from 'sinon' - -import {WindowsHandler} from '../../../src/deprecated/credential-manager-core/credential-handlers/windows-handler.js' - -describe('WindowsHandler', function () { - let execSyncStub: sinon.SinonStub - let handler: WindowsHandler - - beforeEach(function () { - execSyncStub = sinon.stub(childProcess, 'execSync') - handler = new WindowsHandler() - }) - - afterEach(function () { - execSyncStub.restore() - }) - - describe('getAuth', function () { - it('should call execSync with the correct arguments to retrieve the token for the specified service and account', function () { - execSyncStub.returns('my-secret-token') - const token = handler.getAuth('test@example.com', 'heroku-cli') - expect(execSyncStub.args[0][0]).to.contain('Retrieve("heroku-cli", "test@example.com")') - expect(token).to.equal('my-secret-token') - }) - - it('should throw an error when token is empty', function () { - execSyncStub.returns('') - expect(() => handler.getAuth('test@example.com', 'heroku-cli')).to.throw('Failed to retrieve token from Windows Credential Manager: Token not found') - }) - - it('should throw an error when retrieval fails', function () { - execSyncStub.throws(new Error('Permission denied')) - expect(() => handler.getAuth('test@example.com', 'heroku-cli')).to.throw('Failed to retrieve token from Windows Credential Manager: Permission denied') - }) - - it('should scrub sensitive data from error messages', function () { - const err = new Error('Command failed: $vault.Retrieve("heroku-cli", "test@example.com")') - execSyncStub.throws(err) - - try { - handler.getAuth('test@example.com', 'heroku-cli') - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to retrieve token from Windows Credential Manager') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('test@example.com') - } - }) - }) - - describe('listAccounts', function () { - it('should call execSync with the correct arguments to list accounts', function () { - execSyncStub.returns('') - handler.listAccounts('heroku-cli') - - expect(execSyncStub.args[0][0]).to.contain('FindAllByResource("heroku-cli")') - expect(execSyncStub.args[0][0]).to.contain('ForEach-Object { $_.UserName }') - }) - - it('should return an array of accounts when multiple credentials are found', function () { - const mockOutput = ` -user1@example.com -user2@example.com -` - execSyncStub.returns(mockOutput) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['user1@example.com', 'user2@example.com']) - }) - - it('should return a single account when only one credential is found', function () { - const mockOutput = 'test@example.com' - execSyncStub.returns(mockOutput) - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal(['test@example.com']) - }) - - it('should return an empty array when no credentials are found', function () { - execSyncStub.returns('') - const accounts = handler.listAccounts('heroku-cli') - - expect(accounts).to.deep.equal([]) - }) - - it('should throw an error when the search command fails', function () { - execSyncStub.throws(new Error('Permission denied')) - expect(() => handler.listAccounts('heroku-cli')).to.throw('Failed to list accounts in Windows Credential Manager: Permission denied') - }) - }) - - describe('removeAuth', function () { - it('should call execSync with the correct arguments to remove the token for the specified service and account', function () { - execSyncStub.returns('') - handler.removeAuth('test@example.com', 'heroku-cli') - expect(execSyncStub.args[0][0]).to.contain('Retrieve("heroku-cli", "test@example.com")') - expect(execSyncStub.args[0][0]).to.contain('vault.Remove') - }) - - it('should throw an error when removal fails', function () { - execSyncStub.throws(new Error('Permission denied')) - expect(() => { - handler.removeAuth('test@example.com', 'heroku-cli') - }).to.throw('Failed to remove token from Windows Credential Manager: Permission denied') - }) - - it('should return when the credential does not exist', function () { - execSyncStub.throws(new Error('Element not found')) - expect(() => { - handler.removeAuth('missing@example.com', 'heroku-cli') - }).to.not.throw() - }) - - it('should scrub sensitive data from error messages', function () { - const err = new Error('Command failed: $vault.Retrieve("heroku-cli", "test@example.com")') - execSyncStub.throws(err) - - try { - handler.removeAuth('test@example.com', 'heroku-cli') - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to remove token from Windows Credential Manager') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('test@example.com') - } - }) - }) - - describe('saveAuth', function () { - it('should call execSync with the correct arguments to save the token for the specified service and account', function () { - execSyncStub.returns('') - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - handler.saveAuth(authMock) - expect(execSyncStub.args[0][0]).to.contain('Retrieve("heroku-cli", "test@example.com")') - expect(execSyncStub.args[0][0]).to.contain('vault.Remove') - expect(execSyncStub.args[1][0]).to.contain('New-Object Windows.Security.Credentials.PasswordCredential("heroku-cli", "test@example.com", "mytoken")') - expect(execSyncStub.args[1][0]).to.contain('vault.Add') - }) - - it('should throw an error when add command fails', function () { - execSyncStub.onFirstCall().returns('') - execSyncStub.onSecondCall().throws(new Error('Permission denied')) - - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - expect(() => { - handler.saveAuth(authMock) - }).to.throw('Failed to store token in Windows Credential Manager: Permission denied') - }) - - it('should scrub sensitive data from error messages', function () { - execSyncStub.onFirstCall().returns('') - - const err = new Error('Command failed: New-Object Windows.Security.Credentials.PasswordCredential("heroku-cli", "test@example.com", "mytoken")') - execSyncStub.onSecondCall().throws(err) - - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - - try { - handler.saveAuth(authMock) - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - expect((error as Error).message).to.include('Failed to store token in Windows Credential Manager') - expect((error as Error).message).to.include('[SCRUBBED]') - expect((error as Error).message).to.not.include('test@example.com') - expect((error as Error).message).to.not.include('mytoken') - } - }) - - it('should continue to add credential when remove fails because item does not exist', function () { - execSyncStub.onFirstCall().throws(new Error('Element not found')) - execSyncStub.onSecondCall().returns('') - const authMock = { - account: 'test@example.com', - service: 'heroku-cli', - token: 'mytoken', - } - handler.saveAuth(authMock) - expect(execSyncStub.calledTwice).to.be.true - expect(execSyncStub.args[1][0]).to.contain('vault.Add') - }) - }) -}) diff --git a/test/credential-manager/fixtures/source-declaration-compatibility.ts b/test/credential-manager/fixtures/source-declaration-compatibility.ts deleted file mode 100644 index f59797e4..00000000 --- a/test/credential-manager/fixtures/source-declaration-compatibility.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type {AuthEntry as CoreAuthEntry} from '../../../src/credential-manager-core/index.js' -import type {AuthEntry as TypesAuthEntry} from '../../../src/credential-manager-core/lib/types.js' -import type {AuthEntry as FacadeAuthEntry} from '../../../src/credential-manager.js' -import type {AuthEntry as RootAuthEntry} from '../../../src/index.js' - -type Assert = T -type HistoricalAuthEntry = { - account: string | undefined; - token: string | undefined; -} -type IsEqual = ( - () => T extends Left ? 1 : 2 -) extends (() => T extends Right ? 1 : 2) ? true : false - -const historicalAuthEntry = { - account: undefined, - token: undefined, -} - -const coreAuthEntry: CoreAuthEntry = historicalAuthEntry -const facadeAuthEntry: FacadeAuthEntry = historicalAuthEntry -const rootAuthEntry: RootAuthEntry = historicalAuthEntry -const typesAuthEntry: TypesAuthEntry = historicalAuthEntry - -type HistoricalAuthEntryDeclarationsRemainCompatible = [ - Assert>, - Assert>, - Assert>, - Assert>, -] - -export { - coreAuthEntry, - facadeAuthEntry, - type HistoricalAuthEntryDeclarationsRemainCompatible, - rootAuthEntry, - typesAuthEntry, -} diff --git a/test/credential-manager/fixtures/v13.2.0-storage-baseline.json b/test/credential-manager/fixtures/v13.2.0-storage-baseline.json deleted file mode 100644 index 86c22576..00000000 --- a/test/credential-manager/fixtures/v13.2.0-storage-baseline.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "schemaVersion": 1, - "packageVersion": "13.2.0", - "provenance": { - "ref": "command-v13.2.0", - "commit": "7c77d2c15600bfc2a5c0087b9bf7651c0fc1cc17", - "sourceRoot": "src/credential-manager-core", - "derivation": "SHA-256 and UTF-8 byte length from each file at command-v13.2.0" - }, - "allowedRelocations": [ - { - "path": "lib/cli-command-telemetry.ts", - "baseline": "join(dir, '../../../package.json')", - "archived": "join(dir, '../../../../package.json')" - } - ], - "files": [ - { - "path": "credential-handlers/linux-handler.ts", - "sha256": "3b8b981aa2da8717e1a38d8c65675240b196b173753ab84976c47b14d8385fbc", - "bytes": 5777 - }, - { - "path": "credential-handlers/macos-handler.ts", - "sha256": "989b1e5f2c015248ace6ae8b9fc781f26e47992205c017424c416b1f4f560b38", - "bytes": 5053 - }, - { - "path": "credential-handlers/netrc-handler.ts", - "sha256": "404c654322a00b60068dafdf98dc49f09e288b90c968dd5e412959ba51f8723e", - "bytes": 2859 - }, - { - "path": "credential-handlers/windows-handler.ts", - "sha256": "beaadb2a7bf8aff8c2c949021f72b78e4b359f30cc19b360b852236330fec1e0", - "bytes": 6614 - }, - { - "path": "index.ts", - "sha256": "bb22fdabeba416cb33b9f7fc470b167e5bcb5bf0875c0278c6565036a67d9caa", - "bytes": 6696 - }, - { - "path": "lib/cli-command-telemetry.ts", - "sha256": "30324bd6a75f889fd925feaad7a47cbd9b0e0415681810a03e671fa7020196f9", - "bytes": 3259 - }, - { - "path": "lib/credential-storage-selector.ts", - "sha256": "050296880b1f96c3aecd95670e30419bfaca78b573d62028a5a1c86259e5ed0a", - "bytes": 2282 - }, - { - "path": "lib/login-state.ts", - "sha256": "3422d46d4c9cdf408d30484ef645c73ff6e7fde6d771eeed058f9c9d6599f16e", - "bytes": 1517 - }, - { - "path": "lib/netrc-parser.ts", - "sha256": "11b2b38ce250780c7bf6de62bacb537266f679f6038d7b7ee503471e032a1aa2", - "bytes": 12543 - }, - { - "path": "lib/types.ts", - "sha256": "c46d4f7bd0947b8721ee2c162056c9cf53893dc8e4d9ba5cddb0743d6ff5c095", - "bytes": 244 - } - ] -} diff --git a/test/credential-manager/helpers/acceptance-utils.test.ts b/test/credential-manager/helpers/acceptance-utils.test.ts deleted file mode 100644 index 9bf4e275..00000000 --- a/test/credential-manager/helpers/acceptance-utils.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import {expect} from 'chai' -import {type Context} from 'mocha' -import fs from 'node:fs' -import sinon from 'sinon' - -import {MacOSHandler, Netrc} from '../../../src/credential-manager-core/index.js' -import { - ALTERNATE_HOST_NAME, - ALTERNATE_SERVICE_NAME, - cleanupCredentialStore, - cleanupDefaultNetrc, - getAllAcceptanceHosts, - getAllAcceptanceServices, - HOST_NAME, - isPowerShellAvailable, - isSecretToolAvailable, - isSecurityAvailable, - SERVICE_NAME, - setupFakeCredentialStore, - skipUnlessAcceptance, - snapshotDefaultNetrc, -} from './acceptance-utils.js' - -describe('acceptance utils', function () { - afterEach(function () { - sinon.restore() - }) - - describe('skipUnlessAcceptance', function () { - beforeEach(function () { - sinon.stub(process, 'env').value({...process.env}) - }) - - it('skips when env is missing', function () { - delete process.env.ACCEPTANCE_TESTS - const context = {skip: sinon.stub()} as unknown as Context - - skipUnlessAcceptance(context) - - expect((context.skip as unknown as sinon.SinonStub).calledOnce).to.equal(true) - }) - - it('skips when env is not true', function () { - process.env.ACCEPTANCE_TESTS = 'false' - const context = {skip: sinon.stub()} as unknown as Context - - skipUnlessAcceptance(context) - - expect((context.skip as unknown as sinon.SinonStub).calledOnce).to.equal(true) - }) - - it('does not skip when env is true', function () { - process.env.ACCEPTANCE_TESTS = 'true' - const context = {skip: sinon.stub()} as unknown as Context - - skipUnlessAcceptance(context) - - expect((context.skip as unknown as sinon.SinonStub).notCalled).to.equal(true) - }) - }) - - describe('getAllAcceptanceHosts', function () { - it('returns all hosts referenced by the fixture', function () { - const hosts = getAllAcceptanceHosts() - - expect(hosts).to.include(HOST_NAME) - expect(hosts).to.include(ALTERNATE_HOST_NAME) - expect(new Set(hosts).size).to.equal(hosts.length) - }) - }) - - describe('getAllAcceptanceServices', function () { - it('returns all services referenced by the fixture', function () { - const services = getAllAcceptanceServices() - - expect(services).to.include(SERVICE_NAME) - expect(services).to.include(ALTERNATE_SERVICE_NAME) - expect(new Set(services).size).to.equal(services.length) - }) - }) - - describe('cleanupCredentialStore', function () { - let listAccountsStub: sinon.SinonStub - let removeAuthStub: sinon.SinonStub - - beforeEach(function () { - sinon.stub(process, 'platform').value('darwin') - sinon.stub(process, 'env').value({...process.env}) - - listAccountsStub = sinon.stub(MacOSHandler.prototype, 'listAccounts') - removeAuthStub = sinon.stub(MacOSHandler.prototype, 'removeAuth') - }) - - it('is a no-op when HEROKU_NETRC_WRITE is true', async function () { - process.env.HEROKU_NETRC_WRITE = 'true' - - await expect(cleanupCredentialStore()).to.not.be.rejected - expect(listAccountsStub.notCalled).to.equal(true) - expect(removeAuthStub.notCalled).to.equal(true) - }) - - it('removes all accounts for all fixture services', async function () { - delete process.env.HEROKU_NETRC_WRITE - listAccountsStub.withArgs(SERVICE_NAME).returns(['test@example.com', 'second@example.com']) - listAccountsStub.withArgs(ALTERNATE_SERVICE_NAME).returns(['third@example.com']) - - await cleanupCredentialStore() - - expect(removeAuthStub.callCount).to.equal(3) - expect(removeAuthStub.calledWith('test@example.com', SERVICE_NAME)).to.equal(true) - expect(removeAuthStub.calledWith('second@example.com', SERVICE_NAME)).to.equal(true) - expect(removeAuthStub.calledWith('third@example.com', ALTERNATE_SERVICE_NAME)).to.equal(true) - }) - - it('does nothing when there are no accounts to remove', async function () { - delete process.env.HEROKU_NETRC_WRITE - listAccountsStub.returns([]) - - await cleanupCredentialStore() - expect(removeAuthStub.notCalled).to.equal(true) - }) - }) - - describe('cleanupDefaultNetrc', function () { - let saveStub: sinon.SinonStub - let loadStub: sinon.SinonStub - - beforeEach(function () { - saveStub = sinon.stub(Netrc.prototype, 'save') - loadStub = sinon.stub(Netrc.prototype, 'load') - }) - - it('removes all known fixture hosts', async function () { - loadStub.callsFake(async function (this: Netrc) { - this.machines = { - [ALTERNATE_HOST_NAME]: {login: 'test-alt@example.com', password: 'test-token-alt'}, - [HOST_NAME]: {login: 'test@example.com', password: 'test-token'}, - 'unrelated.host.test': {login: 'unrelated@example.com', password: 'unrelated-token'}, - } - }) - - let machinesAfterCleanup: Record = {} - saveStub.callsFake(async function (this: Netrc) { - machinesAfterCleanup = {...(this.machines as Record)} - }) - - await cleanupDefaultNetrc() - - expect(saveStub.calledOnce).to.equal(true) - expect(machinesAfterCleanup[HOST_NAME]).to.equal(undefined) - expect(machinesAfterCleanup[ALTERNATE_HOST_NAME]).to.equal(undefined) - expect(machinesAfterCleanup['unrelated.host.test']).to.deep.equal({login: 'unrelated@example.com', password: 'unrelated-token'}) - }) - - it('does nothing when there are no hosts to remove', async function () { - loadStub.callsFake(async function (this: Netrc) { - this.machines = { - 'unrelated.host.test': {login: 'unrelated@example.com', password: 'unrelated-token'}, - } - }) - - await cleanupDefaultNetrc() - expect(saveStub.notCalled).to.equal(true) - }) - }) - - describe('snapshotDefaultNetrc', function () { - let existsSyncStub: sinon.SinonStub - let readFileSyncStub: sinon.SinonStub - let writeFileSyncStub: sinon.SinonStub - let rmSyncStub: sinon.SinonStub - - beforeEach(function () { - existsSyncStub = sinon.stub(fs, 'existsSync') - readFileSyncStub = sinon.stub(fs, 'readFileSync') - writeFileSyncStub = sinon.stub(fs, 'writeFileSync') - rmSyncStub = sinon.stub(fs, 'rmSync') - }) - - it('restores original contents when default netrc already exists', function () { - const netrcPath = '/tmp/test-existing.netrc' - const originalContents = Buffer.from('machine api.heroku.com login test password secret\n') - - sinon.stub(Netrc.prototype as unknown as {defaultFile: string}, 'defaultFile').get(() => netrcPath) - existsSyncStub.returns(true) - readFileSyncStub.returns(originalContents) - - const snapshot = snapshotDefaultNetrc() - expect(snapshot.netrcPath).to.equal(netrcPath) - - snapshot.restore() - snapshot.restore() - - expect(writeFileSyncStub.calledOnceWithExactly(netrcPath, originalContents)).to.equal(true) - expect(rmSyncStub.notCalled).to.equal(true) - }) - - it('removes netrc when no default netrc existed before test', function () { - const netrcPath = '/tmp/test-missing.netrc' - - sinon.stub(Netrc.prototype as unknown as {defaultFile: string}, 'defaultFile').get(() => netrcPath) - existsSyncStub.returns(false) - - const snapshot = snapshotDefaultNetrc() - expect(snapshot.netrcPath).to.equal(netrcPath) - - snapshot.restore() - snapshot.restore() - - expect(readFileSyncStub.notCalled).to.equal(true) - expect(writeFileSyncStub.notCalled).to.equal(true) - expect(rmSyncStub.calledOnceWithExactly(netrcPath, {force: true})).to.equal(true) - }) - }) - - // Integration-style tests that don't require stubbing ES modules - describe('isSecretToolAvailable', function () { - it('returns a boolean', function () { - const result = isSecretToolAvailable() - expect(typeof result).to.equal('boolean') - }) - }) - - describe('isSecurityAvailable', function () { - it('returns a boolean', function () { - const result = isSecurityAvailable() - expect(typeof result).to.equal('boolean') - }) - }) - - describe('isPowerShellAvailable', function () { - it('returns a boolean', function () { - const result = isPowerShellAvailable() - expect(typeof result).to.equal('boolean') - }) - }) - - describe('setupFakeCredentialStore', function () { - it('returns undefined or a setup object with cleanup', function () { - const setup = setupFakeCredentialStore() - - if (setup) { - expect(setup).to.have.property('cleanup') - expect(setup).to.have.property('originalPath') - expect(setup).to.have.property('tmpDir') - expect(typeof setup.cleanup).to.equal('function') - expect(typeof setup.originalPath).to.equal('string') - expect(typeof setup.tmpDir).to.equal('string') - - // Cleanup after test - setup.cleanup() - } else { - expect(setup).to.equal(undefined) - } - }) - }) -}) diff --git a/test/credential-manager/helpers/acceptance-utils.ts b/test/credential-manager/helpers/acceptance-utils.ts deleted file mode 100644 index 556011e9..00000000 --- a/test/credential-manager/helpers/acceptance-utils.ts +++ /dev/null @@ -1,305 +0,0 @@ -import {type Context} from 'mocha' -import {execSync} from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' - -import {listKeychainAccounts, removeAuth} from '../../../src/credential-manager-core/index.js' -import {Netrc} from '../../../src/credential-manager-core/lib/netrc-parser.js' - -export type AcceptanceFixture = { - account: string; - hosts: string[]; - service: string; - token: string; -} - -export type NetrcSnapshot = { - netrcPath: string; - restore: () => void; -} - -export const HOST_NAME = 'acceptance.test.heroku.com' -export const ALTERNATE_HOST_NAME = 'acceptance.test.alt.heroku.com' -export const SERVICE_NAME = 'heroku-cli-acceptance-test' -export const ALTERNATE_SERVICE_NAME = 'heroku-cli-acceptance-test-alternate' - -export const CREDENTIAL_FIXTURES = { - 'account-alternate-service': { - account: 'test-alternate-service@example.com', - hosts: [HOST_NAME], - service: ALTERNATE_SERVICE_NAME, - token: 'test-token-alternate-service-12345', - }, - 'account-default': { - account: 'test@example.com', - hosts: [HOST_NAME], - service: SERVICE_NAME, - token: 'test-token-12345', - }, - 'account-multiple-hosts': { - account: 'test-multiple-hosts@example.com', - hosts: [HOST_NAME, ALTERNATE_HOST_NAME], - service: SERVICE_NAME, - token: 'test-token-multiple-hosts-12345', - }, -} as const satisfies Record - -/** - * Clears machine entries in the default netrc file between acceptance tests. - * Keeps the file itself, but removes test host entries to avoid cross-test leakage. - */ -export async function cleanupDefaultNetrc(): Promise { - let changed = false - const hosts = getAllAcceptanceHosts() - const netrc = new Netrc() - await netrc.load() - for (const host of hosts) { - if (!Object.hasOwn(netrc.machines, host)) { - continue; - } - - Reflect.deleteProperty(netrc.machines, host) - changed = true - } - - if (changed) { - await netrc.save() - } -} - -/** - * Removes all accounts for the provided test service from the platform-native credential store. - */ -export async function cleanupCredentialStore(): Promise { - const services = getAllAcceptanceServices() - for (const service of services) { - /* eslint-disable no-await-in-loop -- keychain cleanup must run sequentially; each account delete has to complete before listing/removing the next */ - const accounts = await listKeychainAccounts(service) - if (accounts.length === 0) continue - - for (const account of accounts) { - await removeAuth(account, [], service) - } - /* eslint-enable no-await-in-loop */ - } -} - -/** - * Returns all unique hosts referenced by acceptance fixtures. - */ -export function getAllAcceptanceHosts(): string[] { - return [...new Set(Object.values(CREDENTIAL_FIXTURES).flatMap(fixture => fixture.hosts))] -} - -/** - * Returns all unique services referenced by acceptance fixtures. - */ -export function getAllAcceptanceServices(): string[] { - return [...new Set(Object.values(CREDENTIAL_FIXTURES).map(fixture => fixture.service))] -} - -/** - * Skip the current suite or test unless ACCEPTANCE_TESTS=true. - */ -export function skipUnlessAcceptance(context: Context): void { - const value = process.env.ACCEPTANCE_TESTS?.trim().toLowerCase() - if (value !== 'true') { - context.skip() - } -} - -/** - * Captures the current netrc file contents and returns a restore callback. - * If netrc existed, restore writes the original contents back. - * If netrc did not exist, restore removes any netrc created during the test. - */ -export function snapshotDefaultNetrc(): NetrcSnapshot { - const netrcPath = new Netrc().file - const hadExistingFile = fs.existsSync(netrcPath) - const originalContents = hadExistingFile ? fs.readFileSync(netrcPath) : undefined - - let restored = false - - return { - netrcPath, - restore() { - if (restored) return - - if (hadExistingFile) { - if (!originalContents) { - throw new Error(`Original netrc contents were not found for ${netrcPath}`) - } - - fs.writeFileSync(netrcPath, originalContents) - } else { - fs.rmSync(netrcPath, {force: true}) - } - - restored = true - }, - } -} - -export type FakeCredentialStoreSetup = { - cleanup: () => void; - originalPath: string; - tmpDir: string; -} - -/** - * Creates a fake credential store command that always fails. - * Used for testing credential store fallback behavior. - * - * The fake command is placed in a temporary directory that is prepended to PATH, - * ensuring it's found before the real command. - * - * @param commandName - The command to fake (e.g., 'secret-tool', 'security', 'powershell') - * @param scriptContent - The script content for the fake command - * @returns An object with the original PATH and a cleanup function to restore state - */ -function setupFakeCommand(commandName: string, scriptContent: string): FakeCredentialStoreSetup { - // Create a temporary directory for our fake command - const tmpPrefix = process.platform === 'win32' ? process.env.TEMP || String.raw`C:\temp` : '/tmp' - const tmpDir = fs.mkdtempSync(path.join(tmpPrefix, 'keyring-test-')) - const fakeCommand = path.join(tmpDir, commandName) - - // Create a fake command that always fails - fs.writeFileSync(fakeCommand, scriptContent, {mode: 0o755}) - - // Prepend our temp directory to PATH so our fake command is found first - const originalPath = process.env.PATH || '' - const pathSeparator = process.platform === 'win32' ? ';' : ':' - process.env.PATH = `${tmpDir}${pathSeparator}${originalPath}` - - return { - cleanup() { - // Restore PATH - process.env.PATH = originalPath - - // Remove temporary directory - try { - fs.rmSync(tmpDir, {force: true, recursive: true}) - } catch { - // Best effort cleanup - } - }, - originalPath, - tmpDir, - } -} - -/** - * Creates a fake secret-tool executable that always fails. - * Used for testing credential store fallback behavior on Linux. - * - * The fake secret-tool is placed in a temporary directory that is prepended to PATH, - * ensuring it's found before the real secret-tool. - * - * @returns An object with the original PATH and a cleanup function to restore state - */ -export function setupFakeSecretTool(): FakeCredentialStoreSetup { - return setupFakeCommand('secret-tool', '#!/bin/bash\nexit 1\n') -} - -/** - * Creates a fake security command that always fails. - * Used for testing credential store fallback behavior on macOS. - * - * The fake security command is placed in a temporary directory that is prepended to PATH, - * ensuring it's found before the real security command. - * - * @returns An object with the original PATH and a cleanup function to restore state - */ -export function setupFakeSecurity(): FakeCredentialStoreSetup { - return setupFakeCommand('security', '#!/bin/bash\nexit 1\n') -} - -/** - * Creates a fake powershell command that always fails. - * Used for testing credential store fallback behavior on Windows. - * - * The fake powershell command is placed in a temporary directory that is prepended to PATH, - * ensuring it's found before the real powershell command. - * - * @returns An object with the original PATH and a cleanup function to restore state - */ -export function setupFakePowerShell(): FakeCredentialStoreSetup { - const scriptContent = process.platform === 'win32' - ? '@echo off\r\nexit /b 1\r\n' - : '#!/bin/bash\nexit 1\n' - - const commandName = process.platform === 'win32' ? 'powershell.exe' : 'powershell' - return setupFakeCommand(commandName, scriptContent) -} - -/** - * Sets up a fake credential store command appropriate for the current platform. - * - Linux: fake secret-tool - * - macOS: fake security - * - Windows: fake powershell - * - * @returns An object with the original PATH and a cleanup function to restore state, or undefined if platform is not supported - */ -export function setupFakeCredentialStore(): FakeCredentialStoreSetup | undefined { - // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- process.platform has ~11 members; only darwin/linux/win32 are supported and the default returns undefined for the rest - switch (process.platform) { - case 'darwin': { - if (!isSecurityAvailable()) return undefined - return setupFakeSecurity() - } - - case 'linux': { - if (!isSecretToolAvailable()) return undefined - return setupFakeSecretTool() - } - - case 'win32': { - if (!isPowerShellAvailable()) return undefined - return setupFakePowerShell() - } - - default: { - return undefined - } - } -} - -/** - * Checks if secret-tool is available on the system. - * @returns true if secret-tool is available, false otherwise - */ -export function isSecretToolAvailable(): boolean { - try { - execSync('which secret-tool', {encoding: 'utf8', stdio: 'pipe'}) - return true - } catch { - return false - } -} - -/** - * Checks if security command is available on the system (macOS). - * @returns true if security is available, false otherwise - */ -export function isSecurityAvailable(): boolean { - try { - execSync('which security', {encoding: 'utf8', stdio: 'pipe'}) - return true - } catch { - return false - } -} - -/** - * Checks if powershell is available on the system (Windows). - * @returns true if powershell is available, false otherwise - */ -export function isPowerShellAvailable(): boolean { - try { - const command = process.platform === 'win32' ? 'where powershell' : 'which powershell' - execSync(command, {encoding: 'utf8', stdio: 'pipe'}) - return true - } catch { - return false - } -} diff --git a/test/credential-manager/helpers/netrc-stub.ts b/test/credential-manager/helpers/netrc-stub.ts deleted file mode 100644 index 78f64fbe..00000000 --- a/test/credential-manager/helpers/netrc-stub.ts +++ /dev/null @@ -1,69 +0,0 @@ -import {type SinonStub, stub} from 'sinon' - -import {Netrc} from '../../../src/deprecated/credential-manager-core/lib/netrc-parser.js' - -/** - * Mock netrc object used to simulate a parsed .netrc file with test credentials. - */ -const mockNetrc = { - machines: { - 'api.heroku.com': { - login: 'test@example.com', - password: 'mypass', - }, - }, - saveSync: stub(), -} - -let loadSyncStub: SinonStub | undefined -let loadStub: SinonStub | undefined -let saveStub: SinonStub | undefined -let saveSyncStub: SinonStub | undefined - -/** - * Stubs the Netrc prototype's `loadSync` and `load` methods to return mock credentials. - * Stubs `save` / `saveSync` so tests never write to the real netrc path. - * Safe to call multiple times; will not re-stub if already stubbed. - * @returns {void} - */ -export function stubNetrc() { - // Only stub if not already stubbed - loadSyncStub ||= stub(Netrc.prototype, 'loadSync').callsFake(function (this: Netrc) { - Object.assign(this, mockNetrc) - }) - - loadStub ||= stub(Netrc.prototype, 'load').callsFake(async function (this: Netrc) { - Object.assign(this, mockNetrc) - }) as SinonStub - - saveStub ||= stub(Netrc.prototype, 'save').resolves() - - saveSyncStub ||= stub(Netrc.prototype, 'saveSync') -} - -/** - * Restores the original Netrc prototype methods. - * Should be called in test teardown to clean up stubs. - * @returns {void} - */ -export function restoreNetrcStub() { - if (loadSyncStub) { - loadSyncStub.restore() - loadSyncStub = undefined - } - - if (loadStub) { - loadStub.restore() - loadStub = undefined - } - - if (saveStub) { - saveStub.restore() - saveStub = undefined - } - - if (saveSyncStub) { - saveSyncStub.restore() - saveSyncStub = undefined - } -} diff --git a/test/credential-manager/index.test.ts b/test/credential-manager/index.test.ts deleted file mode 100644 index cb6b0732..00000000 --- a/test/credential-manager/index.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import {expect, use} from 'chai' -import chaiAsPromised from 'chai-as-promised' -import sinon from 'sinon' -import {stderr} from 'stdout-stderr' - -import {LinuxHandler} from '../../src/deprecated/credential-manager-core/credential-handlers/linux-handler.js' -import {MacOSHandler} from '../../src/deprecated/credential-manager-core/credential-handlers/macos-handler.js' -import {NetrcHandler} from '../../src/deprecated/credential-manager-core/credential-handlers/netrc-handler.js' -import {WindowsHandler} from '../../src/deprecated/credential-manager-core/credential-handlers/windows-handler.js' -import * as credentialManager from '../../src/deprecated/credential-manager-core/index.js' -import {CredentialStore} from '../../src/deprecated/credential-manager-core/lib/credential-storage-selector.js' -import {unwrap} from '../helpers/unwrap.js' - -use(chaiAsPromised) - -describe('credential-manager', function () { - // default to use macOS platform for testing - beforeEach(function () { - sinon.stub(process, 'platform').value('darwin') - - const env = {...process.env} - sinon.stub(process, 'env').value(env) - - delete env.HEROKU_NETRC_WRITE - }) - - afterEach(function () { - sinon.restore() - }) - - describe('saveAuth', function () { - it('should save to credential store only', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'saveAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'saveAuthForHosts').resolves() - - await credentialManager.saveAuth('user@example.com', 'test-token', ['api.heroku.com']) - - expect(macosStub.calledOnce).to.be.true - expect(macosStub.firstCall.args[0]).to.deep.equal({ - account: 'user@example.com', - service: 'heroku-cli', - token: 'test-token', - }) - expect(netrcStub.notCalled).to.be.true - }) - - it('should save to netrc-only when HEROKU_NETRC_WRITE is true', async function () { - process.env.HEROKU_NETRC_WRITE = 'TRUE' - const macosStub = sinon.stub(MacOSHandler.prototype, 'saveAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'saveAuthForHosts').resolves() - - await credentialManager.saveAuth('user@example.com', 'test-token', ['api.heroku.com']) - - expect(macosStub.notCalled).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should fall back to netrc if credential store fails', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'saveAuth').throws(new Error('Keychain error')) - const netrcStub = sinon.stub(NetrcHandler.prototype, 'saveAuthForHosts').resolves() - - await credentialManager.saveAuth('user@example.com', 'test-token', ['api.heroku.com']) - - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should throw an error when netrc fallback fails', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'saveAuth').throws(new Error('Keychain error')) - const netrcStub = sinon.stub(NetrcHandler.prototype, 'saveAuthForHosts').throws(new Error('Netrc error')) - - await expect(credentialManager.saveAuth('user@example.com', 'test-token', ['api.heroku.com'])) - .to.be.rejectedWith(Error, 'Netrc error') - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should save to credential store with custom service name', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'saveAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'saveAuthForHosts').resolves() - - await credentialManager.saveAuth('user@example.com', 'test-token', ['api.heroku.com'], 'custom-service') - - expect(macosStub.args[0][0]).to.deep.equal({ - account: 'user@example.com', - service: 'custom-service', - token: 'test-token', - }) - expect(netrcStub.notCalled).to.be.true - }) - }) - - describe('getAuth', function () { - it('should retrieve from credential store when available', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'getAuth').returns('keychain-token') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'getAuth') - - const auth = await credentialManager.getAuth('user@example.com', 'api.heroku.com') - - expect(auth).to.deep.equal({account: 'user@example.com', token: 'keychain-token'}) - expect(macosStub.calledOnce).to.be.true - expect(macosStub.firstCall.args[0]).to.equal('user@example.com') - expect(macosStub.firstCall.args[1]).to.equal('heroku-cli') - expect(netrcStub.notCalled).to.be.true - }) - - it('should retrieve from netrc-only when HEROKU_NETRC_WRITE is true', async function () { - process.env.HEROKU_NETRC_WRITE = 'TRUE' - const macosStub = sinon.stub(MacOSHandler.prototype, 'getAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'getAuth').resolves({login: 'user@example.com', password: 'netrc-token'}) - - const auth = await credentialManager.getAuth('user@example.com', 'api.heroku.com') - - expect(macosStub.notCalled).to.be.true - expect(netrcStub.calledOnce).to.be.true - expect(netrcStub.firstCall.args[0]).to.equal('api.heroku.com') - expect(auth).to.deep.equal({account: 'user@example.com', token: 'netrc-token'}) - }) - - it('should fall back to netrc if credential store fails', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'getAuth').throws(new Error('Keychain error')) - const netrcStub = sinon.stub(NetrcHandler.prototype, 'getAuth') - netrcStub.resolves({login: 'user@example.com', password: 'netrc-token'}) - - const auth = await credentialManager.getAuth('user@example.com', 'api.heroku.com') - - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - expect(netrcStub.firstCall.args[0]).to.equal('api.heroku.com') - expect(auth).to.deep.equal({account: 'user@example.com', token: 'netrc-token'}) - }) - - it('should throw error when credentials are not found in either location', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'getAuth').throws(new Error('Not found')) - const netrcStub = sinon.stub(NetrcHandler.prototype, 'getAuth') - netrcStub.rejects(new Error('No auth found for api.heroku.com')) - - await expect(credentialManager.getAuth('user@example.com', 'api.heroku.com')) - .to.be.rejectedWith(Error, 'No auth found for api.heroku.com') - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should throw error when netrc password is empty', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'getAuth').throws(new Error('Not found')) - const netrcStub = sinon.stub(NetrcHandler.prototype, 'getAuth') - netrcStub.resolves({login: 'user@example.com', password: undefined}) - - await expect(credentialManager.getAuth('user@example.com', 'api.heroku.com')) - .to.be.rejectedWith(Error, 'No auth found') - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should fall back to netrc when an account is not provided', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'getAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'getAuth') - netrcStub.resolves({login: 'user@example.com', password: 'netrc-token'}) - - stderr.start() - - const auth = await credentialManager.getAuth(undefined, 'api.heroku.com') - - expect(macosStub.notCalled).to.be.true - expect(netrcStub.calledOnce).to.be.true - expect(auth).to.deep.equal({account: 'user@example.com', token: 'netrc-token'}) - - stderr.stop() - }) - - it('should retrieve from credential store with custom service name', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'getAuth').returns('keychain-token') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'getAuth') - - const auth = await credentialManager.getAuth('user@example.com', 'api.heroku.com', 'custom-service') - - expect(auth).to.deep.equal({account: 'user@example.com', token: 'keychain-token'}) - expect(macosStub.args[0][1]).to.equal('custom-service') - expect(netrcStub.notCalled).to.be.true - }) - }) - - describe('removeAuth', function () { - it('should remove from both credential store and netrc', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'removeAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'removeAuthForHosts').resolves() - - await credentialManager.removeAuth('user@example.com', ['api.heroku.com']) - - expect(macosStub.calledOnce).to.be.true - expect(macosStub.firstCall.args[0]).to.equal('user@example.com') - expect(macosStub.firstCall.args[1]).to.equal('heroku-cli') - expect(netrcStub.calledOnce).to.be.true - expect(netrcStub.firstCall.args[0]).to.deep.equal(['api.heroku.com']) - }) - - it('should remove from both stores even when HEROKU_NETRC_WRITE is true', async function () { - process.env.HEROKU_NETRC_WRITE = 'TRUE' - const macosStub = sinon.stub(MacOSHandler.prototype, 'removeAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'removeAuthForHosts').resolves() - - await credentialManager.removeAuth('user@example.com', ['api.heroku.com']) - - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should continue to netrc if credential store fails', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'removeAuth').throws(new Error('Keychain error')) - const netrcStub = sinon.stub(NetrcHandler.prototype, 'removeAuthForHosts').resolves() - - await credentialManager.removeAuth('user@example.com', ['api.heroku.com']) - - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should remove from credential store once and netrc once for multiple hosts', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'removeAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'removeAuthForHosts').resolves() - - await credentialManager.removeAuth('user@example.com', ['api.heroku.com', 'git.heroku.com']) - - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - expect(netrcStub.firstCall.args[0]).to.deep.equal(['api.heroku.com', 'git.heroku.com']) - }) - - it('should throw an error when netrc fails to remove', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'removeAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'removeAuthForHosts').throws(new Error('Netrc error')) - - await expect(credentialManager.removeAuth('user@example.com', ['api.heroku.com'])) - .to.be.rejectedWith(Error, 'Netrc error') - expect(macosStub.calledOnce).to.be.true - expect(netrcStub.calledOnce).to.be.true - }) - - it('should remove from credential store with custom service name', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'removeAuth') - sinon.stub(NetrcHandler.prototype, 'removeAuthForHosts').resolves() - - await credentialManager.removeAuth('user@example.com', ['api.heroku.com'], 'custom-service') - - expect(macosStub.args[0][1]).to.equal('custom-service') - }) - - it('should continue to netrc if account is undefined without native removal or warnings', async function () { - const macosStub = sinon.stub(MacOSHandler.prototype, 'removeAuth') - const netrcStub = sinon.stub(NetrcHandler.prototype, 'removeAuthForHosts').resolves() - - stderr.start() - await credentialManager.removeAuth(undefined, ['api.heroku.com']) - - expect(macosStub.notCalled).to.be.true - expect(netrcStub.calledOnce).to.be.true - expect(unwrap(stderr.output)).to.not.contain('We can\'t remove the Heroku token from your computer\'s keychain.') - - stderr.stop() - }) - }) - - describe('listKeychainAccounts', function () { - it('should return accounts from the native credential store', async function () { - sinon.stub(MacOSHandler.prototype, 'listAccounts').returns(['user1@example.com', 'user2@example.com']) - - const accounts = await credentialManager.listKeychainAccounts() - - expect(accounts).to.deep.equal(['user1@example.com', 'user2@example.com']) - }) - - it('should pass a custom service name to the handler', async function () { - const listStub = sinon.stub(MacOSHandler.prototype, 'listAccounts').returns(['user@example.com']) - - await credentialManager.listKeychainAccounts('custom-service') - - expect(listStub.calledOnceWith('custom-service')).to.be.true - }) - - it('should return an empty array when no native credential store is available', async function () { - process.env.HEROKU_NETRC_WRITE = 'TRUE' - - const accounts = await credentialManager.listKeychainAccounts() - - expect(accounts).to.deep.equal([]) - }) - }) - - describe('getCredentialHandler', function () { - it('should return the correct credential handler for the given store', function () { - let handler = credentialManager.getCredentialHandler(CredentialStore.MacOSKeychain) - expect(handler).to.be.instanceOf(MacOSHandler) - - handler = credentialManager.getCredentialHandler(CredentialStore.WindowsCredentialManager) - expect(handler).to.be.instanceOf(WindowsHandler) - - handler = credentialManager.getCredentialHandler(CredentialStore.LinuxSecretService) - expect(handler).to.be.instanceOf(LinuxHandler) - }) - }) -}) diff --git a/test/credential-manager/lib/credential-storage-selector.test.ts b/test/credential-manager/lib/credential-storage-selector.test.ts deleted file mode 100644 index 58615fd7..00000000 --- a/test/credential-manager/lib/credential-storage-selector.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import {expect} from 'chai' -import childProcess from 'node:child_process' -import sinon from 'sinon' - -import {CredentialStore, getNativeCredentialStore, getStorageConfig} from '../../../src/deprecated/credential-manager-core/lib/credential-storage-selector.js' - -describe('credential-storage-selector', function () { - describe('getStorageConfig', function () { - let platformStub: sinon.SinonStub - - beforeEach(function () { - platformStub = sinon.stub(process, 'platform') - const env = {...process.env} - sinon.stub(process, 'env').value(env) - delete env.HEROKU_NETRC_WRITE - }) - - afterEach(function () { - sinon.restore() - }) - - it('should return netrc-only when HEROKU_NETRC_WRITE is true', function () { - platformStub.value('darwin') - process.env.HEROKU_NETRC_WRITE = 'TRUE' - - const result = getStorageConfig() - - expect(result.credentialStore).to.be.null - expect(result.useNetrc).to.be.true - }) - - it('should return macOS Keychain without netrc for darwin platform', function () { - platformStub.value('darwin') - - const result = getStorageConfig() - - expect(result.credentialStore).to.equal(CredentialStore.MacOSKeychain) - expect(result.useNetrc).to.be.false - }) - - it('should return Windows Credential Manager without netrc for win32 platform', function () { - platformStub.value('win32') - - const result = getStorageConfig() - - expect(result.credentialStore).to.equal(CredentialStore.WindowsCredentialManager) - expect(result.useNetrc).to.be.false - }) - - it('should return Linux Secret Service without netrc when secret-tool is available', function () { - platformStub.value('linux') - - const execSyncStub = sinon.stub(childProcess, 'execSync') - execSyncStub.returns(Buffer.from('/usr/bin/secret-tool\n')) - - const result = getStorageConfig() - - expect(result.credentialStore).to.equal(CredentialStore.LinuxSecretService) - expect(result.useNetrc).to.be.false - }) - - it('should return netrc-only when secret-tool is not available', function () { - platformStub.value('linux') - - const execSyncStub = sinon.stub(childProcess, 'execSync') - execSyncStub.throws(new Error('secret-tool not found')) - - const result = getStorageConfig() - - expect(result.credentialStore).to.be.null - expect(result.useNetrc).to.be.true - }) - - it('should return netrc-only for unsupported platforms', function () { - platformStub.value('freebsd') - - const result = getStorageConfig() - - expect(result.credentialStore).to.be.null - expect(result.useNetrc).to.be.true - }) - }) - - describe('getNativeCredentialStore', function () { - let platformStub: sinon.SinonStub - - beforeEach(function () { - platformStub = sinon.stub(process, 'platform') - const env = {...process.env} - sinon.stub(process, 'env').value(env) - delete env.HEROKU_NETRC_WRITE - }) - - afterEach(function () { - sinon.restore() - }) - - it('returns macOS Keychain on darwin even when HEROKU_NETRC_WRITE is true', function () { - platformStub.value('darwin') - process.env.HEROKU_NETRC_WRITE = 'TRUE' - - expect(getNativeCredentialStore()).to.equal(CredentialStore.MacOSKeychain) - expect(getStorageConfig().credentialStore).to.be.null - }) - - it('returns Windows store on win32 regardless of HEROKU_NETRC_WRITE', function () { - platformStub.value('win32') - process.env.HEROKU_NETRC_WRITE = 'TRUE' - - expect(getNativeCredentialStore()).to.equal(CredentialStore.WindowsCredentialManager) - }) - - it('returns Linux Secret Service when secret-tool exists', function () { - platformStub.value('linux') - const execSyncStub = sinon.stub(childProcess, 'execSync') - execSyncStub.returns(Buffer.from('/usr/bin/secret-tool\n')) - - expect(getNativeCredentialStore()).to.equal(CredentialStore.LinuxSecretService) - }) - - it('returns null on linux when secret-tool is missing', function () { - platformStub.value('linux') - const execSyncStub = sinon.stub(childProcess, 'execSync') - execSyncStub.throws(new Error('secret-tool not found')) - - expect(getNativeCredentialStore()).to.be.null - }) - - it('returns null on unsupported platforms', function () { - platformStub.value('freebsd') - - expect(getNativeCredentialStore()).to.be.null - }) - }) -}) diff --git a/test/credential-manager/lib/login-state.test.ts b/test/credential-manager/lib/login-state.test.ts deleted file mode 100644 index 53c52d92..00000000 --- a/test/credential-manager/lib/login-state.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import {expect} from 'chai' -import * as fs from 'node:fs' -import * as os from 'node:os' -import path from 'node:path' -import sinon from 'sinon' - -import {deleteLoginState, readLoginState, writeLoginState} from '../../../src/deprecated/credential-manager-core/lib/login-state.js' - -const skipOnWindows = process.platform === 'win32' ? it.skip : it - -describe('login-state', function () { - let tmpDir: string - - beforeEach(function () { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'heroku-login-state-')) - }) - - afterEach(function () { - fs.rmSync(tmpDir, {force: true, recursive: true}) - sinon.restore() - }) - - describe('readLoginState', function () { - it('returns undefined when file does not exist', async function () { - expect(await readLoginState(tmpDir)).to.be.undefined - }) - - it('reads a valid login state file', async function () { - fs.writeFileSync(path.join(tmpDir, 'login.json'), JSON.stringify({account: 'user@example.com'})) - const result = await readLoginState(tmpDir) - expect(result).to.deep.equal({account: 'user@example.com'}) - }) - - it('returns undefined for malformed JSON', async function () { - fs.writeFileSync(path.join(tmpDir, 'login.json'), 'not json') - expect(await readLoginState(tmpDir)).to.be.undefined - }) - - it('returns undefined when account field is missing', async function () { - fs.writeFileSync(path.join(tmpDir, 'login.json'), JSON.stringify({other: 'field'})) - expect(await readLoginState(tmpDir)).to.be.undefined - }) - - it('returns undefined when account is empty string', async function () { - fs.writeFileSync(path.join(tmpDir, 'login.json'), JSON.stringify({account: ''})) - expect(await readLoginState(tmpDir)).to.be.undefined - }) - - it('returns undefined when account is not a string', async function () { - fs.writeFileSync(path.join(tmpDir, 'login.json'), JSON.stringify({account: 123})) - expect(await readLoginState(tmpDir)).to.be.undefined - }) - }) - - describe('writeLoginState', function () { - it('creates the file with the account', async function () { - await writeLoginState(tmpDir, 'user@example.com') - const content = JSON.parse(fs.readFileSync(path.join(tmpDir, 'login.json'), 'utf8')) - expect(content).to.deep.equal({account: 'user@example.com'}) - }) - - it('creates the directory if it does not exist', async function () { - const nestedDir = path.join(tmpDir, 'nested', 'dir') - await writeLoginState(nestedDir, 'user@example.com') - const content = JSON.parse(fs.readFileSync(path.join(nestedDir, 'login.json'), 'utf8')) - expect(content).to.deep.equal({account: 'user@example.com'}) - }) - - it('overwrites an existing file', async function () { - await writeLoginState(tmpDir, 'old@example.com') - await writeLoginState(tmpDir, 'new@example.com') - const content = JSON.parse(fs.readFileSync(path.join(tmpDir, 'login.json'), 'utf8')) - expect(content).to.deep.equal({account: 'new@example.com'}) - }) - - skipOnWindows('sets file permissions to 0o600', async function () { - await writeLoginState(tmpDir, 'user@example.com') - const stats = fs.statSync(path.join(tmpDir, 'login.json')) - // eslint-disable-next-line no-bitwise - expect(stats.mode & 0o777).to.equal(0o600) - }) - }) - - describe('deleteLoginState', function () { - it('deletes the login state file', async function () { - fs.writeFileSync(path.join(tmpDir, 'login.json'), JSON.stringify({account: 'user@example.com'})) - await deleteLoginState(tmpDir) - expect(fs.existsSync(path.join(tmpDir, 'login.json'))).to.be.false - }) - - it('does not throw when file does not exist', async function () { - await deleteLoginState(tmpDir) - }) - - it('does not delete other files in the directory', async function () { - fs.writeFileSync(path.join(tmpDir, 'other.json'), 'keep') - fs.writeFileSync(path.join(tmpDir, 'login.json'), JSON.stringify({account: 'user@example.com'})) - await deleteLoginState(tmpDir) - expect(fs.existsSync(path.join(tmpDir, 'other.json'))).to.be.true - }) - }) -}) diff --git a/test/credential-manager/lib/netrc-parser.test.ts b/test/credential-manager/lib/netrc-parser.test.ts deleted file mode 100644 index aa1bc1f6..00000000 --- a/test/credential-manager/lib/netrc-parser.test.ts +++ /dev/null @@ -1,598 +0,0 @@ -import {expect} from 'chai' -import {execa, ExecaError} from 'execa' -import fs from 'fs-extra' - -import {Netrc} from '../../../src/deprecated/credential-manager-core/lib/netrc-parser.js' - -process.env.NETRC_PARSER_DEBUG = '1' - -const skipOnWindows = process.platform === 'win32' ? it.skip : it - -const configureGpgMock = async () => { - // Create and set temp gpg home directory - const mockGnupgHome = 'tmp/gpg' - fs.mkdirpSync(mockGnupgHome) - await fs.chmod(mockGnupgHome, 0o700) - process.env.GNUPGHOME = mockGnupgHome - - // Import mock gpg key - await execa( - 'gpg', - ['--batch', '--quiet', '--import', 'test/fixtures/gpg.key'], - {env: {...process.env, GNUPGHOME: mockGnupgHome}}, - ) -} - -describe('netrc', function () { - beforeEach(async function () { - fs.mkdirpSync('tmp') - }) - - afterEach(function () { - fs.removeSync('tmp') - delete process.env.GNUPGHOME - }) - - it('can read system netrc', function () { - const netrc = new Netrc() - netrc.loadSync() - expect(Boolean(netrc.machines)).to.be.true - }) - - it('can read system netrc async', async function () { - const netrc = new Netrc() - await netrc.load() - expect(Boolean(netrc.machines)).to.be.true - }) - - it('reads basic', function () { - const f = 'tmp/netrc' - fs.writeFileSync( - f, - `machine mail.google.com # foo - login joe@gmail.com - account gmail - password somethingSecret -machine ray login demo password mypassword`, - ) - const netrc = new Netrc(f) - netrc.loadSync() - - expect(netrc.machines['mail.google.com'].login).to.equal('joe@gmail.com') - expect(netrc.machines['mail.google.com'].account).to.equal('gmail') - expect(netrc.machines['mail.google.com'].password).to.equal('somethingSecret') - - expect(netrc.machines.ray.login).to.equal('demo') - expect(netrc.machines.ray.password).to.equal('mypassword') - }) - - it('bad default order', function () { - const f = 'tmp/netrc' - fs.writeFileSync( - f, - `# I am a comment -machine mail.google.com - login joe@gmail.com - account gmail - password somethingSecret -# I am another comment - -default - login anonymous - password joe@example.com - -machine ray login demo password mypassword - `, - ) - const netrc = new Netrc(f) - netrc.loadSync() - - expect(netrc.machines['mail.google.com'].login).to.equal('joe@gmail.com') - expect(netrc.machines['mail.google.com'].account).to.equal('gmail') - expect(netrc.machines['mail.google.com'].password).to.equal('somethingSecret') - - expect(netrc.machines.ray.login).to.equal('demo') - expect(netrc.machines.ray.password).to.equal('mypassword') - }) - - it('it loads the netrc file with comments', function () { - const f = 'tmp/netrc' - fs.writeFileSync( - f, - `machine api.dickeyxxx.com # foo - login jeff@foo.com - password myapikey`, - ) - const netrc = new Netrc(f) - netrc.loadSync() - - expect(netrc.machines['api.dickeyxxx.com'].login).to.equal('jeff@foo.com') - expect(netrc.machines['api.dickeyxxx.com'].password).to.equal('myapikey') - }) - - it('finds the login, account, and password with odd characters, comments, and trailing spaces', function () { - const f = 'tmp/netrc' - fs.writeFileSync( - f, - `# I am a comment -machine mail.google.com -\tlogin joe@gmail.com - account justagmail #end of line comment with trailing space - password somethingSecret - # I am another comment - -macdef allput -put src/* - -macdef allput2 - put src/* -put src2/* - -machine ray login demo password mypassword - -machine weirdlogin login uname password pass#pass - -default - login anonymous - password joe@example.com -`, - ) - const netrc = new Netrc(f) - netrc.loadSync() - - expect(netrc.machines['mail.google.com'].login).to.equal('joe@gmail.com') - expect(netrc.machines['mail.google.com'].account).to.equal('justagmail') - expect(netrc.machines['mail.google.com'].password).to.equal('somethingSecret') - }) - - const gpgEncrypted = `-----BEGIN PGP MESSAGE----- -Version: GnuPG v2 - -hIwD1rghrTHCzmIBA/9JIhd9NaY64C7QMIOa8KV/e97Hs9he6EAHdhDUMeb6/5HU -KaxHX77rHjF0TxNUumQrMTfp+EjKzjuDqTxrv0TnpqB8JYhwLqVCGPM+OvjNlILy -/EdDpkqEaKqM4KArRQjE4n8ifAi5CbldI/mO+oBvHTq5StJDNEhE+xMjRzGJ29LA -VQEWWdR291Z8Y0cbZwX2DmGsPuo6tX0JeWQlG9ms8966wVk2LKFuUyynHBVjcsjv -REKnai8ZixhaKRBE/NOiLo/Eqp6nI7/i8YU+mYV0rFljpLSnQ7LJcgw3ItyKXQ9F -ws16ShzCIGM11JFySwb0NoV6H9VSakfu2LN1RpKFD2lvc6i75N0NWf0Jh/mKHFz+ -ugLe8sik/Zu8grrxtOVxfgtjFEQvjT3u02D4pDQP1lNp7SjVfqUC+XnxWQC+SQVC -kKvydwB3oZqwHp6jpgLVTxjTfhm1vNTB7gAbgNOF63yQ/Wmrn3Pe38huh+TIKJCy -pQgBLBordnqQajWt1ao+8AZiAsOooF0wJqm/mH1Og5/ADuhvZEQ= -=PGaL ------END PGP MESSAGE-----` - - skipOnWindows('synchronously decrypts gpg-encrypted netrc file', async function () { - await configureGpgMock().catch((error: unknown) => { - if (error instanceof ExecaError && error.code === 'ENOENT') { - console.log('GPG not found, skipping test') - return this.skip() - } - - throw error - }) - const f = 'tmp/netrc.gpg' - fs.writeFileSync(f, gpgEncrypted) - const netrc = new Netrc(f) - netrc.loadSync() - - expect(netrc.machines['mail.google.com'].login).to.equal('joe@gmail.com') - expect(netrc.machines['mail.google.com'].account).to.equal('justagmail') - expect(netrc.machines['mail.google.com'].password).to.equal('somethingSecret') - - netrc.saveSync() - expect(fs.readFileSync(f, {encoding: 'utf8'})).to.contain('-----BEGIN PGP MESSAGE-----') - }) - - skipOnWindows('asynchronously decrypts gpg-encrypted netrc file', async function () { - await configureGpgMock().catch((error: unknown) => { - if (error instanceof ExecaError && error.code === 'ENOENT') { - console.log('GPG not found, skipping test') - return this.skip() - } - - throw error - }) - const f = 'tmp/netrc.gpg' - await fs.writeFile(f, gpgEncrypted) - const netrc = new Netrc(f) - await netrc.load() - - expect(netrc.machines['mail.google.com'].login).to.equal('joe@gmail.com') - expect(netrc.machines['mail.google.com'].account).to.equal('justagmail') - expect(netrc.machines['mail.google.com'].password).to.equal('somethingSecret') - - await netrc.save() - expect(fs.readFileSync(f, {encoding: 'utf8'})).to.contain('-----BEGIN PGP MESSAGE-----') - }) - - it('saving', function () { - const f = 'tmp/netrc' - fs.writeFileSync( - f, - `# I am a comment -machine mail.google.com -\tlogin joe@gmail.com - password somethingSecret #end of line comment with trailing space - # I am another comment - -macdef allput -put src/* - -macdef allput2 - put src/* -put src2/* - -machine ray login demo password mypassword - -machine weirdlogin login uname password pass#pass - -default - login anonymous - password joe@example.com -`, - ) - const netrc = new Netrc(f) - netrc.loadSync() - netrc.machines['mail.google.com'].login = 'joe2@gmail.com' - netrc.machines['mail.google.com'].account = 'justanaccount' - netrc.machines.ray.login = 'demo2' - netrc.machines.ray.account = 'newaccount' - netrc.machines.new = {login: 'myuser', password: 'mypass'} - netrc.machines.anothernew = {} - netrc.machines.anothernew = {login: 'myuser'} - netrc.saveSync() - - expect(fs.readFileSync(f, 'utf8')).to.equal(`# I am a comment -machine mail.google.com - login joe2@gmail.com - password somethingSecret #end of line comment with trailing space - account justanaccount - # I am another comment - -macdef allput -put src/* - -macdef allput2 - put src/* -put src2/* - -machine ray login demo2 password mypassword account newaccount -machine weirdlogin login uname password pass #pass - -default - login anonymous - password joe@example.com -machine new login myuser password mypass -machine anothernew login myuser -`) - }) - - it('adding a machine should create a new entry', async function () { - const f = 'tmp/netrc' - - const beforeSave = `machine api.dickeyxxx.com # foo - login jeff@foo.com - password myapikey` - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines['foo.bar.com'] = {login: 'foo@bar.com', password: 'foopassword'} - await netrc.save() - - const afterSave = `machine api.dickeyxxx.com # foo - login jeff@foo.com - password myapikey -machine foo.bar.com - login foo@bar.com - password foopassword\n` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('edit host', async function () { - const f = 'tmp/netrc' - - const beforeSave = `machine a - login jeff@foo.com - password myapikey -` - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines.a.host = 'b' - await netrc.save() - - const afterSave = `machine b - login jeff@foo.com - password myapikey\n` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('edit existing machine', async function () { - const f = 'tmp/netrc' - - const beforeSave = `machine api.dickeyxxx.com # foo - login jeff@foo.com - password myapikey -machine b -\tlogin jeff@bar.com -\tpassword myapikey2` - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines['api.dickeyxxx.com'] = {login: 'foo@bar.com', password: 'foopassword'} - netrc.machines.b.login = 'foobar' - await netrc.save() - - const afterSave = `machine api.dickeyxxx.com # foo - login foo@bar.com - password foopassword -machine b -\tlogin foobar -\tpassword myapikey2\n` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('removing a machine', async function () { - const f = 'tmp/netrc' - - const beforeSave = `machine api.dickeyxxx.com # foo - login jeff@foo.com - password myapikey -machine foo.bar.com - password foopassword - login foo@bar.com -` - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - delete netrc.machines['api.dickeyxxx.com'] - await netrc.save() - - const afterSave = `machine foo.bar.com - login foo@bar.com - password foopassword -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('setting machine to undefined', async function () { - const f = 'tmp/netrc' - - const beforeSave = `machine api.dickeyxxx.com # foo - login jeff@foo.com - password myapikey -machine foo.bar.com - password foopassword - login foo@bar.com -` - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines['api.dickeyxxx.com'] = undefined as any - await netrc.save() - - const afterSave = `machine foo.bar.com - login foo@bar.com - password foopassword -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('empty netrc', async function () { - const f = 'tmp/netrc' - - const beforeSave = '' - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines['api.dickeyxxx.com'] = {login: 'foo', password: 'bar'} - netrc.machines['foo.dickeyxxx.com'] = {login: 'foo2', password: 'bar2'} - await netrc.save() - - const afterSave = `machine api.dickeyxxx.com login foo password bar -machine foo.dickeyxxx.com login foo2 password bar2 -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('netrc with nothing useful', async function () { - const f = 'tmp/netrc' - - const beforeSave = 'foobar\n' - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines['api.dickeyxxx.com'] = {login: 'foo', password: 'bar'} - netrc.machines['foo.dickeyxxx.com'] = {login: 'foo2', password: 'bar2'} - await netrc.save() - - const afterSave = `foobar -machine api.dickeyxxx.com login foo password bar -machine foo.dickeyxxx.com login foo2 password bar2 -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('netrc with duplicate entries', async function () { - const f = 'tmp/netrc' - - const beforeSave = `machine a - login foo - password bar -machine b - login foo2 - password bar2 -machine a - login ignoreme - password ignoreme -` - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - await netrc.save() - - const afterSave = `machine a - login foo - password bar -machine b - login foo2 - password bar2 -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('set existing', async function () { - const f = 'tmp/netrc' - - const beforeSave = 'machine a password p login u' - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines.a = {login: 'foo', password: 'bar'} - await netrc.save() - - const afterSave = 'machine a login foo password bar\n' - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('set new prop', async function () { - const f = 'tmp/netrc' - - const beforeSave = 'machine foo password p login u' - - fs.writeFileSync(f, beforeSave) - - const netrc = new Netrc(f) - await netrc.load() - netrc.machines.foo.login = 'uu' - netrc.machines.foo.account = 'bar' - netrc.machines.foo.password = undefined - expect('foo' in netrc.machines).to.equal(true) - expect('bar' in netrc.machines).to.equal(false) - delete netrc.machines.bar - await netrc.save() - - const afterSave = `machine foo login uu account bar -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('only login', async function () { - const f = 'tmp/netrc' - const beforeSave = 'machine u login foo password pass' - fs.writeFileSync(f, beforeSave) - const netrc = new Netrc(f) - await netrc.load() - netrc.machines.foo = {login: 'uu'} - netrc.saveSync() - - const afterSave = `machine u login foo password pass -machine foo login uu -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('only password', async function () { - const f = 'tmp/netrc' - const beforeSave = 'machine u login foo password pass' - fs.writeFileSync(f, beforeSave) - const netrc = new Netrc(f) - await netrc.load() - netrc.machines.foo = {password: 'uu'} - netrc.saveSync() - - const afterSave = `machine u login foo password pass -machine foo password uu -` - - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('file not found', async function () { - const f = 'tmp/netrc' - fs.removeSync(f) - const netrc = new Netrc(f) - await netrc.load() - netrc.machines.foo = {login: 'u', password: 'p'} - await netrc.save() - const afterSave = 'machine foo login u password p\n' - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('file not found sync', function () { - const f = 'tmp/netrc' - fs.removeSync(f) - const netrc = new Netrc(f) - netrc.loadSync() - netrc.machines.foo = {login: 'u', password: 'p'} - netrc.saveSync() - const afterSave = 'machine foo login u password p\n' - expect(fs.readFileSync(f, 'utf8')).to.equal(afterSave) - }) - - it('loadSync() wraps and rethrows non-ENOENT errors with file context', function () { - const f = 'tmp' - const netrc = new Netrc(f) - try { - netrc.loadSync() - expect.fail('Expected an error to be thrown') - } catch (error: unknown) { - expect(error).to.be.instanceOf(Error) - expect((error as {detail?: string}).detail).to.include(`Error occurred during reading netrc file: ${f}`) - } - }) - - it('load() wraps and rethrows non-ENOENT errors with file context', async function () { - const f = 'tmp' - const netrc = new Netrc(f) - try { - await netrc.load() - expect.fail('Expected an error to be thrown') - } catch (error: unknown) { - expect(error).to.be.instanceOf(Error) - expect((error as {detail?: string}).detail).to.include(`Error occurred during reading netrc file: ${f}`) - } - }) - - it('extra code coverage checks', function () { - const netrc = new Netrc() - netrc.loadSync() - // eslint-disable-next-line unicorn/no-computed-property-existence-check -- intentionally exercises the `in` operator against the machines object for coverage - expect(Symbol('test') in netrc.machines).to.equal(false) - netrc.machines.a = {login: 'foo'} - // eslint-disable-next-line unicorn/no-computed-property-existence-check -- intentionally exercises the `in` operator against a machine entry for coverage - expect(Symbol('test') in netrc.machines.a).to.equal(false) - expect(netrc.machines.a.lwljlkwejf).to.equal(undefined) - expect(netrc.machines.a[Symbol('test') as any]).to.equal(undefined) - expect(netrc.machines[Symbol('test') as any]).to.equal(undefined) - netrc.machines.b = undefined as any - }) -}) diff --git a/test/env-auth.test.ts b/test/env-auth.test.ts new file mode 100644 index 00000000..645dc29f --- /dev/null +++ b/test/env-auth.test.ts @@ -0,0 +1,60 @@ +import {expect} from 'chai' + +import {resolveEnvToken} from '../src/env-auth.js' + +describe('resolveEnvToken', () => { + const {env} = process + + beforeEach(() => { + process.env = {} + }) + + afterEach(() => { + process.env = env + }) + + const collectWarnings = () => { + const warnings: string[] = [] + const warn = (message: string): void => { + warnings.push(message) + } + + return {warn, warnings} + } + + it('returns the HEROKU_API_KEY token with no warning', () => { + process.env.HEROKU_API_KEY = 'key-token' + const {warn, warnings} = collectWarnings() + expect(resolveEnvToken(warn)).to.equal('key-token') + expect(warnings).to.deep.equal([]) + }) + + it('returns no token when nothing is set, and does not warn', () => { + const {warn, warnings} = collectWarnings() + expect(resolveEnvToken(warn)).to.be.undefined + expect(warnings).to.deep.equal([]) + }) + + it('warns and returns no token when HEROKU_API_TOKEN is set without HEROKU_API_KEY', () => { + process.env.HEROKU_API_TOKEN = 'mistaken-token' + const {warn, warnings} = collectWarnings() + expect(resolveEnvToken(warn)).to.be.undefined + expect(warnings).to.have.lengthOf(1) + expect(warnings[0]).to.match(/HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY/) + }) + + it('treats an empty HEROKU_API_KEY as unset', () => { + process.env.HEROKU_API_KEY = '' + const {warn, warnings} = collectWarnings() + expect(resolveEnvToken(warn)).to.be.undefined + expect(warnings).to.deep.equal([]) + }) + + it('does not warn when both HEROKU_API_KEY and HEROKU_API_TOKEN are set', () => { + process.env.HEROKU_API_KEY = 'key-token' + process.env.HEROKU_API_TOKEN = 'also-set' + const {warn, warnings} = collectWarnings() + expect(resolveEnvToken(warn)).to.equal('key-token') + expect(warnings).to.deep.equal([]) + }) +}) diff --git a/test/fixtures/env-default-flags-plugin/commands/demo.ts b/test/fixtures/env-default-flags-plugin/commands/demo.ts new file mode 100644 index 00000000..bd38221a --- /dev/null +++ b/test/fixtures/env-default-flags-plugin/commands/demo.ts @@ -0,0 +1,15 @@ +// the fixture needs its own package.json to load as an oclif plugin, so import rules treat it as a separate package +/* eslint-disable n/no-extraneous-import, n/no-unpublished-import */ +import {Command} from '@oclif/core/command' + +import * as flags from '../../../../src/flags/index.js' + +export default class Demo extends Command { + static flags = { + app: flags.app({required: true}), + org: flags.org(), + team: flags.team(), + } + + async run() {} +} diff --git a/test/fixtures/env-default-flags-plugin/package.json b/test/fixtures/env-default-flags-plugin/package.json new file mode 100644 index 00000000..a323ecde --- /dev/null +++ b/test/fixtures/env-default-flags-plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "env-default-flags-plugin", + "version": "0.0.0", + "type": "module", + "oclif": {"commands": "./commands"} +} diff --git a/test/flags/no-cache-default.test.ts b/test/flags/no-cache-default.test.ts new file mode 100644 index 00000000..e326905c --- /dev/null +++ b/test/flags/no-cache-default.test.ts @@ -0,0 +1,48 @@ +import {Plugin} from '@oclif/core' +import {expect} from 'fancy-test' +import {fileURLToPath} from 'node:url' + +const envVars = ['HEROKU_APP', 'HEROKU_ORGANIZATION', 'HEROKU_TEAM'] + +describe('manifest generation', () => { + const fixtureRoot = fileURLToPath(new URL('../fixtures/env-default-flags-plugin', import.meta.url)) + const cachedDefault = async (flag: string, respectNoCacheDefault: boolean) => { + const plugin = new Plugin({ignoreManifest: true, respectNoCacheDefault, root: fixtureRoot}) + await plugin.load() + return plugin.manifest.commands.demo.flags[flag].default + } + + let originalEnv: Record + + beforeEach(() => { + originalEnv = Object.fromEntries(envVars.map(name => [name, process.env[name]])) + process.env.HEROKU_APP = 'my-local-app' + process.env.HEROKU_ORGANIZATION = 'my-local-org' + process.env.HEROKU_TEAM = 'my-local-team' + }) + + afterEach(() => { + for (const name of envVars) { + if (originalEnv[name] === undefined) delete process.env[name] + else process.env[name] = originalEnv[name] + } + }) + + const flagsWithEnvDefaults = [ + {flag: 'app', value: 'my-local-app'}, + {flag: 'org', value: 'my-local-org'}, + {flag: 'team', value: 'my-local-team'}, + ] + + for (const {flag, value} of flagsWithEnvDefaults) { + describe(`--${flag}`, () => { + it('caches the local default when generating at runtime', async () => { + expect(await cachedDefault(flag, false)).to.equal(value) + }) + + it('keeps the local default out of generated docs and manifests', async () => { + expect(await cachedDefault(flag, true)).to.be.undefined + }) + }) + } +}) diff --git a/test/helpers/credential-manager-stub.ts b/test/helpers/credential-manager-stub.ts index 5937e960..44b31ab8 100644 --- a/test/helpers/credential-manager-stub.ts +++ b/test/helpers/credential-manager-stub.ts @@ -1,4 +1,5 @@ -import * as real from '../../src/credential-manager-core/lib/credential-manager-adapter.js' +import * as real from '@heroku/heroku-credential-manager' + import {setCredentialManagerProvider} from '../../src/credential-manager.js' const DEFAULT_TOKEN = 'mypass' diff --git a/test/helpers/unwrap.ts b/test/helpers/unwrap.ts deleted file mode 100644 index e4dd2c97..00000000 --- a/test/helpers/unwrap.ts +++ /dev/null @@ -1,8 +0,0 @@ -const unwrap = function (str: string): string { - let sanitize = str.replaceAll(/\n ([▸›»]) {3}/g, '') - sanitize = sanitize.replaceAll(/ ([▸›»]) {3}/g, '') - - return sanitize -} - -export {unwrap} diff --git a/test/login-state-coordinator.test.ts b/test/login-state-coordinator.test.ts index 19eed83e..24c10eba 100644 --- a/test/login-state-coordinator.test.ts +++ b/test/login-state-coordinator.test.ts @@ -1,10 +1,10 @@ +import {readLoginState} from '@heroku/heroku-credential-manager' import {expect} from 'chai' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import * as sinon from 'sinon' -import {readLoginState} from '../src/credential-manager-core/lib/login-state.js' import { deleteLoginStateCoordinated, deleteLoginStateIf, diff --git a/test/package-contract.test.ts b/test/package-contract.test.ts deleted file mode 100644 index 0abe98f1..00000000 --- a/test/package-contract.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import {HTTP} from '@heroku/http-call' -import {expect} from 'chai' -import {readdirSync, readFileSync} from 'node:fs' -import path from 'node:path' -import ts from 'typescript' - -const packageName = '@heroku/heroku-credential-manager' -const expectedVersion = '0.1.0' -const packagePath = path.resolve('node_modules', packageName) -const httpCallPackageName = '@heroku/http-call' -const httpCallVersion = '5.5.2' -const httpCallPackagePath = path.resolve('node_modules', httpCallPackageName) - -type PackageManifest = { - dependencies: Record; - exports: Record; - version: string; -} - -type Lockfile = { - packages: Record; - integrity?: string; - resolved?: string; - version?: string; - }>; -} - -function readJson(filePath: string): T { - return JSON.parse(readFileSync(filePath, 'utf8')) as T -} - -function readDeclarations(dirPath: string): string { - return readdirSync(dirPath, {withFileTypes: true}) - .flatMap(entry => { - const entryPath = path.join(dirPath, entry.name) - - if (entry.isDirectory()) return readDeclarations(entryPath) - return entry.name.endsWith('.d.ts') ? readFileSync(entryPath, 'utf8') : [] - }) - .join('\n') -} - -function interfaceMethodNames(declarations: string, interfaceName: string): string[] { - const sourceFile = ts.createSourceFile('declarations.d.ts', declarations, ts.ScriptTarget.Latest, true) - const methodNames: string[] = [] - - function visit(node: ts.Node): void { - if (ts.isInterfaceDeclaration(node) && node.name.text === interfaceName) { - methodNames.push(...node.members.filter(member => ts.isMethodSignature(member)).map(member => member.name.getText(sourceFile))) - } - - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return methodNames.toSorted() -} - -function classMemberNames(declarations: string, className: string): string[] { - const sourceFile = ts.createSourceFile('declarations.d.ts', declarations, ts.ScriptTarget.Latest, true) - const methodNames: string[] = [] - - function visit(node: ts.Node): void { - if (ts.isClassDeclaration(node) && node.name?.text === className) { - methodNames.push(...node.members.flatMap(member => member.name ? member.name.getText(sourceFile) : [])) - } - - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return methodNames.toSorted() -} - -describe('package contracts', () => { - it(`detects unexpected ${packageName} HerokuApiClientLike methods regardless of formatting`, () => { - const declarations = ` - interface HerokuApiClientLike { - delete(path: string): Promise - get(path: string): Promise - post(path: string): Promise - } - ` - - expect(interfaceMethodNames(declarations, 'HerokuApiClientLike')).to.deep.equal(['delete', 'get', 'post']) - }) - - it(`pins the immutable ${packageName} package and lockfile artifact`, () => { - const manifest = readJson(path.resolve('package.json')) - const lockfile = readJson(path.resolve('package-lock.json')) - const lockRoot = lockfile.packages[''] - const lockPackage = lockfile.packages[`node_modules/${packageName}`] - - expect(manifest.dependencies[packageName]).to.equal(expectedVersion) - expect(lockRoot.dependencies?.[packageName]).to.equal(expectedVersion) - expect(lockPackage).to.include({ - integrity: 'sha512-jonzLCL5kt79oL3TZYxFsJ9d7eO6ZrGJmsjDkAjDb4CNFvC72DH5Ke6xZ+G9iN5HgQ36Y7Mji+V5h+QS0bEj/Q==', - resolved: 'https://registry.npmjs.org/@heroku/heroku-credential-manager/-/heroku-credential-manager-0.1.0.tgz', - version: expectedVersion, - }) - }) - - it(`installs the expected ${packageName} package API contract`, () => { - const installedManifest = readJson(path.join(packagePath, 'package.json')) - const declarations = readDeclarations(packagePath) - - expect(installedManifest.version).to.equal(expectedVersion) - expect(installedManifest.exports).to.include.all.keys('.', './login') - expect(interfaceMethodNames(declarations, 'HerokuApiClientLike')).to.deep.equal(['delete', 'get']) - expect(declarations).not.to.match(/\bLoginHttp\w*/) - }) - - it('publishes without an `exports` map so the barrel and deep imports resolve to one module instance', () => { - // The host CLI keys completion behavior off the reference identity of the - // Completion singletons: a `completionCatalog` entry must be the SAME object - // the host sees however it imports this package. That holds only while - // package.json has NO `exports` field — without one, the root barrel and any - // subpath co-resolve to a single ESM instance; an `exports` map would fork - // them into two instances and break identity. See the note in src/index.ts. - const manifest = readJson>(path.resolve('package.json')) - expect(manifest).to.not.have.property('exports') - }) - - it(`pins the verified ${httpCallPackageName} version in the manifest and lockfile`, () => { - const manifest = readJson(path.resolve('package.json')) - const lockfile = readJson(path.resolve('package-lock.json')) - - expect(manifest.dependencies[httpCallPackageName]).to.equal(httpCallVersion) - expect(lockfile.packages[''].dependencies?.[httpCallPackageName]).to.equal(httpCallVersion) - expect(lockfile.packages[`node_modules/${httpCallPackageName}`].version).to.equal(httpCallVersion) - expect(readJson(path.join(httpCallPackagePath, 'package.json')).version).to.equal(httpCallVersion) - }) - - it('provides the transport hooks required by the login adapter', async () => { - const declarations = readFileSync(path.join(httpCallPackagePath, 'lib/http.d.ts'), 'utf8') - const requiredHooks = ['_maybeRetry', '_redirect', '_request', '_wait'] - const runtimePrototype = HTTP.prototype as unknown as Record - - expect(classMemberNames(declarations, 'HTTP')).to.include.members(requiredHooks) - for (const hook of requiredHooks) expect(runtimePrototype[hook]).to.be.a('function') - - const request = new HTTP('https://example.test') as unknown as { - _maybeRetry(error: Error): Promise; - _request(): Promise; - _wait(delay: number): Promise; - } - const calls: string[] = [] - request._wait = async () => { - calls.push('_wait') - } - - request._request = async () => { - calls.push('_request') - } - - await request._maybeRetry(Object.assign(new Error('socket reset'), {code: 'ECONNRESET'})) - expect(calls).to.deep.equal(['_wait', '_request']) - }) -}) From 9c7de5330491ce7efc5e1fdf62de128d95e5bca9 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Tue, 20 Oct 2026 11:13:56 -0700 Subject: [PATCH 2/2] fix(beta): satisfy v14 eslint for env-default-flags test + fixture The no-cache-default flag test and its oclif-plugin fixture were authored on main under the older lint config and trip v14's stricter rules once rebased in: - fixture: give the eslint-disable directive a description and extend it to cover import-x/no-extraneous-dependencies for the root-resolved @oclif/core. - test: delete the dynamic env key via Reflect.deleteProperty to satisfy @typescript-eslint/no-dynamic-delete. Applied here to unblock the beta release; the same fix is still owed on v14.0.0 in a follow-up PR. --- test/fixtures/env-default-flags-plugin/commands/demo.ts | 3 +-- test/flags/no-cache-default.test.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/fixtures/env-default-flags-plugin/commands/demo.ts b/test/fixtures/env-default-flags-plugin/commands/demo.ts index bd38221a..5d41dbb2 100644 --- a/test/fixtures/env-default-flags-plugin/commands/demo.ts +++ b/test/fixtures/env-default-flags-plugin/commands/demo.ts @@ -1,5 +1,4 @@ -// the fixture needs its own package.json to load as an oclif plugin, so import rules treat it as a separate package -/* eslint-disable n/no-extraneous-import, n/no-unpublished-import */ +/* eslint-disable n/no-extraneous-import, n/no-unpublished-import, import-x/no-extraneous-dependencies -- the fixture has its own package.json so it loads as a standalone oclif plugin; @oclif/core is resolved from the repo root, not this fixture's deps */ import {Command} from '@oclif/core/command' import * as flags from '../../../../src/flags/index.js' diff --git a/test/flags/no-cache-default.test.ts b/test/flags/no-cache-default.test.ts index e326905c..7f2d7471 100644 --- a/test/flags/no-cache-default.test.ts +++ b/test/flags/no-cache-default.test.ts @@ -23,7 +23,7 @@ describe('manifest generation', () => { afterEach(() => { for (const name of envVars) { - if (originalEnv[name] === undefined) delete process.env[name] + if (originalEnv[name] === undefined) Reflect.deleteProperty(process.env, name) else process.env[name] = originalEnv[name] } })