Skip to content
Merged
1 change: 1 addition & 0 deletions cspell-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ postrelease
postrun
preauth
prerun
preupdate
processname
processtype
procfile
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,17 @@
"prerun": [
"./dist/hooks/prerun/analytics"
],
"preupdate": [
"./dist/hooks/preupdate/check-npm-auth"
],
Comment thread
eablack marked this conversation as resolved.
"recache": "./dist/hooks/recache",
"update": [
"./dist/hooks/update/plugin-migrate",
Comment thread
eablack marked this conversation as resolved.
"./dist/hooks/update/brew",
"./dist/hooks/update/completions",
"./dist/hooks/update/tidy",
"./dist/hooks/recache",
"./dist/hooks/update/show-version-info"
"./dist/hooks/update/show-version-info",
"./dist/hooks/update/check-plugin-health"
]
},
"macos": {
Expand Down
10 changes: 5 additions & 5 deletions src/hooks/init/terms-of-service.ts
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
119 changes: 119 additions & 0 deletions src/hooks/preupdate/check-npm-auth.ts
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
91 changes: 91 additions & 0 deletions src/hooks/update/check-plugin-health.ts
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
60 changes: 0 additions & 60 deletions src/hooks/update/plugin-migrate.ts

This file was deleted.

Loading
Loading