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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,30 @@ inup [options]
-i, --ignore <packages> Ignore packages (comma-separated, glob supported)
--max-depth <number> Maximum scan depth for package discovery (default: 10)
--package-manager <name> Force package manager (npm, yarn, pnpm, bun)
--json Print a machine-readable JSON report and exit (read-only)
-c, --check Exit non-zero if updates exist, without writing (for CI)
--debug Write verbose debug logs
```

## CI & Scripting

`inup` runs headless automatically when stdout isn't a TTY or `$CI` is set, so it never hangs in a
pipeline waiting on the interactive UI. Both `--json` and `--check` are **read-only** — they report,
they never edit `package.json` or install.

```bash
inup --check # exit 1 if anything is outdated → fails the build
inup --json | jq # structured drift report for dashboards/bots
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.

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.

## Keyboard Shortcuts

<!-- KEYS:START -->
Expand Down
41 changes: 30 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface CliOptions {
debug?: boolean
color?: boolean
saveExact?: boolean
json?: boolean
check?: boolean
}

export async function runCli(options: CliOptions): Promise<void> {
Expand All @@ -34,15 +36,22 @@ export async function runCli(options: CliOptions): Promise<void> {
enableDebugLogging()
}

const gitState = getGitWorkingTreeState(cwd)
if (gitState.isRepo && gitState.isDirty) {
const shouldProceed = await TerminalInput.promptForImmediateConfirmation(
`${chalk.yellow('Warning:')} dirty working tree. Proceed anyway? ${chalk.dim('[y/N]')} `,
false
)
if (!shouldProceed) {
console.log(chalk.yellow('Upgrade cancelled.'))
return
// Headless when piped, in CI, or when a non-interactive flag is set. The TUI only renders in
// interactive mode; everything else routes through the read-only headless report path.
const interactive = !!process.stdout.isTTY && !process.env.CI && !options.json && !options.check

// The dirty-tree prompt would hang without a TTY; headless is read-only anyway, so skip it.
if (interactive) {
const gitState = getGitWorkingTreeState(cwd)
if (gitState.isRepo && gitState.isDirty) {
const shouldProceed = await TerminalInput.promptForImmediateConfirmation(
`${chalk.yellow('Warning:')} dirty working tree. Proceed anyway? ${chalk.dim('[y/N]')} `,
false
)
if (!shouldProceed) {
console.log(chalk.yellow('Upgrade cancelled.'))
return
}
}
}

Expand Down Expand Up @@ -74,8 +83,11 @@ export async function runCli(options: CliOptions): Promise<void> {
process.exit(1)
}

// Check for updates in the background (non-blocking)
const updateCheckPromise = checkForUpdateAsync(PACKAGE_NAME, PACKAGE_VERSION)
// Check for updates in the background (non-blocking). Interactive only — keeps headless stdout
// clean and avoids a lingering fetch handle in CI.
const updateCheckPromise = interactive
? checkForUpdateAsync(PACKAGE_NAME, PACKAGE_VERSION)
: undefined

// Validate package manager if provided
let packageManager: PackageManager | undefined
Expand Down Expand Up @@ -103,6 +115,11 @@ export async function runCli(options: CliOptions): Promise<void> {
debug: options.debug || process.env.INUP_DEBUG === '1',
saveExact: options.saveExact ?? false,
})
if (!interactive) {
await upgrader.runHeadless({ json: options.json, check: options.check })
return
}

await upgrader.run()

// After the main flow completes, check if there's an update available
Expand Down Expand Up @@ -150,6 +167,8 @@ program
.option('--debug', 'write verbose debug log to /tmp/inup-debug-YYYY-MM-DD.log')
.option('--no-color', 'disable colored output (also respects NO_COLOR / FORCE_COLOR)')
.option('--save-exact', 'write exact versions instead of preserving the range prefix (^/~)')
.option('--json', 'print a machine-readable JSON report and exit (non-interactive, read-only)')
.option('-c, --check', 'exit non-zero if updates exist, without writing (for CI; read-only)')
.action(runCli)

// Handle uncaught errors gracefully
Expand Down
123 changes: 123 additions & 0 deletions src/core/upgrade-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { PackageDetector } from './package-detector'
import { InteractiveUI } from '../interactive-ui'
import { PackageUpgrader } from './upgrader'
import {
HeadlessOptions,
HeadlessReport,
HeadlessReportEntry,
PackageInfo,
PackageLoadProgress,
PackageSelectionState,
Expand All @@ -11,6 +14,8 @@ 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 @@ -184,6 +189,124 @@ 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
7 changes: 4 additions & 3 deletions src/services/package-manager-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export class PackageManagerDetector {
return fromLockFile
}

// 3. Fallback to npm
console.log(
// 3. Fallback to npm. Warn on stderr so it never corrupts --json output on stdout.
console.error(
chalk.yellow(
'⚠️ No package manager detected. Defaulting to npm. Consider adding a "packageManager" field to your package.json.'
)
Expand Down Expand Up @@ -129,7 +129,8 @@ export class PackageManagerDetector {

// If multiple lock files, use most recently modified
if (existingLocks.length > 1) {
console.log(
// stderr so it never corrupts --json output on stdout.
console.error(
chalk.yellow(
'⚠️ Multiple lock files detected. Using most recently modified. Consider cleaning up unused lock files.'
)
Expand Down
28 changes: 28 additions & 0 deletions src/types/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ export interface UpgradeOptions extends VulnerabilityDisplayOptions {
saveExact?: boolean // Write bare versions instead of preserving the range prefix (^/~)
}

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

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?: VulnerabilitySummary
}

export interface HeadlessReport {
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[]
}

export interface PackageJson {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
Expand Down
10 changes: 7 additions & 3 deletions src/ui/utils/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,20 @@ export const ConsoleUtils = {
LINE_WIDTH: 80,

/**
* Show a progress message on the current line (overwrites previous content)
* Show a progress message on the current line (overwrites previous content).
* Written to stderr so stdout stays clean for --json / piped output, and only
* when stderr is a TTY — the \r animation is just noise in a redirected log.
*/
showProgress(message: string): void {
process.stdout.write(`\r${' '.repeat(ConsoleUtils.LINE_WIDTH)}\r${message}`)
if (!process.stderr.isTTY) return
process.stderr.write(`\r${' '.repeat(ConsoleUtils.LINE_WIDTH)}\r${message}`)
},

/**
* Clear the current progress line
*/
clearProgress(): void {
process.stdout.write('\r' + ' '.repeat(ConsoleUtils.LINE_WIDTH) + '\r')
if (!process.stderr.isTTY) return
process.stderr.write('\r' + ' '.repeat(ConsoleUtils.LINE_WIDTH) + '\r')
},
}
Loading
Loading