Skip to content

Commit 582511d

Browse files
authored
feat/headless-ci-v2 (#58)
* feat(headless): cross-reference advisories against upgrade targets Add fixedByRange and fixedByLatest fields to indicate whether upgrading within range or to latest resolves each advisory. Include schemaVersion in the report payload for consumer pinning. Refactor vulnerability types to support the new cross-referencing data. * refactor(headless): extract headless runner into dedicated feature module Move non-interactive CLI logic from UpgradeRunner into a new HeadlessRunner class under src/features/headless/. This separates the CI/read-only path from the interactive TUI runner and co-locates vulnerability auditing and report generation with the headless feature.
1 parent cead183 commit 582511d

12 files changed

Lines changed: 406 additions & 240 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,15 @@ inup | cat # plain line-based report when piped to a log
6868
6969
Each reported package carries its health signals: `deprecated` (npm deprecation message), `enginesNode`
7070
(declared `engines.node`), and `vulnerability` (known advisories on the currently-installed version,
71-
from one bulk `npm audit`-style request). The summary includes a `vulnerable` count.
71+
from one bulk `npm audit`-style request). Every advisory is **cross-referenced against the upgrade
72+
targets**, so you know whether the upgrade actually fixes it:
73+
74+
- `vulnerability.advisories[].fixedByRange` / `fixedByLatest` — does the in-range / latest target escape
75+
this advisory's affected range?
76+
- `vulnerability.fixedByRange` / `fixedByLatest` — does the target clear **every** advisory?
77+
78+
The summary includes a `vulnerable` count, and the payload carries a `schemaVersion` so scripts and
79+
agents can pin to a known shape.
7280
7381
Output hygiene: with `--json`, stdout carries **only** the JSON document; all progress and warnings go
7482
to stderr. Exit codes: `0` up to date, `1` updates exist (`--check`), `2` error.

src/cli.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { Command } from 'commander'
44
import chalk from 'chalk'
55
import { resolve } from 'path'
66
import { UpgradeRunner } from './index'
7+
import { HeadlessRunner } from './features/headless'
78
import { checkForUpdateAsync } from './services'
89
import { loadProjectConfig, PACKAGE_NAME, PACKAGE_VERSION } from './config'
9-
import { PackageManager } from './types'
10+
import { PackageManager, UpgradeOptions } from './types'
1011
import { enableDebugLogging, applyColorSetting } from './utils'
1112
import { getGitWorkingTreeState } from './utils/git'
1213
import { TerminalInput } from './ui'
@@ -101,26 +102,28 @@ export async function runCli(options: CliOptions): Promise<void> {
101102
packageManager = options.packageManager as PackageManager
102103
}
103104

104-
const upgrader = new UpgradeRunner({
105+
const runnerOptions: UpgradeOptions = {
105106
cwd,
106107
excludePatterns,
107108
scanDirs: projectConfig.scanDirs,
108109
maxDepth,
109110
ignorePackages,
110111
packageManager,
111-
showPeerDependencyVulnerabilities:
112-
projectConfig.showPeerDependencyVulnerabilities ?? false,
112+
showPeerDependencyVulnerabilities: projectConfig.showPeerDependencyVulnerabilities ?? false,
113113
showOptionalDependencyVulnerabilities:
114114
projectConfig.showOptionalDependencyVulnerabilities ?? false,
115115
debug: options.debug || process.env.INUP_DEBUG === '1',
116116
saveExact: options.saveExact ?? false,
117-
})
117+
}
118+
119+
// Non-interactive (piped / CI / --json / --check) routes to the read-only headless feature;
120+
// only the interactive path builds the full TUI runner.
118121
if (!interactive) {
119-
await upgrader.runHeadless({ json: options.json, check: options.check })
122+
await new HeadlessRunner(runnerOptions).run({ json: options.json, check: options.check })
120123
return
121124
}
122125

123-
await upgrader.run()
126+
await new UpgradeRunner(runnerOptions).run()
124127

125128
// After the main flow completes, check if there's an update available
126129
const updateCheck = await updateCheckPromise

src/core/upgrade-runner.ts

Lines changed: 0 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import { PackageDetector } from './package-detector'
33
import { InteractiveUI } from '../interactive-ui'
44
import { PackageUpgrader } from './upgrader'
55
import {
6-
HeadlessOptions,
7-
HeadlessReport,
8-
HeadlessReportEntry,
96
PackageInfo,
107
PackageLoadProgress,
118
PackageSelectionState,
@@ -14,8 +11,6 @@ import {
1411
PackageManagerInfo,
1512
} from '../types'
1613
import { PackageManagerDetector } from '../services/package-manager-detector'
17-
import { fetchVulnerabilities } from '../services'
18-
import { createVulnerabilitySummary } from '../ui/presenters/vulnerability'
1914
import { ConsoleUtils } from '../ui/utils'
2015
import { getPerformanceTracker } from '../features/debug'
2116

@@ -189,124 +184,6 @@ export class UpgradeRunner {
189184
}
190185
}
191186

192-
/**
193-
* Non-interactive entry point: resolve the outdated list without rendering the
194-
* TUI, then either emit a JSON report (--json) or a plain line-based report.
195-
* With --check, sets a non-zero exit code when updates exist so CI can gate on it.
196-
* Read-only: never writes package.json or installs.
197-
*/
198-
public async runHeadless(options: HeadlessOptions): Promise<void> {
199-
try {
200-
this.checkPrerequisites()
201-
202-
const packages = await this.detector.getOutdatedPackages()
203-
const outdated = this.detector.getOutdatedPackagesOnly(packages)
204-
205-
// Enrich with security advisories (one bulk request, best-effort) — the same audit the
206-
// TUI runs on `s`, so --json/--check carry the vuln signal CI cares about.
207-
await this.enrichWithVulnerabilities(outdated)
208-
209-
if (options.json) {
210-
// stdout is reserved for the JSON document only.
211-
console.log(JSON.stringify(this.buildHeadlessReport(packages, outdated), null, 2))
212-
} else {
213-
this.printPlainReport(outdated)
214-
}
215-
216-
// Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors.
217-
if (options.check && outdated.length > 0) {
218-
process.exitCode = 1
219-
}
220-
} catch (error) {
221-
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`))
222-
process.exit(2)
223-
}
224-
}
225-
226-
private buildHeadlessReport(all: PackageInfo[], outdated: PackageInfo[]): HeadlessReport {
227-
return {
228-
summary: {
229-
total: all.length,
230-
outdated: outdated.length,
231-
major: outdated.filter((pkg) => pkg.hasMajorUpdate).length,
232-
vulnerable: outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length,
233-
},
234-
outdated: outdated.map((pkg) => {
235-
const entry: HeadlessReportEntry = {
236-
name: pkg.name,
237-
current: pkg.currentVersion,
238-
range: pkg.rangeVersion,
239-
latest: pkg.latestVersion,
240-
type: pkg.type,
241-
packageJsonPath: pkg.packageJsonPath,
242-
hasMajorUpdate: pkg.hasMajorUpdate,
243-
}
244-
if (pkg.deprecated) entry.deprecated = pkg.deprecated
245-
if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode
246-
if (pkg.vulnerability) entry.vulnerability = pkg.vulnerability
247-
return entry
248-
}),
249-
}
250-
}
251-
252-
/**
253-
* Attach known security advisories to the outdated packages in place. Audits each package's
254-
* currently-installed specifier (matching the interactive audit) via one bulk request.
255-
* Best-effort: `fetchVulnerabilities` swallows network errors and returns an empty map, so a
256-
* failed audit never blocks the report.
257-
*/
258-
private async enrichWithVulnerabilities(outdated: PackageInfo[]): Promise<void> {
259-
if (outdated.length === 0) return
260-
261-
// The bulk advisory API is keyed by package name (one version per name), so dedupe by name.
262-
const versions = new Map<string, string>()
263-
for (const pkg of outdated) {
264-
if (!versions.has(pkg.name)) versions.set(pkg.name, pkg.currentVersion)
265-
}
266-
267-
const advisories = await fetchVulnerabilities(versions)
268-
if (advisories.size === 0) return
269-
270-
for (const pkg of outdated) {
271-
const found = advisories.get(pkg.name)
272-
if (!found || found.vulnerabilities.length === 0 || !found.highestSeverity) continue
273-
pkg.vulnerability = createVulnerabilitySummary(
274-
undefined,
275-
found.vulnerabilities.map((item) => ({
276-
id: item.id,
277-
title: item.title,
278-
severity: item.severity,
279-
url: item.url,
280-
})),
281-
found.highestSeverity
282-
)
283-
}
284-
}
285-
286-
private printPlainReport(outdated: PackageInfo[]): void {
287-
if (outdated.length === 0) {
288-
console.log('All dependencies are up to date — no upgrades needed.')
289-
return
290-
}
291-
292-
for (const pkg of outdated) {
293-
const major = pkg.hasMajorUpdate ? ' (major)' : ''
294-
const vuln =
295-
pkg.vulnerability && pkg.vulnerability.count > 0
296-
? ` [vuln: ${pkg.vulnerability.count} ${pkg.vulnerability.highestSeverity}]`
297-
: ''
298-
const deprecated = pkg.deprecated ? ' [deprecated]' : ''
299-
console.log(
300-
`${pkg.name} ${pkg.currentVersion}${pkg.latestVersion} [${pkg.type}]${major}${vuln}${deprecated}`
301-
)
302-
}
303-
304-
const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size
305-
const vulnerable = outdated.filter((pkg) => (pkg.vulnerability?.count ?? 0) > 0).length
306-
const vulnNote = vulnerable > 0 ? ` — ${vulnerable} with known vulnerabilities` : ''
307-
console.log(`\n${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`)
308-
}
309-
310187
private checkPrerequisites(): void {
311188
// Check if package.json exists
312189
if (!this.detector.hasPackageJson()) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import chalk from 'chalk'
2+
import { PackageDetector } from '../../core/package-detector'
3+
import type { UpgradeOptions } from '../../types'
4+
import { auditVulnerabilities } from './vulnerability-audit'
5+
import { buildHeadlessReport, renderPlainReport } from './report'
6+
import { HeadlessOptions } from './types'
7+
8+
/**
9+
* Non-interactive entry point. Resolves the outdated list without rendering the TUI, then either
10+
* emits a JSON report (--json) or a plain line-based report. With --check, sets a non-zero exit
11+
* code when updates exist so CI can gate on it. Read-only: never writes package.json or installs.
12+
*
13+
* This is the headless counterpart to the interactive `UpgradeRunner`; it shares only the
14+
* `PackageDetector` (the scan/resolve layer), not the UI/upgrade machinery.
15+
*/
16+
export class HeadlessRunner {
17+
private detector: PackageDetector
18+
19+
constructor(options?: UpgradeOptions) {
20+
this.detector = new PackageDetector(options)
21+
}
22+
23+
async run(options: HeadlessOptions): Promise<void> {
24+
try {
25+
if (!this.detector.hasPackageJson()) {
26+
throw new Error('No package.json found in current directory')
27+
}
28+
29+
const packages = await this.detector.getOutdatedPackages()
30+
const outdated = this.detector.getOutdatedPackagesOnly(packages)
31+
32+
// Audit the current versions (one bulk request, best-effort) and cross-reference each
33+
// advisory against the upgrade targets, so the report says whether upgrading *fixes* it.
34+
const vulnerabilities = await auditVulnerabilities(outdated)
35+
36+
if (options.json) {
37+
// stdout is reserved for the JSON document only.
38+
console.log(JSON.stringify(buildHeadlessReport(packages, outdated, vulnerabilities), null, 2))
39+
} else {
40+
console.log(renderPlainReport(outdated, vulnerabilities))
41+
}
42+
43+
// Exit 1 only means "updates exist" (like `prettier --check`); 2 is reserved for errors.
44+
if (options.check && outdated.length > 0) {
45+
process.exitCode = 1
46+
}
47+
} catch (error) {
48+
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`))
49+
process.exit(2)
50+
}
51+
}
52+
}

src/features/headless/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './types'
2+
export { HeadlessRunner } from './headless-runner'
3+
export { auditVulnerabilities, upgradeClears } from './vulnerability-audit'
4+
export { buildHeadlessReport, renderPlainReport } from './report'

src/features/headless/report.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { PackageInfo } from '../../types'
2+
import {
3+
HEADLESS_SCHEMA_VERSION,
4+
HeadlessReport,
5+
HeadlessReportEntry,
6+
HeadlessVulnerability,
7+
} from './types'
8+
9+
type VulnerabilityMap = Map<PackageInfo, HeadlessVulnerability>
10+
11+
/** Build the machine-readable `--json` payload from the scanned + outdated package sets. */
12+
export function buildHeadlessReport(
13+
all: PackageInfo[],
14+
outdated: PackageInfo[],
15+
vulnerabilities: VulnerabilityMap
16+
): HeadlessReport {
17+
return {
18+
schemaVersion: HEADLESS_SCHEMA_VERSION,
19+
summary: {
20+
total: all.length,
21+
outdated: outdated.length,
22+
major: outdated.filter((pkg) => pkg.hasMajorUpdate).length,
23+
vulnerable: vulnerabilities.size,
24+
},
25+
outdated: outdated.map((pkg) => {
26+
const entry: HeadlessReportEntry = {
27+
name: pkg.name,
28+
current: pkg.currentVersion,
29+
range: pkg.rangeVersion,
30+
latest: pkg.latestVersion,
31+
type: pkg.type,
32+
packageJsonPath: pkg.packageJsonPath,
33+
hasMajorUpdate: pkg.hasMajorUpdate,
34+
}
35+
if (pkg.deprecated) entry.deprecated = pkg.deprecated
36+
if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode
37+
const vulnerability = vulnerabilities.get(pkg)
38+
if (vulnerability) entry.vulnerability = vulnerability
39+
return entry
40+
}),
41+
}
42+
}
43+
44+
/** Render the plain, line-based report (one line per package + a recap) as a single string. */
45+
export function renderPlainReport(outdated: PackageInfo[], vulnerabilities: VulnerabilityMap): string {
46+
if (outdated.length === 0) {
47+
return 'All dependencies are up to date — no upgrades needed.'
48+
}
49+
50+
const lines = outdated.map((pkg) => {
51+
const major = pkg.hasMajorUpdate ? ' (major)' : ''
52+
const deprecated = pkg.deprecated ? ' [deprecated]' : ''
53+
return `${pkg.name} ${pkg.currentVersion}${pkg.latestVersion} [${pkg.type}]${major}${vulnMarker(vulnerabilities.get(pkg))}${deprecated}`
54+
})
55+
56+
const fileCount = new Set(outdated.map((pkg) => pkg.packageJsonPath)).size
57+
const vulnNote =
58+
vulnerabilities.size > 0 ? ` — ${vulnerabilities.size} with known vulnerabilities` : ''
59+
lines.push('', `${outdated.length} package(s) outdated across ${fileCount} file(s)${vulnNote}.`)
60+
return lines.join('\n')
61+
}
62+
63+
/** A compact `[vuln: N sev → verdict]` tag for the plain report; '' when there are none. */
64+
function vulnMarker(vulnerability: HeadlessVulnerability | undefined): string {
65+
if (!vulnerability) return ''
66+
// Prefer the cheaper fix: if the in-range bump already clears it, that's the safer action.
67+
const verdict = vulnerability.fixedByRange
68+
? 'fixed by range upgrade'
69+
: vulnerability.fixedByLatest
70+
? 'fixed by latest only'
71+
: 'not fixed by upgrade'
72+
return ` [vuln: ${vulnerability.count} ${vulnerability.highestSeverity}${verdict}]`
73+
}

src/features/headless/types.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { DependencyType, VulnerabilitySeverity } from '../../types'
2+
3+
export interface HeadlessOptions {
4+
json?: boolean // Emit a machine-readable JSON report on stdout
5+
check?: boolean // Exit non-zero when updates exist (CI gate)
6+
}
7+
8+
/** Bump when the `--json` shape changes in a way consumers (scripts, agents) must adapt to. */
9+
export const HEADLESS_SCHEMA_VERSION = 1
10+
11+
export interface HeadlessAdvisory {
12+
id: number
13+
title: string
14+
severity: VulnerabilitySeverity
15+
url: string
16+
vulnerableVersions: string // The advisory's affected semver range, verbatim from npm
17+
fixedByRange: boolean // The in-range target (`range`) is no longer affected
18+
fixedByLatest: boolean // The latest target (`latest`) is no longer affected
19+
}
20+
21+
export interface HeadlessVulnerability {
22+
count: number
23+
highestSeverity: VulnerabilitySeverity
24+
fixedByRange: boolean // Every advisory is cleared by upgrading within the current range
25+
fixedByLatest: boolean // Every advisory is cleared by upgrading to latest
26+
advisories: HeadlessAdvisory[]
27+
}
28+
29+
export interface HeadlessReportEntry {
30+
name: string
31+
current: string // Raw specifier from package.json (with ^/~ prefix)
32+
range: string // Latest version satisfying the current range
33+
latest: string // Absolute latest version
34+
type: DependencyType
35+
packageJsonPath: string
36+
hasMajorUpdate: boolean
37+
deprecated?: string // npm deprecation message for the latest version, if any
38+
enginesNode?: string // declared engines.node range for the latest version, if any
39+
vulnerability?: HeadlessVulnerability // Advisories on the current version + whether upgrading clears them
40+
}
41+
42+
export interface HeadlessReport {
43+
schemaVersion: number // HEADLESS_SCHEMA_VERSION — lets agents pin to a known shape
44+
summary: {
45+
total: number // Packages scanned
46+
outdated: number // Packages with an available update
47+
major: number // Of the outdated, how many are a major bump
48+
vulnerable: number // Of the outdated, how many have ≥1 known advisory on the current version
49+
}
50+
outdated: HeadlessReportEntry[]
51+
}

0 commit comments

Comments
 (0)