-
Notifications
You must be signed in to change notification settings - Fork 235
feat: improve npm authentication and plugin health checks during CLI updates #3547
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
eablack
merged 9 commits into
v11.0.0
from
eb/better-messaging-when-failing-from-private-plugins
Mar 3, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
894b731
feat: improve npm authentication and plugin health checks during CLI …
eablack d86b865
fix linting
eablack 38d76ae
perf: parallelize npm package privacy checks in batches of 5
eablack ff66d69
refactor: extract npm authentication logic into reusable module
eablack 444bde8
fix: resolve deprecation warnings and improve error messaging
eablack 3be927e
refactor: move check-npm-auth hook to correct preupdate directory
eablack 3f367e5
clean up check-plugin-health and tests
eablack e5b23b8
update description of test expectation
eablack b24f23d
correct comment explaining error trap logic.
eablack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -279,6 +279,7 @@ postrelease | |
| postrun | ||
| preauth | ||
| prerun | ||
| preupdate | ||
| processname | ||
| processtype | ||
| procfile | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,20 @@ | ||
| import {Hook, ux} from '@oclif/core' | ||
| import * as path from 'path' | ||
| import * as fs from 'fs-extra' | ||
| import fs from 'fs-extra' | ||
| import path from 'node:path' | ||
|
|
||
| export function checkTos(options: any) { | ||
| export async function checkTos(options: any) { | ||
| const tosPath: string = path.join(options.config.cacheDir, 'terms-of-service') | ||
| const viewedBanner = fs.pathExistsSync(tosPath) | ||
| const message = 'Our terms of service have changed: https://dashboard.heroku.com/terms-of-service' | ||
|
|
||
| if (!viewedBanner) { | ||
| ux.warn(message) | ||
| fs.createFile(tosPath) | ||
| await fs.createFile(tosPath) | ||
| } | ||
| } | ||
|
|
||
| const hook: Hook.Init = async function (options) { | ||
| checkTos(options) | ||
| await checkTos(options) | ||
| } | ||
|
|
||
| export default hook |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| /* eslint-disable valid-jsdoc */ | ||
| import {color} from '@heroku/heroku-cli-util' | ||
| import {Hook, ux} from '@oclif/core' | ||
| import inquirer from 'inquirer' | ||
| import {readFile} from 'node:fs/promises' | ||
| import {join} from 'node:path' | ||
| import tsheredocLib from 'tsheredoc' | ||
|
|
||
| import {NpmAuth} from '../../lib/npm-auth.js' | ||
|
|
||
| const tsheredoc = tsheredocLib.default | ||
|
|
||
| /** | ||
| * Check if user has private plugins that may require npm authentication | ||
| */ | ||
| const checkNpmAuth: Hook<'preupdate'> = async function (opts) { | ||
| try { | ||
| // Check if npm is available on the system | ||
| const npmAvailable = await NpmAuth.isNpmAvailable() | ||
| if (!npmAvailable) { | ||
| return | ||
| } | ||
|
|
||
| // Read the plugins package.json to see what plugins are installed | ||
| const pluginsPjsonPath = join(this.config.dataDir, 'package.json') | ||
| let pluginsPjson: any | ||
|
|
||
| try { | ||
| const content = await readFile(pluginsPjsonPath, 'utf8') | ||
| pluginsPjson = JSON.parse(content) | ||
| } catch { | ||
| // No plugins installed yet, nothing to check | ||
| return | ||
| } | ||
|
|
||
| const dependencies = pluginsPjson.dependencies || {} | ||
| const plugins = Object.keys(dependencies) | ||
|
|
||
| if (plugins.length === 0) { | ||
| return | ||
| } | ||
|
|
||
| // Check which plugins are actually private | ||
| // Process in batches of 5 to parallelize npm API calls | ||
| const batchSize = 5 | ||
| const privatePlugins: string[] = [] | ||
|
|
||
| for (let i = 0; i < plugins.length; i += batchSize) { | ||
| const batch = plugins.slice(i, i + batchSize) | ||
| const results = await Promise.all( | ||
| batch.map(async plugin => { | ||
| const isPrivate = await NpmAuth.isPrivatePackage(plugin) | ||
| this.debug(`${plugin} is ${isPrivate ? 'private' : 'public'}`) | ||
| return isPrivate ? plugin : null | ||
| }), | ||
| ) | ||
|
|
||
| privatePlugins.push(...results.filter((p): p is string => p !== null)) | ||
| } | ||
|
|
||
| if (privatePlugins.length === 0) { | ||
| return | ||
| } | ||
|
|
||
| // Check if npm is authenticated | ||
| const isAuthenticated = await NpmAuth.isAuthenticated() | ||
| if (isAuthenticated) { | ||
| return | ||
| } | ||
|
|
||
| // User is not authenticated, prompt them | ||
| const pluginList = privatePlugins.map(p => ` • ${p}`).join('\n') | ||
|
|
||
| ux.warn(tsheredoc` | ||
|
|
||
| ================================================================== | ||
| NPM AUTHENTICATION REQUIRED | ||
| ================================================================== | ||
|
|
||
| You have ${privatePlugins.length} private plugin(s) installed: | ||
| ${pluginList} | ||
|
|
||
| These plugins require npm authentication to update. | ||
|
|
||
| ================================================================== | ||
| `) | ||
|
|
||
| const {shouldLogin} = await inquirer.prompt([{ | ||
| default: true, | ||
| message: 'Would you like to authenticate with npm now?', | ||
| name: 'shouldLogin', | ||
| type: 'confirm', | ||
| }]) | ||
|
|
||
| if (!shouldLogin) { | ||
| ux.warn(tsheredoc` | ||
| Skipping npm authentication. | ||
|
|
||
| Run ${color.code('npm login')} before your next update to update private plugins. | ||
|
|
||
| Continuing with update, but private plugins may fail to update. | ||
| `) | ||
| return | ||
| } | ||
|
|
||
| // Run npm login | ||
| await NpmAuth.login() | ||
| } catch (error: any) { | ||
| // If user interrupted with Ctrl+C or other exit signal, respect that and exit | ||
| if (error.oclif?.exit !== undefined) { | ||
| throw error | ||
| } | ||
|
|
||
| // For other errors, don't block the update | ||
| this.debug(`npm auth check failed: ${error.message}`) | ||
| } | ||
| } | ||
|
|
||
| export default checkNpmAuth |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| /* eslint-disable valid-jsdoc */ | ||
| import {color} from '@heroku/heroku-cli-util' | ||
| import {Hook, ux} from '@oclif/core' | ||
| import {existsSync} from 'node:fs' | ||
| import {readFile} from 'node:fs/promises' | ||
| import {join} from 'node:path' | ||
| import tsheredocLib from 'tsheredoc' | ||
|
|
||
| const tsheredoc = tsheredocLib.default | ||
|
|
||
| interface PluginsPackageJson { | ||
| dependencies?: Record<string, string> | ||
| } | ||
|
|
||
| /** | ||
| * Read and parse the plugins package.json file | ||
| * Exported for testing purposes | ||
| */ | ||
| async function readPluginsPackageJson(packageJsonPath: string): Promise<PluginsPackageJson | null> { | ||
| try { | ||
| const content = await readFile(packageJsonPath, 'utf8') | ||
| return JSON.parse(content) | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Check if plugins are properly installed after an update | ||
| * and provide recovery instructions if they're not | ||
| */ | ||
| const checkPluginHealth: Hook<'update'> = async function (opts) { | ||
| try { | ||
| // Read the plugins package.json to see what plugins should be installed | ||
| const pluginsPjsonPath = join(this.config.dataDir, 'package.json') | ||
|
|
||
| const pluginsPjson = await readPluginsPackageJson(pluginsPjsonPath) | ||
| if (!pluginsPjson) { | ||
| // No plugins configured or invalid JSON, nothing to check | ||
| return | ||
| } | ||
|
|
||
| const configuredPlugins = Object.keys(pluginsPjson.dependencies || {}) | ||
| if (configuredPlugins.length === 0) { | ||
| return | ||
| } | ||
|
|
||
| // Check if any configured plugins are missing from node_modules | ||
| const nodeModulesPath = join(this.config.dataDir, 'node_modules') | ||
| const missingPlugins: string[] = [] | ||
|
|
||
| for (const plugin of configuredPlugins) { | ||
| const pluginPath = join(nodeModulesPath, plugin) | ||
| if (!existsSync(pluginPath)) { | ||
| missingPlugins.push(plugin) | ||
| } | ||
| } | ||
|
|
||
| if (missingPlugins.length > 0) { | ||
| const pluginList = missingPlugins.map(p => ` • ${p}`).join('\n') | ||
| const installCommands = missingPlugins.map(p => ` ${color.code('heroku plugins:install')} ${p}`).join('\n') | ||
| const uninstallCommands = missingPlugins.map(p => ` ${color.code('heroku plugins:uninstall')} ${p}`).join('\n') | ||
|
|
||
| ux.warn(tsheredoc` | ||
|
|
||
| =================================================================== | ||
| PLUGIN INSTALLATION INCOMPLETE | ||
| =================================================================== | ||
|
|
||
| ${missingPlugins.length} plugin(s) failed to install during the update: | ||
| ${pluginList} | ||
|
|
||
| To fix this: | ||
|
|
||
| 1. Manually reinstall each plugin: | ||
| ${installCommands} | ||
|
|
||
| 2. Or remove plugins you no longer need: | ||
| ${uninstallCommands} | ||
|
|
||
| =================================================================== | ||
|
|
||
| `) | ||
| } | ||
| } catch (error: any) { | ||
| // Don't block if this check fails | ||
| this.debug(`plugin health check failed: ${error.message}`) | ||
| } | ||
| } | ||
|
|
||
| export default checkPluginHealth |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.