Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,15 @@ inup | cat # plain line-based report when piped to a log

Each reported package carries its health signals: `deprecated` (npm deprecation message), `enginesNode`
(declared `engines.node`), and `vulnerability` (known advisories on the currently-installed version,
from one bulk `npm audit`-style request). The summary includes a `vulnerable` count.
from one bulk `npm audit`-style request). Every advisory is **cross-referenced against the upgrade
targets**, so you know whether the upgrade actually fixes it:

- `vulnerability.advisories[].fixedByRange` / `fixedByLatest` — does the in-range / latest target escape
this advisory's affected range?
- `vulnerability.fixedByRange` / `fixedByLatest` — does the target clear **every** advisory?

The summary includes a `vulnerable` count, and the payload carries a `schemaVersion` so scripts and
agents can pin to a known shape.

Output hygiene: with `--json`, stdout carries **only** the JSON document; all progress and warnings go
to stderr. Exit codes: `0` up to date, `1` updates exist (`--check`), `2` error.
Expand Down
17 changes: 10 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { Command } from 'commander'
import chalk from 'chalk'
import { resolve } from 'path'
import { UpgradeRunner } from './index'
import { HeadlessRunner } from './features/headless'
import { checkForUpdateAsync } from './services'
import { loadProjectConfig, PACKAGE_NAME, PACKAGE_VERSION } from './config'
import { PackageManager } from './types'
import { PackageManager, UpgradeOptions } from './types'
import { enableDebugLogging, applyColorSetting } from './utils'
import { getGitWorkingTreeState } from './utils/git'
import { TerminalInput } from './ui'
Expand Down Expand Up @@ -101,26 +102,28 @@ export async function runCli(options: CliOptions): Promise<void> {
packageManager = options.packageManager as PackageManager
}

const upgrader = new UpgradeRunner({
const runnerOptions: UpgradeOptions = {
cwd,
excludePatterns,
scanDirs: projectConfig.scanDirs,
maxDepth,
ignorePackages,
packageManager,
showPeerDependencyVulnerabilities:
projectConfig.showPeerDependencyVulnerabilities ?? false,
showPeerDependencyVulnerabilities: projectConfig.showPeerDependencyVulnerabilities ?? false,
showOptionalDependencyVulnerabilities:
projectConfig.showOptionalDependencyVulnerabilities ?? false,
debug: options.debug || process.env.INUP_DEBUG === '1',
saveExact: options.saveExact ?? false,
})
}

// Non-interactive (piped / CI / --json / --check) routes to the read-only headless feature;
// only the interactive path builds the full TUI runner.
if (!interactive) {
await upgrader.runHeadless({ json: options.json, check: options.check })
await new HeadlessRunner(runnerOptions).run({ json: options.json, check: options.check })
return
}

await upgrader.run()
await new UpgradeRunner(runnerOptions).run()

// After the main flow completes, check if there's an update available
const updateCheck = await updateCheckPromise
Expand Down
123 changes: 0 additions & 123 deletions src/core/upgrade-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import { PackageDetector } from './package-detector'
import { InteractiveUI } from '../interactive-ui'
import { PackageUpgrader } from './upgrader'
import {
HeadlessOptions,
HeadlessReport,
HeadlessReportEntry,
PackageInfo,
PackageLoadProgress,
PackageSelectionState,
Expand All @@ -14,8 +11,6 @@ import {
PackageManagerInfo,
} from '../types'
import { PackageManagerDetector } from '../services/package-manager-detector'
import { fetchVulnerabilities } from '../services'
import { createVulnerabilitySummary } from '../ui/presenters/vulnerability'
import { ConsoleUtils } from '../ui/utils'
import { getPerformanceTracker } from '../features/debug'

Expand Down Expand Up @@ -189,124 +184,6 @@ export class UpgradeRunner {
}
}

/**
* Non-interactive entry point: resolve the outdated list without rendering the
* TUI, then either emit a JSON report (--json) or a plain line-based report.
* With --check, sets a non-zero exit code when updates exist so CI can gate on it.
* Read-only: never writes package.json or installs.
*/
public async runHeadless(options: HeadlessOptions): Promise<void> {
try {
this.checkPrerequisites()

const packages = await this.detector.getOutdatedPackages()
const outdated = this.detector.getOutdatedPackagesOnly(packages)

// Enrich with security advisories (one bulk request, best-effort) — the same audit the
// TUI runs on `s`, so --json/--check carry the vuln signal CI cares about.
await this.enrichWithVulnerabilities(outdated)

if (options.json) {
// stdout is reserved for the JSON document only.
console.log(JSON.stringify(this.buildHeadlessReport(packages, outdated), null, 2))
} else {
this.printPlainReport(outdated)
}

// Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors.
if (options.check && outdated.length > 0) {
process.exitCode = 1
}
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`))
process.exit(2)
}
}

private buildHeadlessReport(all: PackageInfo[], outdated: PackageInfo[]): HeadlessReport {
return {
summary: {
total: all.length,
outdated: outdated.length,
major: outdated.filter((pkg) => pkg.hasMajorUpdate).length,
vulnerable: outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length,
},
outdated: outdated.map((pkg) => {
const entry: HeadlessReportEntry = {
name: pkg.name,
current: pkg.currentVersion,
range: pkg.rangeVersion,
latest: pkg.latestVersion,
type: pkg.type,
packageJsonPath: pkg.packageJsonPath,
hasMajorUpdate: pkg.hasMajorUpdate,
}
if (pkg.deprecated) entry.deprecated = pkg.deprecated
if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode
if (pkg.vulnerability) entry.vulnerability = pkg.vulnerability
return entry
}),
}
}

/**
* Attach known security advisories to the outdated packages in place. Audits each package's
* currently-installed specifier (matching the interactive audit) via one bulk request.
* Best-effort: `fetchVulnerabilities` swallows network errors and returns an empty map, so a
* failed audit never blocks the report.
*/
private async enrichWithVulnerabilities(outdated: PackageInfo[]): Promise<void> {
if (outdated.length === 0) return

// The bulk advisory API is keyed by package name (one version per name), so dedupe by name.
const versions = new Map<string, string>()
for (const pkg of outdated) {
if (!versions.has(pkg.name)) versions.set(pkg.name, pkg.currentVersion)
}

const advisories = await fetchVulnerabilities(versions)
if (advisories.size === 0) return

for (const pkg of outdated) {
const found = advisories.get(pkg.name)
if (!found || found.vulnerabilities.length === 0 || !found.highestSeverity) continue
pkg.vulnerability = createVulnerabilitySummary(
undefined,
found.vulnerabilities.map((item) => ({
id: item.id,
title: item.title,
severity: item.severity,
url: item.url,
})),
found.highestSeverity
)
}
}

private printPlainReport(outdated: PackageInfo[]): void {
if (outdated.length === 0) {
console.log('All dependencies are up to date — no upgrades needed.')
return
}

for (const pkg of outdated) {
const major = pkg.hasMajorUpdate ? ' (major)' : ''
const vuln =
pkg.vulnerability && pkg.vulnerability.count > 0
? ` [vuln: ${pkg.vulnerability.count} ${pkg.vulnerability.highestSeverity}]`
: ''
const deprecated = pkg.deprecated ? ' [deprecated]' : ''
console.log(
`${pkg.name} ${pkg.currentVersion} → ${pkg.latestVersion} [${pkg.type}]${major}${vuln}${deprecated}`
)
}

const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size
const vulnerable = outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length
const vulnNote = vulnerable > 0 ? ` — ${vulnerable} with known vulnerabilities` : ''
console.log(`\n${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`)
}

private checkPrerequisites(): void {
// Check if package.json exists
if (!this.detector.hasPackageJson()) {
Expand Down
52 changes: 52 additions & 0 deletions src/features/headless/headless-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import chalk from 'chalk'
import { PackageDetector } from '../../core/package-detector'
import type { UpgradeOptions } from '../../types'
import { auditVulnerabilities } from './vulnerability-audit'
import { buildHeadlessReport, renderPlainReport } from './report'
import { HeadlessOptions } from './types'

/**
* Non-interactive entry point. Resolves the outdated list without rendering the TUI, then either
* emits a JSON report (--json) or a plain line-based report. With --check, sets a non-zero exit
* code when updates exist so CI can gate on it. Read-only: never writes package.json or installs.
*
* This is the headless counterpart to the interactive `UpgradeRunner`; it shares only the
* `PackageDetector` (the scan/resolve layer), not the UI/upgrade machinery.
*/
export class HeadlessRunner {
private detector: PackageDetector

constructor(options?: UpgradeOptions) {
this.detector = new PackageDetector(options)
}

async run(options: HeadlessOptions): Promise<void> {
try {
if (!this.detector.hasPackageJson()) {
throw new Error('No package.json found in current directory')
}

const packages = await this.detector.getOutdatedPackages()
const outdated = this.detector.getOutdatedPackagesOnly(packages)

// Audit the current versions (one bulk request, best-effort) and cross-reference each
// advisory against the upgrade targets, so the report says whether upgrading *fixes* it.
const vulnerabilities = await auditVulnerabilities(outdated)

if (options.json) {
// stdout is reserved for the JSON document only.
console.log(JSON.stringify(buildHeadlessReport(packages, outdated, vulnerabilities), null, 2))
} else {
console.log(renderPlainReport(outdated, vulnerabilities))
}

// Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors.
if (options.check && outdated.length > 0) {
process.exitCode = 1
}
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`))
process.exit(2)
}
}
}
4 changes: 4 additions & 0 deletions src/features/headless/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './types'
export { HeadlessRunner } from './headless-runner'
export { auditVulnerabilities, upgradeClears } from './vulnerability-audit'
export { buildHeadlessReport, renderPlainReport } from './report'
73 changes: 73 additions & 0 deletions src/features/headless/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { PackageInfo } from '../../types'
import {
HEADLESS_SCHEMA_VERSION,
HeadlessReport,
HeadlessReportEntry,
HeadlessVulnerability,
} from './types'

type VulnerabilityMap = Map<PackageInfo, HeadlessVulnerability>

/** Build the machine-readable `--json` payload from the scanned + outdated package sets. */
export function buildHeadlessReport(
all: PackageInfo[],
outdated: PackageInfo[],
vulnerabilities: VulnerabilityMap
): HeadlessReport {
return {
schemaVersion: HEADLESS_SCHEMA_VERSION,
summary: {
total: all.length,
outdated: outdated.length,
major: outdated.filter((pkg) => pkg.hasMajorUpdate).length,
vulnerable: vulnerabilities.size,
},
outdated: outdated.map((pkg) => {
const entry: HeadlessReportEntry = {
name: pkg.name,
current: pkg.currentVersion,
range: pkg.rangeVersion,
latest: pkg.latestVersion,
type: pkg.type,
packageJsonPath: pkg.packageJsonPath,
hasMajorUpdate: pkg.hasMajorUpdate,
}
if (pkg.deprecated) entry.deprecated = pkg.deprecated
if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode
const vulnerability = vulnerabilities.get(pkg)
if (vulnerability) entry.vulnerability = vulnerability
return entry
}),
}
}

/** Render the plain, line-based report (one line per package + a recap) as a single string. */
export function renderPlainReport(outdated: PackageInfo[], vulnerabilities: VulnerabilityMap): string {
if (outdated.length === 0) {
return 'All dependencies are up to date — no upgrades needed.'
}

const lines = outdated.map((pkg) => {
const major = pkg.hasMajorUpdate ? ' (major)' : ''
const deprecated = pkg.deprecated ? ' [deprecated]' : ''
return `${pkg.name} ${pkg.currentVersion} → ${pkg.latestVersion} [${pkg.type}]${major}${vulnMarker(vulnerabilities.get(pkg))}${deprecated}`
})

const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size
const vulnNote =
vulnerabilities.size > 0 ? ` — ${vulnerabilities.size} with known vulnerabilities` : ''
lines.push('', `${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`)
return lines.join('\n')
}

/** A compact `[vuln: N sev → verdict]` tag for the plain report; '' when there are none. */
function vulnMarker(vulnerability: HeadlessVulnerability | undefined): string {
if (!vulnerability) return ''
// Prefer the cheaper fix: if the in-range bump already clears it, that's the safer action.
const verdict = vulnerability.fixedByRange
? 'fixed by range upgrade'
: vulnerability.fixedByLatest
? 'fixed by latest only'
: 'not fixed by upgrade'
return ` [vuln: ${vulnerability.count} ${vulnerability.highestSeverity} → ${verdict}]`
}
51 changes: 51 additions & 0 deletions src/features/headless/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { DependencyType, VulnerabilitySeverity } from '../../types'

export interface HeadlessOptions {
json?: boolean // Emit a machine-readable JSON report on stdout
check?: boolean // Exit non-zero when updates exist (CI gate)
}

/** Bump when the `--json` shape changes in a way consumers (scripts, agents) must adapt to. */
export const HEADLESS_SCHEMA_VERSION = 1

export interface HeadlessAdvisory {
id: number
title: string
severity: VulnerabilitySeverity
url: string
vulnerableVersions: string // The advisory's affected semver range, verbatim from npm
fixedByRange: boolean // The in-range target (`range`) is no longer affected
fixedByLatest: boolean // The latest target (`latest`) is no longer affected
}

export interface HeadlessVulnerability {
count: number
highestSeverity: VulnerabilitySeverity
fixedByRange: boolean // Every advisory is cleared by upgrading within the current range
fixedByLatest: boolean // Every advisory is cleared by upgrading to latest
advisories: HeadlessAdvisory[]
}

export interface HeadlessReportEntry {
name: string
current: string // Raw specifier from package.json (with ^/~ prefix)
range: string // Latest version satisfying the current range
latest: string // Absolute latest version
type: DependencyType
packageJsonPath: string
hasMajorUpdate: boolean
deprecated?: string // npm deprecation message for the latest version, if any
enginesNode?: string // declared engines.node range for the latest version, if any
vulnerability?: HeadlessVulnerability // Advisories on the current version + whether upgrading clears them
}

export interface HeadlessReport {
schemaVersion: number // HEADLESS_SCHEMA_VERSION — lets agents pin to a known shape
summary: {
total: number // Packages scanned
outdated: number // Packages with an available update
major: number // Of the outdated, how many are a major bump
vulnerable: number // Of the outdated, how many have ≥1 known advisory on the current version
}
outdated: HeadlessReportEntry[]
}
Loading
Loading