Skip to content
Open
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,34 @@ inup [options]
-c, --check Exit non-zero if updates exist, without writing (for CI)
--apply Non-interactively write upgrades + install (for CI/automation)
--target <level> With --apply: how far to bump — minor (default) | patch | latest
--minimum-release-age <mins> Only offer versions published at least this many minutes ago
--debug Write verbose debug logs
```

### Release-age cooldown (supply-chain guard)

Freshly published versions are the ones most likely to be a compromised release nobody has
caught yet. With a cooldown, inup simply doesn't offer versions younger than the window — in
the TUI, in reports, and in `--apply`:

```bash
inup --minimum-release-age 10080 # ignore versions younger than 7 days
```

Or persist it in `.inuprc` (minutes, matching pnpm's setting of the same name), with optional
per-package exemptions:

```json
{
"minimumReleaseAge": 10080,
"minimumReleaseAgeExclude": ["@myco/*", "internal-tool"]
}
```

Registries that don't expose publish times are unaffected (the policy only acts on positive
evidence). Enabling the cooldown fetches the full registry metadata instead of the abbreviated
format, so the first run is somewhat slower; subsequent runs are cushioned by inup's ETag cache.

## CI & Scripting

Run inup non-interactively to gate builds on outdated or vulnerable dependencies, generate a
Expand Down
21 changes: 21 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface CliOptions {
check?: boolean
apply?: boolean
target?: string
minimumReleaseAge?: string
}

export async function runCli(options: CliOptions): Promise<void> {
Expand All @@ -57,6 +58,19 @@ export async function runCli(options: CliOptions): Promise<void> {
process.exit(1)
}

// Validate --minimum-release-age the same way. Undefined means "defer to .inuprc".
let cliMinimumReleaseAge: number | undefined
if (options.minimumReleaseAge !== undefined) {
cliMinimumReleaseAge = Number.parseInt(options.minimumReleaseAge, 10)
if (!Number.isInteger(cliMinimumReleaseAge) || cliMinimumReleaseAge < 0) {
console.error(chalk.red(`Invalid minimum release age: ${options.minimumReleaseAge}`))
console.error(
chalk.yellow('Expected a non-negative number of minutes, e.g. --minimum-release-age 10080')
)
process.exit(1)
}
}

// The dirty-tree prompt would hang without a TTY; headless is read-only anyway, so skip it.
if (interactive) {
const gitState = getGitWorkingTreeState(cwd)
Expand Down Expand Up @@ -130,6 +144,9 @@ export async function runCli(options: CliOptions): Promise<void> {
projectConfig.showOptionalDependencyVulnerabilities ?? false,
debug: options.debug || process.env.INUP_DEBUG === '1',
saveExact: options.saveExact ?? false,
// CLI wins over .inuprc for the scalar; the exclusion list only comes from config.
minimumReleaseAge: cliMinimumReleaseAge ?? projectConfig.minimumReleaseAge ?? 0,
minimumReleaseAgeExclude: projectConfig.minimumReleaseAgeExclude,
// Adaptive concurrency defaults ON; it's an internal/dev toggle with no public
// flag. Set INUP_ADAPTIVE=0 to disable (e.g. for A/B perf comparisons).
adaptive: process.env.INUP_ADAPTIVE !== '0',
Expand Down Expand Up @@ -201,6 +218,10 @@ program
'--apply',
'non-interactively write upgrades to package.json and run install (honors .inuprc ignore/exclude)'
)
.option(
'--minimum-release-age <minutes>',
'only offer versions published at least this many minutes ago (supply-chain cooldown; also via .inuprc)'
)
.option(
'--target <level>',
'with --apply: how far to bump — minor | patch | latest (default: minor, in-range only)',
Expand Down
74 changes: 70 additions & 4 deletions src/features/upgrade/package-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
StreamOutdatedPackagesInitialPayload,
UpgradeOptions,
} from '../../shared/types'
import { findClosestMinorVersion } from '../../shared/versions'
import { filterVersionsByReleaseAge, findClosestMinorVersion } from '../../shared/versions'
import { getPerformanceTracker, isPerfLoggingEnabled } from '../debug'

interface PreparedDependencies {
Expand All @@ -37,6 +37,8 @@ export class PackageDetector {
private scanDirs: string[]
private ignorePackages: string[]
private maxDepth: number
private minimumReleaseAge: number
private minimumReleaseAgeExclude: string[]

private readonly batchSize = 10
private readonly maxConcurrency = 10
Expand All @@ -49,6 +51,8 @@ export class PackageDetector {
this.ignorePackages = options?.ignorePackages || []
this.maxDepth = options?.maxDepth ?? 10
this.adaptive = options?.adaptive ?? true
this.minimumReleaseAge = options?.minimumReleaseAge ?? 0
this.minimumReleaseAgeExclude = options?.minimumReleaseAgeExclude ?? []
this.packageJsonPath = findPackageJson(this.cwd)
if (this.packageJsonPath) {
this.packageJson = readPackageJson(this.packageJsonPath)
Expand Down Expand Up @@ -130,6 +134,9 @@ export class PackageDetector {
batchSize: this.batchSize,
maxConcurrency: this.maxConcurrency,
adaptive: this.adaptive,
// Publish times live only in the full packument; fetch it only when the
// release-age policy actually needs them.
fullMetadata: this.minimumReleaseAge > 0,
onControlTick: (tick) => performanceTracker.recordControlTick(tick),
onPackageTiming: isPerfLoggingEnabled()
? (name, latencyMs) => performanceTracker.recordPackageTiming({ name, latencyMs })
Expand Down Expand Up @@ -392,7 +399,10 @@ export class PackageDetector {
return this.createFailedPackageInfo(dep)
}

const { latestVersion, allVersions } = packageData
const { latestVersion, allVersions, deprecated, enginesNode } = this.applyReleaseAgePolicy(
dep,
packageData
)
const closestMinorVersion = findClosestMinorVersion(dep.version, allVersions)

const installedClean = semver.coerce(dep.version)?.version || dep.version
Expand Down Expand Up @@ -433,8 +443,8 @@ export class PackageDetector {
hasRangeUpdate,
hasMajorUpdate,
allVersions,
deprecated: packageData.deprecated,
enginesNode: packageData.enginesNode,
deprecated,
enginesNode,
}
} catch (error) {
debugLog.error('PackageDetector', `error processing ${dep.name}`, error)
Expand All @@ -443,6 +453,62 @@ export class PackageDetector {
})
}

/**
* Enforce the release-age cooldown (`minimumReleaseAge`, minutes): versions published more
* recently than the window are treated as if they don't exist yet, so neither the TUI nor
* --apply can pick them. Freshly published versions are the most likely to be a compromised
* release nobody has caught yet.
*
* When the true latest is gated away, the health signals tied to it (deprecation, engines)
* are dropped rather than misattributed to the older effective latest. If EVERY version is
* too young (brand-new package), the installed version becomes the effective latest — the
* package simply reports "nothing to upgrade to (yet)".
*/
private applyReleaseAgePolicy(
dep: DependencyEntry,
packageData: PackageVersionData
): PackageVersionData {
if (this.minimumReleaseAge <= 0) return packageData
if (
this.minimumReleaseAgeExclude.length > 0 &&
isPackageIgnored(dep.name, this.minimumReleaseAgeExclude)
) {
return packageData
}

const eligible = filterVersionsByReleaseAge(
packageData.allVersions,
packageData.publishTimes,
this.minimumReleaseAge
)
if (eligible.length === packageData.allVersions.length) return packageData

const gatedCount = packageData.allVersions.length - eligible.length
const effectiveLatest =
eligible.slice().sort(semver.rcompare)[0] ??
(semver.coerce(dep.version)?.version || dep.version)
const latestUnchanged = effectiveLatest === packageData.latestVersion

const gateKey = `${dep.name}@${dep.version}`
if (!this.loggedReleaseAgeGates.has(gateKey)) {
this.loggedReleaseAgeGates.add(gateKey)
debugLog.info(
'PackageDetector',
`release-age gate: ${gatedCount} version(s) of ${dep.name} younger than ${this.minimumReleaseAge}min hidden (effective latest: ${effectiveLatest})`
)
}

return {
...packageData,
latestVersion: effectiveLatest,
allVersions: eligible,
deprecated: latestUnchanged ? packageData.deprecated : undefined,
enginesNode: latestUnchanged ? packageData.enginesNode : undefined,
}
}

private readonly loggedReleaseAgeGates = new Set<string>()

private createFailedPackageInfo(dep: DependencyEntry): PackageInfo {
return {
name: dep.name,
Expand Down
27 changes: 27 additions & 0 deletions src/shared/config/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export interface InupProjectConfig {
*/
scanDirs?: string[]

/**
* Release-age cooldown in MINUTES (matching pnpm's setting of the same name): versions
* published more recently than this are not offered as upgrade targets. A supply-chain
* guard against freshly compromised releases. 0 or absent disables the policy.
* Example: 10080 = 7 days.
*/
minimumReleaseAge?: number

/**
* Packages exempt from `minimumReleaseAge`. Supports exact names and the same glob
* patterns as `ignore` (e.g. "@myco/*").
*/
minimumReleaseAgeExclude?: string[]

/**
* Show vulnerability badges for peerDependencies in the package list.
* Defaults to false so peer dependency risk stays hidden unless explicitly enabled.
Expand Down Expand Up @@ -100,6 +114,19 @@ function normalizeConfig(config: InupProjectConfig): InupProjectConfig {
}
}

// JSON.parse can only yield finite numbers, so type + sign checks are sufficient.
if (typeof config.minimumReleaseAge === 'number' && config.minimumReleaseAge >= 0) {
normalized.minimumReleaseAge = config.minimumReleaseAge
}

if (config.minimumReleaseAgeExclude) {
if (Array.isArray(config.minimumReleaseAgeExclude)) {
normalized.minimumReleaseAgeExclude = config.minimumReleaseAgeExclude.filter(
(item) => typeof item === 'string'
)
}
}

if (typeof config.showPeerDependencyVulnerabilities === 'boolean') {
normalized.showPeerDependencyVulnerabilities = config.showPeerDependencyVulnerabilities
}
Expand Down
32 changes: 25 additions & 7 deletions src/shared/registry/npm-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface PackageVersionData {
allVersions: string[]
deprecated?: string // npm deprecation message for the latest version, if any
enginesNode?: string // declared engines.node range for the latest version, if any
/** ISO publish time per version; only present when the full packument was fetched. */
publishTimes?: Record<string, string>
}

const inFlightLookups = new InflightMap<PackageVersionData>()
Expand Down Expand Up @@ -80,10 +82,13 @@ const DEFAULT_FIXED_CONCURRENCY = 10
async function getFreshPackageData(
packageName: string,
currentVersion: string | undefined,
fullMetadata: boolean,
onAttempt?: AttemptObserver
): Promise<PackageVersionData> {
const cacheKey = `${packageName}@${currentVersion ?? ''}`
return inFlightLookups.dedupe(cacheKey, () => fetchPackageFromRegistry(packageName, onAttempt))
const cacheKey = `${packageName}@${currentVersion ?? ''}${fullMetadata ? '#full' : ''}`
return inFlightLookups.dedupe(cacheKey, () =>
fetchPackageFromRegistry(packageName, fullMetadata, onAttempt)
)
}

const encodeRegistryPath = (packageName: string, pathPrefix: string): string => {
Expand Down Expand Up @@ -112,18 +117,23 @@ export type AttemptObserver = (outcome: RegistryAttemptOutcome) => void

async function attemptRegistryFetch(
target: RegistryTarget,
path: string
path: string,
fullMetadata: boolean
): Promise<RegistryAttemptOutcome> {
const startedAt = Date.now()
// Conditional request: if we have a stored ETag for this packument, ask the
// registry to validate it. Unchanged → 304 (no body) and we reuse stored data.
// This still hits the registry every run, so data is never served stale.
// Keys are origin-qualified so two registries can never collide on a path.
const cacheKey = `${target.origin}${path}`
// Full-packument responses parse to richer data (publish times), so they get
// their own cache entry — a 304 must never revive an abbreviated-format body.
const cacheKey = `${target.origin}${path}${fullMetadata ? '#full' : ''}`
const cached = readEtag(cacheKey)
try {
const requestHeaders: Record<string, string> = {
accept: 'application/vnd.npm.install-v1+json',
// The abbreviated install-v1 format is much smaller but has no `time` field;
// release-age policies need publish times, hence the full packument.
accept: fullMetadata ? 'application/json' : 'application/vnd.npm.install-v1+json',
'accept-encoding': 'gzip, deflate, br',
}
if (target.authHeader) {
Expand Down Expand Up @@ -204,11 +214,12 @@ async function attemptRegistryFetch(
async function fetchFromRegistryWithRetries(
target: RegistryTarget,
path: string,
fullMetadata: boolean,
onAttempt?: AttemptObserver
): Promise<RegistryAttemptOutcome> {
let lastOutcome: RegistryAttemptOutcome = { kind: 'transient' }
for (let attempt = 0; attempt < MAX_REGISTRY_ATTEMPTS; attempt++) {
const outcome = await attemptRegistryFetch(target, path)
const outcome = await attemptRegistryFetch(target, path, fullMetadata)
onAttempt?.(outcome)
if (outcome.kind === 'success' || outcome.kind === 'not-found') {
return outcome
Expand All @@ -229,13 +240,14 @@ async function fetchFromRegistryWithRetries(

async function fetchPackageFromRegistry(
packageName: string,
fullMetadata: boolean,
onAttempt?: AttemptObserver
): Promise<PackageVersionData> {
// Scoped packages may live on a different registry (with credentials) than
// unscoped ones — resolved from the npm config chain, memoized per scope.
const target = registryTargetFor(packageName)
const path = encodeRegistryPath(packageName, target.pathPrefix)
const outcome = await fetchFromRegistryWithRetries(target, path, onAttempt)
const outcome = await fetchFromRegistryWithRetries(target, path, fullMetadata, onAttempt)

if (outcome.kind === 'success') {
return outcome.data
Expand Down Expand Up @@ -279,6 +291,11 @@ export async function fetchPackageVersions(
onControlTick?: (tick: ControlTick) => void
/** Per-package successful round-trip latency, for perf diagnostics. */
onPackageTiming?: (name: string, latencyMs: number) => void
/**
* Fetch the FULL packument instead of the abbreviated install-v1 format. Larger payloads,
* but includes per-version publish times — required by release-age policies. Default: false.
*/
fullMetadata?: boolean
} & FetchPackageVersionsOptions = {}
): Promise<Map<string, PackageVersionData>> {
const packageData = new Map<string, PackageVersionData>()
Expand Down Expand Up @@ -377,6 +394,7 @@ export async function fetchPackageVersions(
const data = await getFreshPackageData(
packageName,
options.currentVersions?.get(packageName),
options.fullMetadata ?? false,
observerFor(packageName)
)
packageData.set(packageName, data)
Expand Down
2 changes: 2 additions & 0 deletions src/shared/types/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export interface UpgradeOptions extends VulnerabilityDisplayOptions {
debug?: boolean // Write verbose debug log to /tmp/inup-debug-YYYY-MM-DD.log
saveExact?: boolean // Write bare versions instead of preserving the range prefix (^/~)
adaptive?: boolean // Adaptive registry concurrency (AIMD). Defaults to true.
minimumReleaseAge?: number // Minutes a version must have been public before it's offered. 0/absent disables.
minimumReleaseAgeExclude?: string[] // Package names/patterns exempt from minimumReleaseAge
}

export interface PackageJson {
Expand Down
Loading
Loading