Skip to content

Commit 2862e38

Browse files
committed
feat: improve npm authentication and plugin health checks during CLI updates
This change improves the user experience when updating the Heroku CLI by adding better handling for npm authentication and plugin installation issues. Key changes: - Add preupdate hook (check-npm-auth) that detects private plugins and prompts users to authenticate with npm before attempting the update - Add post-update hook (check-plugin-health) that verifies all plugins installed correctly and provides recovery instructions for missing plugins - Remove deprecated v6 plugin migration code from plugin-migrate hook - Add comprehensive unit tests for both new hooks The check-npm-auth hook: - Reads installed plugins from package.json - Checks which plugins are private (require authentication) - Verifies npm authentication status - Prompts user to login if needed before proceeding with update - Handles user cancellation gracefully with clear messaging The check-plugin-health hook: - Runs after plugin installation during update - Checks if all configured plugins exist in node_modules - Warns users about missing plugins with recovery instructions - Suggests both reinstall and uninstall options This resolves issues where users would get cryptic npm errors during CLI updates when logged out of npm with private plugins installed.
1 parent c6ab2eb commit 2862e38

7 files changed

Lines changed: 543 additions & 62 deletions

File tree

cspell-dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ postrelease
279279
postrun
280280
preauth
281281
prerun
282+
preupdate
282283
processname
283284
processtype
284285
procfile

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,16 @@
191191
"prerun": [
192192
"./dist/hooks/prerun/analytics"
193193
],
194+
"preupdate": [
195+
"./dist/hooks/update/check-npm-auth"
196+
],
194197
"recache": "./dist/hooks/recache",
195198
"update": [
196-
"./dist/hooks/update/plugin-migrate",
197199
"./dist/hooks/update/brew",
198200
"./dist/hooks/update/completions",
199201
"./dist/hooks/update/tidy",
200-
"./dist/hooks/recache"
202+
"./dist/hooks/recache",
203+
"./dist/hooks/update/check-plugin-health"
201204
]
202205
},
203206
"macos": {

src/hooks/update/check-npm-auth.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/* eslint-disable valid-jsdoc */
2+
import {color} from '@heroku/heroku-cli-util'
3+
import {Hook, ux} from '@oclif/core'
4+
import inquirer from 'inquirer'
5+
import {exec, spawn} from 'node:child_process'
6+
import {readFile} from 'node:fs/promises'
7+
import {join} from 'node:path'
8+
import {promisify} from 'node:util'
9+
import tsheredocLib from 'tsheredoc'
10+
11+
const execAsync = promisify(exec)
12+
const tsheredoc = tsheredocLib.default
13+
14+
/**
15+
* Check if a package requires authentication by trying to access it
16+
* Returns true if the package appears to be private (requires auth)
17+
*/
18+
async function isPrivatePackage(packageName: string): Promise<boolean> {
19+
try {
20+
// Try to get package info without authentication
21+
const {stderr, stdout} = await execAsync(`npm view ${packageName} name --json`, {
22+
env: {
23+
...process.env,
24+
// Temporarily unset npm auth to test if package is public
25+
npm_config_registry: 'https://registry.npmjs.org',
26+
},
27+
timeout: 10000,
28+
})
29+
30+
const output = stdout + stderr
31+
32+
// If we get a 401/403 or auth-related error, it's private
33+
if (
34+
output.includes('401 Unauthorized')
35+
|| output.includes('403 Forbidden')
36+
|| output.includes('E401')
37+
|| output.includes('E403')
38+
|| output.includes('authenticate')
39+
|| output.includes('Access token')
40+
) {
41+
return true
42+
}
43+
44+
// If we get a 404, it might be private or might not exist
45+
// We'll treat it as potentially private to be safe
46+
if (output.includes('404') || output.includes('E404')) {
47+
return true
48+
}
49+
50+
// If we successfully got the package name, it's public
51+
return false
52+
} catch (error: any) {
53+
const errorMessage = error.message || error.toString()
54+
55+
// Auth errors indicate private package
56+
if (
57+
errorMessage.includes('401')
58+
|| errorMessage.includes('403')
59+
|| errorMessage.includes('authenticate')
60+
|| errorMessage.includes('Access token')
61+
) {
62+
return true
63+
}
64+
65+
// 404 might mean private or non-existent - treat as private to be safe
66+
if (errorMessage.includes('404')) {
67+
return true
68+
}
69+
70+
// On other errors, assume it might be private
71+
return true
72+
}
73+
}
74+
75+
/**
76+
* Check if user has private plugins that may require npm authentication
77+
*/
78+
const checkNpmAuth: Hook<'preupdate'> = async function (opts) {
79+
try {
80+
// Read the plugins package.json to see what plugins are installed
81+
const pluginsPjsonPath = join(this.config.dataDir, 'package.json')
82+
let pluginsPjson: any
83+
84+
try {
85+
const content = await readFile(pluginsPjsonPath, 'utf8')
86+
pluginsPjson = JSON.parse(content)
87+
} catch {
88+
// No plugins installed yet, nothing to check
89+
return
90+
}
91+
92+
const dependencies = pluginsPjson.dependencies || {}
93+
const plugins = Object.keys(dependencies)
94+
95+
if (plugins.length === 0) {
96+
return
97+
}
98+
99+
// Check which plugins are actually private
100+
this.debug('Checking if any installed plugins require authentication...')
101+
const privatePlugins: string[] = []
102+
103+
for (const plugin of plugins) {
104+
this.debug(`Checking ${plugin}...`)
105+
const isPrivate = await isPrivatePackage(plugin)
106+
if (isPrivate) {
107+
this.debug(`${plugin} appears to be private`)
108+
privatePlugins.push(plugin)
109+
} else {
110+
this.debug(`${plugin} is public`)
111+
}
112+
}
113+
114+
if (privatePlugins.length === 0) {
115+
this.debug('No private plugins detected')
116+
return
117+
}
118+
119+
// Check if npm is authenticated
120+
try {
121+
await execAsync('npm whoami', {timeout: 5000})
122+
this.debug('User is authenticated with npm')
123+
} catch {
124+
// User is not authenticated, prompt them
125+
const pluginList = privatePlugins.map(p => ` • ${p}`).join('\n')
126+
127+
ux.warn(tsheredoc`
128+
129+
==================================================================
130+
NPM AUTHENTICATION REQUIRED
131+
==================================================================
132+
133+
You have ${privatePlugins.length} private plugin(s) installed:
134+
${pluginList}
135+
136+
These plugins require npm authentication to update.
137+
138+
==================================================================
139+
`)
140+
141+
const {shouldLogin} = await inquirer.prompt([{
142+
default: true,
143+
message: 'Would you like to authenticate with npm now?',
144+
name: 'shouldLogin',
145+
type: 'confirm',
146+
}])
147+
148+
if (!shouldLogin) {
149+
ux.stdout(tsheredoc`
150+
Update cancelled. To update the CLI with private plugins, you must
151+
first authenticate with npm.
152+
153+
Run ${color.code('npm login')} and then try ${color.code('heroku update')} again. Alternatively,
154+
you can remove the private plugins and try the update again.
155+
`)
156+
ux.exit(1)
157+
}
158+
159+
try {
160+
await new Promise<void>((resolve, reject) => {
161+
const npmLogin = spawn('npm', ['login'], {
162+
stdio: 'inherit',
163+
})
164+
165+
npmLogin.on('exit', code => {
166+
if (code === 0) {
167+
resolve()
168+
} else {
169+
reject(new Error(`npm login exited with code ${code}`))
170+
}
171+
})
172+
173+
npmLogin.on('error', reject)
174+
})
175+
176+
// Verify authentication succeeded
177+
try {
178+
await execAsync('npm whoami', {timeout: 5000})
179+
ux.stdout('✓ Successfully authenticated with npm')
180+
} catch {
181+
this.error(tsheredoc`
182+
npm login did not complete successfully. Please try again manually:
183+
184+
npm login
185+
186+
Then run:
187+
heroku update
188+
`, {exit: 1})
189+
}
190+
} catch (error: any) {
191+
ux.action.stop('failed')
192+
this.error(tsheredoc`
193+
npm login failed: ${error.message}
194+
195+
Please authenticate manually:
196+
npm login
197+
198+
Then try the update again:
199+
heroku update
200+
`, {exit: 1})
201+
}
202+
}
203+
} catch (error: any) {
204+
// If it's an intentional exit (user chose not to authenticate), let it propagate
205+
if (error.oclif?.exit !== undefined) {
206+
throw error
207+
}
208+
209+
// For other errors, don't block the update
210+
this.debug(`npm auth check failed: ${error.message}`)
211+
}
212+
}
213+
214+
export default checkNpmAuth
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {color} from '@heroku/heroku-cli-util'
2+
import {Hook} from '@oclif/core'
3+
import {existsSync} from 'node:fs'
4+
import {readFile} from 'node:fs/promises'
5+
import {join} from 'node:path'
6+
import tsheredocLib from 'tsheredoc'
7+
8+
const tsheredoc = tsheredocLib.default
9+
10+
// eslint-disable-next-line valid-jsdoc
11+
/**
12+
* Check if plugins are properly installed after an update
13+
* and provide recovery instructions if they're not
14+
*/
15+
const checkPluginHealth: Hook<'update'> = async function (opts) {
16+
try {
17+
// Read the plugins package.json to see what plugins should be installed
18+
const pluginsPjsonPath = join(this.config.dataDir, 'package.json')
19+
20+
if (!existsSync(pluginsPjsonPath)) {
21+
// No plugins configured, nothing to check
22+
return
23+
}
24+
25+
let pluginsPjson: any
26+
try {
27+
const content = await readFile(pluginsPjsonPath, 'utf8')
28+
pluginsPjson = JSON.parse(content)
29+
} catch {
30+
return
31+
}
32+
33+
const configuredPlugins = Object.keys(pluginsPjson.dependencies || {})
34+
if (configuredPlugins.length === 0) {
35+
return
36+
}
37+
38+
// Check if any configured plugins are missing from node_modules
39+
const nodeModulesPath = join(this.config.dataDir, 'node_modules')
40+
const missingPlugins: string[] = []
41+
42+
for (const plugin of configuredPlugins) {
43+
const pluginPath = join(nodeModulesPath, plugin)
44+
if (!existsSync(pluginPath)) {
45+
missingPlugins.push(plugin)
46+
}
47+
}
48+
49+
if (missingPlugins.length > 0) {
50+
const pluginList = missingPlugins.map(p => ` • ${p}`).join('\n')
51+
const installCommands = missingPlugins.map(p => ` ${color.code('heroku plugins:install')} ${p}`).join('\n')
52+
const uninstallCommands = missingPlugins.map(p => ` ${color.code('heroku plugins:uninstall')} ${p}`).join('\n')
53+
54+
this.warn(tsheredoc`
55+
56+
===================================================================
57+
PLUGIN INSTALLATION INCOMPLETE
58+
===================================================================
59+
60+
${missingPlugins.length} plugin(s) failed to install during the update:
61+
${pluginList}
62+
63+
This usually happens when:
64+
• Network issues during installation
65+
• The plugin has been removed from the npm registry
66+
• A version conflict or dependency issue
67+
• Insufficient disk space
68+
69+
To fix this:
70+
71+
1. Manually reinstall each plugin:
72+
${installCommands}
73+
74+
2. Or remove plugins you no longer need:
75+
${uninstallCommands}
76+
77+
${'═'.repeat(70)}
78+
79+
`)
80+
}
81+
} catch (error: any) {
82+
// Don't block if this check fails
83+
this.debug(`plugin health check failed: ${error.message}`)
84+
}
85+
}
86+
87+
export default checkPluginHealth

src/hooks/update/plugin-migrate.ts

Lines changed: 0 additions & 60 deletions
This file was deleted.

0 commit comments

Comments
 (0)