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
69 changes: 51 additions & 18 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
MIN_CONCURRENCY,
} from './lib/constants.js';
import type { Args, OutdatedMap, SkipFileConfig } from './lib/types.js';
import { isVersionHigher, parseSkipEntry } from './lib/utils.js';
import {
isSkipFileConfig,
isVersionHigher,
parseSkipEntry,
} from './lib/utils.js';
function isSortBy(value: unknown): value is Args['sortBy'] {
return (
value === 'name' ||
Expand Down Expand Up @@ -42,16 +46,46 @@ function isFormat(value: unknown): value is Args['format'] {
*/
export function parseArgs(argv: string[]): Args {
const a = new Map<string, string | true>();
const skipArgs: string[] = [];
for (let i = 2; i < argv.length; i += 1) {
const k = argv[i];
const v = argv[i + 1];
if (k.startsWith('--')) {
let k = argv[i];
let v: string | undefined = argv[i + 1];
if (!k.startsWith('--')) {
continue;
}
// Support --key=value syntax
const eqIdx = k.indexOf('=');
if (eqIdx !== -1) {
v = k.substring(eqIdx + 1);
k = k.substring(0, eqIdx);
}
if (k === '--skip') {
// Accumulate --skip values instead of overwriting
if (v && !v.startsWith('--')) {
const parts = v
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
for (const part of parts) {
if (!skipArgs.includes(part)) {
skipArgs.push(part);
}
}
if (eqIdx === -1) {
i += 1;
}
}
} else if (v !== undefined && !v.startsWith('--') && eqIdx === -1) {
a.set(k, v);
i += 1;
} else if (eqIdx !== -1 && v !== undefined) {
// For --key= with an empty value (e.g. "--concurrency="), treat it as
// "no value provided" so downstream numeric options keep their defaults.
if (v !== '') {
a.set(k, v);
i += 1;
} else {
a.set(k, true);
}
} else {
a.set(k, true);
}
}
const sortByRaw = a.get('--sort-by');
Expand All @@ -71,12 +105,7 @@ export function parseArgs(argv: string[]): Args {
const quiet = Boolean(a.get('--quiet'));
const checkAll = Boolean(a.get('--check-all'));
const iso = Boolean(a.get('--iso'));
// Parse skip packages from command line
const skipPackages: string[] = [];
const skipValue = a.get('--skip');
if (skipValue && typeof skipValue === 'string') {
skipPackages.push(...skipValue.split(',').map((p) => p.trim()));
}
const skipPackages = skipArgs;

// Load skip packages from default file
let fileSkipPackages: string[] = [];
Expand All @@ -86,11 +115,14 @@ export function parseArgs(argv: string[]): Args {
try {
const defaultSkipFile = join(process.cwd(), '.outdated-plus-skip');
const content = readFileSync(defaultSkipFile, 'utf-8');
skipConfig = JSON.parse(content);
fileSkipPackages = skipConfig?.packages || [];
skipFilePath = defaultSkipFile;
const parsed: unknown = JSON.parse(content);
if (isSkipFileConfig(parsed)) {
skipConfig = parsed;
fileSkipPackages = skipConfig.packages;
skipFilePath = defaultSkipFile;
}
} catch {
// Default file doesn't exist, ignore
// Default file doesn't exist or is invalid, ignore
}

const normalizedSort: Args['sortBy'] =
Expand All @@ -99,6 +131,7 @@ export function parseArgs(argv: string[]): Args {
: sortBy === 'published'
? 'published_latest'
: sortBy;
const mergedSkips = [...new Set([...skipPackages, ...fileSkipPackages])];
return {
olderThan,
showAll,
Expand All @@ -110,7 +143,7 @@ export function parseArgs(argv: string[]): Args {
sortBy: normalizedSort,
order,
format,
skip: [...skipPackages, ...fileSkipPackages],
skip: mergedSkips,
_skipConfig: skipConfig,
_skipFilePath: skipFilePath,
_commandLineSkips: skipPackages,
Expand Down
132 changes: 107 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,61 @@ import {
shouldSkipPackage,
} from './lib/utils.js';

/**
* Module-level AbortController for graceful shutdown.
* Triggered by SIGINT/SIGTERM to cancel all in-flight HTTP requests.
* Re-created on each run() invocation to support re-use.
*/
let shutdownController = new AbortController();

/** Used by tests to simulate SIGINT/SIGTERM during fetchPackageMeta. */
function __testAbortShutdown(): void {
shutdownController.abort();
}

/** Used by tests to reset shutdown state after simulating abort. */
function __testResetShutdown(): void {
shutdownController = new AbortController();
}

Comment thread
AlexF090 marked this conversation as resolved.
if (process.env.NODE_ENV === 'test' || process.env.VITEST) {
const g = globalThis as {
__testAbortShutdown?: () => void;
__testResetShutdown?: () => void;
};
g.__testAbortShutdown = __testAbortShutdown;
g.__testResetShutdown = __testResetShutdown;
}

/**
* Spawns a command and returns its JSON output.
*
* @param cmd - The command to execute (e.g., 'npm').
* @param args - Array of command-line arguments.
* @returns Promise that resolves to the parsed JSON output, or an empty object if parsing fails.
* @returns Promise that resolves to the parsed JSON output, or an empty object if parsing fails. Rejects if the process fails to spawn (e.g. ENOENT).
*/
export function spawnJson(cmd: string, args: string[]): Promise<unknown> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
let settled = false;
child.stdout.on('data', (c) => {
out += String(c);
});
child.on('error', (err) => {
if (!settled) {
settled = true;
reject(err);
}
});
Comment on lines 61 to +81

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says spawn errors resolve with an empty object/string, but spawnJson/spawnText now reject on child.on('error') (and run() treats that as a fatal error / exit code 1). Please align either the implementation or the PR description so the documented behavior matches what the CLI will do when npm is missing (ENOENT).

Copilot uses AI. Check for mistakes.
child.on('close', () => {
try {
resolve(out.trim() ? JSON.parse(out) : {});
} catch {
resolve({});
if (!settled) {
settled = true;
try {
resolve(out.trim() ? JSON.parse(out) : {});
} catch {
resolve({});
}
}
});
});
Expand All @@ -64,14 +100,24 @@ export function spawnJson(cmd: string, args: string[]): Promise<unknown> {
* @returns Promise that resolves to the trimmed text output.
*/
export function spawnText(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
let settled = false;
child.stdout.on('data', (c) => {
out += String(c);
});
child.on('error', (err) => {
if (!settled) {
settled = true;
reject(err);
}
});
child.on('close', () => {
resolve(out.trim());
if (!settled) {
settled = true;
resolve(out.trim());
}
});
});
}
Expand All @@ -87,18 +133,29 @@ export function spawnText(cmd: string, args: string[]): Promise<string> {
export async function fetchPackageMeta(pkg: string): Promise<Meta> {
const url = `${NPM_REGISTRY}/${encodeURIComponent(pkg)}`;

if (shutdownController.signal.aborted) {
throw new NetworkError('Operation cancelled', url);
}

const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
HTTP_REQUEST_TIMEOUT_MS,
);

const onShutdown = () => controller.abort();
shutdownController.signal.addEventListener('abort', onShutdown);
if (shutdownController.signal.aborted) {
controller.abort();
}

Comment thread
AlexF090 marked this conversation as resolved.
Comment thread
AlexF090 marked this conversation as resolved.
try {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
signal: controller.signal,
redirect: 'error',
});

if (!response.ok) {
Expand Down Expand Up @@ -128,6 +185,9 @@ export async function fetchPackageMeta(pkg: string): Promise<Meta> {
}

if (error instanceof Error && error.name === 'AbortError') {
if (shutdownController.signal.aborted) {
throw new NetworkError('Operation cancelled', url);
}
throw new NetworkError('Request timeout', url);
}

Expand All @@ -137,6 +197,7 @@ export async function fetchPackageMeta(pkg: string): Promise<Meta> {
);
} finally {
clearTimeout(timeoutId);
shutdownController.signal.removeEventListener('abort', onShutdown);
}
}

Expand All @@ -146,18 +207,35 @@ export async function fetchPackageMeta(pkg: string): Promise<Meta> {
* @param cwd - The current working directory where package.json should be located.
* @returns Object containing dependencies and devDependencies. Returns empty objects if file cannot be read or parsed.
*/
function toStringRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (typeof v === 'string') {
Comment thread
AlexF090 marked this conversation as resolved.
result[k] = v;
}
}
return result;
}

export function readPackageJson(cwd: string): {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
} {
try {
const packageJsonPath = join(cwd, 'package.json');
const content = readFileSync(packageJsonPath, 'utf-8');
const data = JSON.parse(content);
return {
dependencies: data.dependencies || {},
devDependencies: data.devDependencies || {},
};
const data: unknown = JSON.parse(content);
if (!data || typeof data !== 'object') {
return { dependencies: {}, devDependencies: {} };
}
const obj = data as Record<string, unknown>;
const deps = 'dependencies' in obj ? toStringRecord(obj.dependencies) : {};
const devDeps =
'devDependencies' in obj ? toStringRecord(obj.devDependencies) : {};
return { dependencies: deps, devDependencies: devDeps };
} catch {
return { dependencies: {}, devDependencies: {} };
}
Expand Down Expand Up @@ -326,16 +404,12 @@ export class ProgressBar {
}

/**
* Gets the total count of installed packages.
* Gets the total count of declared packages from package.json.
*/
export async function getPackageCount(): Promise<number> {
try {
const output = await spawnText('npm', ['list', '--depth=0', '--json']);
const data = JSON.parse(output);
return Object.keys(data.dependencies || {}).length;
} catch {
return 0;
}
export function getPackageCount(): number {
const cwd = process.cwd();
const { dependencies, devDependencies } = readPackageJson(cwd);
return Object.keys(dependencies).length + Object.keys(devDependencies).length;
}

/**
Expand Down Expand Up @@ -365,6 +439,11 @@ export function printUpToDateMessage(
export async function run(): Promise<number> {
const args = parseArgs(process.argv);

shutdownController = new AbortController();
const onSignal = () => shutdownController.abort();
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);

Comment thread
AlexF090 marked this conversation as resolved.
try {
let outdated: OutdatedMap;
let metas: Record<string, Meta>;
Expand All @@ -385,13 +464,13 @@ export async function run(): Promise<number> {
typeof outdatedRaw !== 'object' ||
Object.keys(outdatedRaw).length === 0
) {
const packageCount = await getPackageCount();
const packageCount = getPackageCount();
printUpToDateMessage(packageCount, args.quiet);
return 0;
}

if (!isOutdatedMap(outdatedRaw)) {
const packageCount = await getPackageCount();
const packageCount = getPackageCount();
printUpToDateMessage(packageCount, args.quiet);
return 0;
}
Expand Down Expand Up @@ -459,7 +538,7 @@ export async function run(): Promise<number> {
// Only show "up to date" message if no filtering was applied
const hasFiltering = args.olderThan > 0 || args.skip.length > 0;
if (!hasFiltering) {
const packageCount = await getPackageCount();
const packageCount = getPackageCount();
printUpToDateMessage(packageCount, args.quiet);
}
return 0;
Expand All @@ -480,6 +559,9 @@ export async function run(): Promise<number> {
console.error(formatError(error));
}
return 1;
} finally {
process.off('SIGINT', onSignal);
process.off('SIGTERM', onSignal);
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export const AGE_THRESHOLD_YELLOW = 90;
export const NPM_REGISTRY = 'https://registry.npmjs.org';
export const HTTP_REQUEST_TIMEOUT_MS = 10_000;

// Regex patterns
export const NODE_MODULES_REGEX = /node_modules\/(.+)$/;
// Regex patterns — matches the last node_modules/ segment to handle nested dependencies
export const NODE_MODULES_REGEX = /.*node_modules\/(.+)$/;

// Default concurrency
export const DEFAULT_CONCURRENCY = 12;
Expand Down
Loading
Loading