diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f0a84c..c6aed6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 20.15.0 + node-version: 22.20.0 - run: npm ci - run: npm test diff --git a/src/lib/cli-options.ts b/src/lib/cli-options.ts index 7de6f92..25ac4a6 100644 --- a/src/lib/cli-options.ts +++ b/src/lib/cli-options.ts @@ -38,7 +38,7 @@ export function getCliOptions() { ) .option( "--filter ", - "filter packages by wildcard pattern matching from package.json file", + "filter packages by regex pattern matching from package.json file", ) .option( "--warn-days ", @@ -62,8 +62,8 @@ export function getCliOptions() { Examples: $ npm-check-last-publish --sort name --order asc $ npm-check-last-publish --sort average - $ npm-check-last-publish --filter "@types/*" - $ npm-check-last-publish --filter "react-*" + $ npm-check-last-publish --filter @types + $ npm-check-last-publish --filter "^react.*s$" $ npm-check-last-publish --warn-days 60 --error-days 120 $ npm-check-last-publish --output json > report.json $ npm-check-last-publish --output csv > report.csv diff --git a/src/lib/expand-package-patterns.ts b/src/lib/expand-package-patterns.ts index 85c0d54..11ba956 100644 --- a/src/lib/expand-package-patterns.ts +++ b/src/lib/expand-package-patterns.ts @@ -1,14 +1,10 @@ -import { globRegex } from "./glob-regex.ts"; - export function expandPackagePatterns( pattern: string, allNames: string[], ): string[] { - if (!pattern.includes("*")) return [pattern]; - - const regex = globRegex(pattern); const matched = allNames.filter((pkg) => { - return regex.test(pkg); + return new RegExp(pattern).test(pkg); }); + return [...new Set(matched)]; } diff --git a/src/lib/fetch-packages.ts b/src/lib/fetch-packages.ts index 0f10ce2..33612a5 100644 --- a/src/lib/fetch-packages.ts +++ b/src/lib/fetch-packages.ts @@ -1,76 +1,37 @@ -import { styleText } from "node:util"; -import type { PackagePublishInfo } from "../types.ts"; import { expandPackagePatterns } from "./expand-package-patterns.ts"; -import { getPackagePublishDate } from "./get-package-publish-date.ts"; -import { progressBar } from "./progress-bar.ts"; import { readPackageJson } from "./read-package-json.ts"; -type PackageInfoResult = { - results: (PackagePublishInfo | null)[]; - errors: { package: string; error: Error }[]; -}; +export class NoPackageFoundError extends Error { + constructor() { + super("No packages found to check."); + this.name = "NoPackageFoundError"; + } +} export async function fetchPackageInfoList( inputPackages: string[], filter: string, -): Promise { +): Promise { let packagesToCheck: string[]; const shouldReadFromPackage = inputPackages.length === 0 || filter; if (shouldReadFromPackage) { - try { - const packageJSON = await readPackageJson(); - const allDependencies = { - ...packageJSON.dependencies, - ...packageJSON.devDependencies, - }; - packagesToCheck = Object.keys(allDependencies); - packagesToCheck = filter - ? expandPackagePatterns(filter, packagesToCheck) - : packagesToCheck; - } catch (e) { - if (e instanceof Error && e.message === "FILE_NOT_FOUND") { - console.error( - styleText( - "redBright", - `[ERROR]: Cannot find ${styleText( - "bold", - "package.json", - )}. Please ensure that the file exists in your current working directory. \n`, - ), - ); - process.exit(1); - } - - throw e; - } + const packageJSON = await readPackageJson(); + const allDependencies = { + ...packageJSON.dependencies, + ...packageJSON.devDependencies, + }; + packagesToCheck = Object.keys(allDependencies); + packagesToCheck = filter + ? expandPackagePatterns(filter, packagesToCheck) + : packagesToCheck; } else { packagesToCheck = inputPackages.map((p) => p.toLowerCase()); } if (packagesToCheck.length === 0) { - throw new Error("No matching packages found for given patterns."); - } - - const errors: { package: string; error: Error }[] = []; - const results: (PackagePublishInfo | null)[] = []; - - progressBar.start(packagesToCheck.length, 0); - - for (const pkgName of packagesToCheck) { - try { - const info = await getPackagePublishDate(pkgName); - results.push(info); - } catch (err) { - const message = - err instanceof Error ? err.message.split("\n")[0] : String(err); - errors.push({ package: pkgName, error: new Error(message) }); - results.push(null); - } - progressBar.increment(); + throw new NoPackageFoundError(); } - progressBar.stop(); - - return { results, errors }; + return packagesToCheck; } diff --git a/src/lib/format-package-info.ts b/src/lib/format-package-info.ts index f592cb0..39998df 100644 --- a/src/lib/format-package-info.ts +++ b/src/lib/format-package-info.ts @@ -1,18 +1,23 @@ -import type { PackageInfo, PackagePublishInfo, Thresholds } from "../types.ts"; +import { errorPackageInfo, type PackageInfo } from "../models/package-info.ts"; +import type { PackagePublishInfo } from "../models/package-publish-info.ts"; +import type { Thresholds } from "../types.ts"; import { getAveragePublishDays } from "./get-average-publish-days.ts"; import { getColorArea } from "./get-color-area.ts"; interface FormatPackageOptions { - pkg: PackagePublishInfo; + publishInfo: PackagePublishInfo; thresholds: Thresholds; } export function formatPackageInfo({ - pkg, + publishInfo, thresholds, }: FormatPackageOptions): PackageInfo { + if (publishInfo.tag === "Error") + return errorPackageInfo(publishInfo.packageName); + const { packageName, packagePublishDate, packageVersion, publishedTimes } = - pkg; + publishInfo; const averagePublishDays = getAveragePublishDays(publishedTimes); diff --git a/src/lib/get-package-publish-date.ts b/src/lib/get-package-publish-date.ts index d37ac4e..ffc0597 100644 --- a/src/lib/get-package-publish-date.ts +++ b/src/lib/get-package-publish-date.ts @@ -1,15 +1,18 @@ +import type { PackagePublishInfo } from "../models/package-publish-info.ts"; import { getPackageVersionsDetail } from "./get-package-versions-detail.ts"; -export const getPackagePublishDate = async (packageName: string) => { - try { - const { packageVersion, publishedTimes } = - await getPackageVersionsDetail(packageName); - const packagePublishDate = new Date(publishedTimes[packageVersion]); - return { packagePublishDate, packageVersion, packageName, publishedTimes }; - } catch (error: unknown) { - const e = error as Error; - throw new Error( - `Failed to fetch package info for "${packageName}": ${e.message}`, - ); - } +export const getPackagePublishDate = async ( + packageName: string, +): Promise => { + const { packageVersion, publishedTimes } = + await getPackageVersionsDetail(packageName); + const packagePublishDate = new Date(publishedTimes[packageVersion]); + + return { + tag: "OK", + packagePublishDate, + packageVersion, + packageName, + publishedTimes, + }; }; diff --git a/src/lib/get-package-versions-detail.ts b/src/lib/get-package-versions-detail.ts index f7daa10..eeba61d 100644 --- a/src/lib/get-package-versions-detail.ts +++ b/src/lib/get-package-versions-detail.ts @@ -8,13 +8,28 @@ type PackageVersionsDetail = { const execPromise = util.promisify(exec); +export class PackageNotFoundError extends Error { + public packageName: string; + + constructor(packageName: string) { + super(`Package "${packageName}" not found on npm registry.`); + this.name = "PackageNotFoundError"; + this.packageName = packageName; + } +} + export const getPackageVersionsDetail = async (packageName: string) => { - const { stdout: packageInfoStdout } = await execPromise( - `npm view ${packageName} time version --json`, - ); - const packageInfo: PackageVersionsDetail = JSON.parse(packageInfoStdout); - return { - packageVersion: packageInfo.version, - publishedTimes: packageInfo.time, - }; + try { + const { stdout: packageInfoStdout } = await execPromise( + `npm view ${packageName} time version --json`, + ); + + const packageInfo: PackageVersionsDetail = JSON.parse(packageInfoStdout); + return { + packageVersion: packageInfo.version, + publishedTimes: packageInfo.time, + }; + } catch { + throw new PackageNotFoundError(packageName); + } }; diff --git a/src/lib/glob-regex.ts b/src/lib/glob-regex.ts deleted file mode 100644 index 6cdc014..0000000 --- a/src/lib/glob-regex.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @reference https://github.com/aleclarson/glob-regex/blob/master/index.js - */ - -const dotRE = /\./g; -const dotPattern = "\\."; - -const restRE = /\*\*$/g; -const restPattern = "(.+)"; - -const globRE = /(?:\*\*\/|\*\*|\*)/g; -const globPatterns = { - "*": "([^/]+)", // no backslashes - "**": "(.+/)?([^/]+)", // short for "**/*" - "**/": "(.+/)?", // one or more directories -}; - -function mapToPattern(str: string) { - return globPatterns[str as keyof typeof globPatterns]; -} - -function replace(glob: string) { - return glob - .replace(dotRE, dotPattern) - .replace(restRE, restPattern) - .replace(globRE, mapToPattern); -} - -export function globRegex(glob: string) { - return new RegExp(`^${replace(glob)}$`, "i"); -} - -globRegex.replace = replace; diff --git a/src/lib/process-packages.ts b/src/lib/process-packages.ts deleted file mode 100644 index ada4afe..0000000 --- a/src/lib/process-packages.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - PackagePublishInfo, - SortBy, - SortOrder, - Thresholds, -} from "../types.ts"; -import { formatPackageInfo } from "./format-package-info.ts"; -import { sortPackages } from "./sort-packages.ts"; - -interface ProcessPackageOptions { - results: PackagePublishInfo[]; - sortBy: SortBy; - sortOrder: SortOrder; - thresholds: Thresholds; -} - -function isDefined(value: T | undefined | null): value is T { - return value !== undefined && value !== null; -} - -export function processPackageData({ - results, - sortBy, - sortOrder, - thresholds, -}: ProcessPackageOptions) { - const validResults = results.filter(isDefined); - const formatted = validResults.map((pkg) => - formatPackageInfo({ pkg, thresholds }), - ); - return sortPackages(formatted, sortBy, sortOrder); -} diff --git a/src/lib/sort-packages.ts b/src/lib/sort-packages.ts index 223f106..5736d4d 100644 --- a/src/lib/sort-packages.ts +++ b/src/lib/sort-packages.ts @@ -1,4 +1,5 @@ -import type { PackageInfo, SortBy, SortOrder } from "../types.js"; +import type { PackageInfo } from "../models/package-info.ts"; +import type { SortBy, SortOrder } from "../types.js"; export function sortPackages( list: PackageInfo[], @@ -13,9 +14,26 @@ export function sortPackages( result = a.name.localeCompare(b.name); break; case "average": + if (a.averagePublishDays == null) { + result = -1; + break; + } + if (b.averagePublishDays == null) { + result = 1; + break; + } + result = a.averagePublishDays - b.averagePublishDays; break; default: + if (a.date == null) { + result = -1; + break; + } + if (b.date == null) { + result = 1; + break; + } result = a.date.getTime() - b.date.getTime(); } diff --git a/src/main.ts b/src/main.ts index 1133218..7970cff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,34 +3,80 @@ import "temporal-polyfill/global"; import { styleText } from "node:util"; import { getCliOptions } from "./lib/cli-options.ts"; -import { fetchPackageInfoList } from "./lib/fetch-packages.ts"; -import { processPackageData } from "./lib/process-packages.ts"; +import { + fetchPackageInfoList, + NoPackageFoundError, +} from "./lib/fetch-packages.ts"; +import { formatPackageInfo } from "./lib/format-package-info.ts"; +import { getPackagePublishDate } from "./lib/get-package-publish-date.ts"; import { progressBar } from "./lib/progress-bar.ts"; +import { sortPackages } from "./lib/sort-packages.ts"; +import { + errorPackagePublishInfo, + type PackagePublishInfo, +} from "./models/package-publish-info.ts"; import { mkRenderer } from "./renderer/mkRenderer.ts"; try { const { packages, sortBy, sortOrder, filter, thresholds, output } = getCliOptions(); + let packagesToCheck: string[] = []; - const { results, errors } = await fetchPackageInfoList(packages, filter); + try { + packagesToCheck = await fetchPackageInfoList(packages, filter); + } catch (e) { + if (e instanceof NoPackageFoundError) { + console.error( + styleText( + "redBright", + `[ERROR]: Cannot find ${styleText("bold", "package.json")}. Please ensure that the file exists in your current working directory. \n`, + ), + ); + process.exit(1); + } + if (e instanceof Error && e.message === "NO_PACKAGE_FOUND") { + console.error( + styleText( + "redBright", + "[ERROR]: No matching packages found for given patterns.", + ), + ); + process.exit(1); + } - const sortedInfo = processPackageData({ - results: results.filter((r) => r !== null), - sortBy, - sortOrder, - thresholds, - }); + throw e; + } + + progressBar.start(packagesToCheck.length, 0); + const promises = packagesToCheck.map>((pkgName) => + getPackagePublishDate(pkgName) + .catch(() => errorPackagePublishInfo(pkgName)) + .finally(() => progressBar.increment()), + ); + + const results = await Promise.all(promises); + progressBar.stop(); + + const formatted = results.map((publishInfo) => + formatPackageInfo({ publishInfo, thresholds }), + ); + + const sortedInfo = sortPackages(formatted, sortBy, sortOrder); const render = mkRenderer(output); console.log(render(sortedInfo)); - if (errors.length > 0) { - for (const { package: pkg, error } of errors) { - console.warn( - styleText(["black", "bgYellow"], " WARNING "), - styleText("yellow", `${pkg}: ${error.message}\n`), - ); - } + const failedPackages = results.filter((info) => info.tag === "Error"); + + if (failedPackages.length > 0) { + process.exitCode = 1; + console.error( + styleText( + "redBright", + "\n[ERROR]: Could not retrieve publish date for the following packages:\n" + + failedPackages.map((pkg) => `- ${pkg.packageName}`).join("\n"), + ), + ); } } catch (err) { progressBar.stop(); diff --git a/src/models/package-info.ts b/src/models/package-info.ts new file mode 100644 index 0000000..227ab55 --- /dev/null +++ b/src/models/package-info.ts @@ -0,0 +1,30 @@ +import type { TerminalColor } from "../types.ts"; + +export type PackageInfo = + | { + name: string; + version: string; + date: Date; + diffDays: number; + area: TerminalColor; + averagePublishDays: number; + } + | { + name: string; + version: undefined; + date: undefined; + diffDays: undefined; + area: "red"; + averagePublishDays: undefined; + }; + +export const errorPackageInfo = (name: string): PackageInfo => { + return { + name, + version: undefined, + date: undefined, + diffDays: undefined, + area: "red", + averagePublishDays: undefined, + }; +}; diff --git a/src/models/package-publish-info.ts b/src/models/package-publish-info.ts new file mode 100644 index 0000000..a2af7bc --- /dev/null +++ b/src/models/package-publish-info.ts @@ -0,0 +1,25 @@ +export type PackagePublishInfo = + | { + tag: "OK"; + packageName: string; + packageVersion: string; + packagePublishDate: Date; + publishedTimes: Record; + } + | { + tag: "Error"; + packageName: string; + packageVersion: "0.0.0"; + packagePublishDate: null; + publishedTimes: Record; + }; + +export const errorPackagePublishInfo = (name: string): PackagePublishInfo => { + return { + tag: "Error", + packageName: name, + packageVersion: "0.0.0", + packagePublishDate: null, + publishedTimes: {}, + }; +}; diff --git a/src/renderer/mkRenderer.ts b/src/renderer/mkRenderer.ts index ab90779..68315d7 100644 --- a/src/renderer/mkRenderer.ts +++ b/src/renderer/mkRenderer.ts @@ -1,4 +1,5 @@ -import type { Output, PackageInfo } from "../types.ts"; +import type { PackageInfo } from "../models/package-info.ts"; +import type { Output } from "../types.ts"; import { renderCsv } from "./render-csv.ts"; import { renderJSON } from "./render-json.ts"; import { renderTable } from "./render-table.ts"; diff --git a/src/renderer/render-csv.ts b/src/renderer/render-csv.ts index 2a9b780..cb1a23b 100644 --- a/src/renderer/render-csv.ts +++ b/src/renderer/render-csv.ts @@ -1,4 +1,4 @@ -import type { PackageInfo } from "../types.ts"; +import type { PackageInfo } from "../models/package-info.ts"; export function renderCsv(objects: PackageInfo[]): string { if (Object.keys(objects).length === 0) { diff --git a/src/renderer/render-json.ts b/src/renderer/render-json.ts index 2da9e03..6205603 100644 --- a/src/renderer/render-json.ts +++ b/src/renderer/render-json.ts @@ -1,4 +1,4 @@ -import type { PackageInfo } from "../types.ts"; +import type { PackageInfo } from "../models/package-info.ts"; export function renderJSON(objects: PackageInfo[]): string { return JSON.stringify(objects, null, 2); diff --git a/src/renderer/render-table.ts b/src/renderer/render-table.ts index b90c938..415ef26 100644 --- a/src/renderer/render-table.ts +++ b/src/renderer/render-table.ts @@ -1,7 +1,7 @@ import { styleText } from "node:util"; import Table from "cli-table3"; import { formatRelativeTime } from "../lib/format-relative-time.ts"; -import type { PackageInfo } from "../types.ts"; +import type { PackageInfo } from "../models/package-info.ts"; const TABLE_HEADER_TITLES = ["Name", "Version", "Date", "Average"] as const; @@ -13,16 +13,22 @@ export function renderTable(packagesInfo: PackageInfo[]) { for (const pkg of packagesInfo) { const { area, averagePublishDays, date, name, version } = pkg; - const formattedDate = formatRelativeTime(date); + const formattedDate = date ? formatRelativeTime(date) : "N/A"; + + const averagePublishDaysText = averagePublishDays + ? styleText( + "cyan", + averagePublishDays === 1 + ? "daily" + : `every ${averagePublishDays} days`, + ) + : styleText("red", "N/A"); table.push([ styleText(area, name), - styleText(area, version), + styleText(area, version ?? "N/A"), styleText(area, formattedDate), - styleText( - "cyan", - averagePublishDays === 1 ? "daily" : `every ${averagePublishDays} days`, - ), + averagePublishDaysText, ]); } diff --git a/src/types.ts b/src/types.ts index fc09bb5..d5079fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,22 +1,6 @@ export type Area = "safe" | "warn" | "error"; export type TerminalColor = "green" | "yellow" | "red"; -export type PackageInfo = { - name: string; - version: string; - date: Date; - diffDays: number; - area: TerminalColor; - averagePublishDays: number; -}; - -export type PackagePublishInfo = { - packageName: string; - packageVersion: string; - packagePublishDate: Date; - publishedTimes: Record; -}; - export type SortBy = "name" | "date" | "average"; export type SortOrder = "asc" | "desc"; export type Output = "table" | "json" | "csv";