diff --git a/.gitignore b/.gitignore index 44a3712..71f383d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ playwright-report # Agent skills from npm packages (managed by skills-npm) **/skills/npm-* + +# Copied at publish time from root /skills/ by scripts/copy-skills.mjs +packages/node-modules-inspector/skills/ diff --git a/README.md b/README.md index 1edab4f..98fdea4 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,44 @@ Then you can host the `.node-modules-inspector` folder with any static file serv You can see a build for all Anthony Fu's packages at [everything.antfu.dev](https://everything.antfu.dev). +## CLI Reports + +In addition to the web UI, the inspector exposes three machine-readable reports designed for shell pipelines and AI coding agents: + +```bash +npx node-modules-inspector report duplicates # packages installed in multiple versions +npx node-modules-inspector report sizes # packages sorted by install size +npx node-modules-inspector report maintainers # dep-upgrade opportunities + publint, grouped by consumer/author +``` + +Each command renders a pretty ANSI table by default. Add `--json` to emit JSON to stdout — progress logs go to stderr, so output is pipe-safe: + +```bash +npx node-modules-inspector report duplicates --json | jq '.[].name' +npx node-modules-inspector report sizes --json --limit 10 +npx node-modules-inspector report maintainers --json --sort migration --no-latest-only +``` + +Common options across all reports: `--root `, `--config `, `--depth `, `--limit `. Run `node-modules-inspector report --help` for the full per-report flag set. + +## MCP Server + +The same three reports are exposed as MCP tools for AI coding agents. Start the server over stdio: + +```bash +npx node-modules-inspector mcp +``` + +Tools exposed: +- `nmi:report-duplicates` +- `nmi:report-sizes` +- `nmi:report-maintainers` + +Input/output JSON Schemas are auto-derived from the underlying valibot definitions and surfaced via `tools/list`. Wire it into Claude Code (or any MCP-compatible client) by adding `node-modules-inspector mcp` as a stdio server in your MCP config. + +> [!NOTE] +> An [agent skill](./skills/node-modules-inspector/SKILL.md) lives at `skills/node-modules-inspector/SKILL.md` (repo root) and is copied into the published tarball at `/skills/` during `prepack`. If your project uses [`skills-npm`](https://github.com/antfu/skills-npm), the skill is automatically symlinked into your agent's skill directory after `pnpm install`. + ## Screenshots ![Image](https://github.com/user-attachments/assets/80ce6f9d-26fb-4fcf-8c51-e3d2b6f9f24c) diff --git a/packages/node-modules-inspector/package.json b/packages/node-modules-inspector/package.json index cbd9500..01e3bdd 100644 --- a/packages/node-modules-inspector/package.json +++ b/packages/node-modules-inspector/package.json @@ -22,7 +22,8 @@ "bin": "bin.mjs", "files": [ "bin.mjs", - "dist" + "dist", + "skills" ], "scripts": { "dev": "pnpm run -r stub && (cd src && ROLLDOWN_OPTIONS_VALIDATION=loose nuxi dev)", @@ -30,13 +31,14 @@ "build": "pnpm run wc:prepare && (cd src && ROLLDOWN_OPTIONS_VALIDATION=loose nuxi build) && unbuild", "build:debug": "ROLLDOWN_OPTIONS_VALIDATION=loose NUXT_DEBUG_BUILD=true pnpm run build", "start": "node ./bin.mjs", - "prepack": "pnpm build", - "dev:prepare": "(cd src && nuxi prepare) && pnpm wc:prepare", + "prepack": "pnpm build && node scripts/copy-skills.mjs", + "dev:prepare": "(cd src && nuxi prepare) && pnpm wc:prepare && node scripts/copy-skills.mjs", "wc:prepare": "rollup -c", "wc:build": "pnpm run wc:prepare && NMI_BACKEND=webcontainer ROLLDOWN_OPTIONS_VALIDATION=loose nuxi build src && unbuild", "wc:dev": "pnpm run -r stub && pnpm run wc:prepare && (cd src && NMI_BACKEND=webcontainer ROLLDOWN_OPTIONS_VALIDATION=loose nuxi dev)" }, "dependencies": { + "@modelcontextprotocol/sdk": "catalog:deps", "ansis": "catalog:deps", "cac": "catalog:deps", "devframe": "catalog:deps", @@ -54,11 +56,13 @@ "structured-clone-es": "catalog:deps", "tinyglobby": "catalog:deps", "unconfig": "catalog:deps", - "unstorage": "catalog:deps" + "unstorage": "catalog:deps", + "valibot": "catalog:deps" }, "devDependencies": { "@types/semver": "catalog:types", "@unocss/nuxt": "catalog:bundling", + "@valibot/to-json-schema": "catalog:testing", "@vueuse/nuxt": "catalog:bundling", "@webcontainer/api": "catalog:frontend", "@xterm/addon-fit": "catalog:frontend", diff --git a/packages/node-modules-inspector/scripts/copy-skills.mjs b/packages/node-modules-inspector/scripts/copy-skills.mjs new file mode 100644 index 0000000..4651878 --- /dev/null +++ b/packages/node-modules-inspector/scripts/copy-skills.mjs @@ -0,0 +1,25 @@ +// Copies the repo-root `skills/` folder into this package so it ships in the +// published tarball at `/skills/`. The root location is the source of +// truth (it's where humans and most agent-skills tooling look directly); this +// copy is what `skills-npm` and `npm install` consumers see. + +import { existsSync } from 'node:fs' +import { cp, rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const src = resolve(here, '../../../skills') +const dest = resolve(here, '../skills') + +if (!existsSync(src)) { + console.error(`[copy-skills] source not found: ${src}`) + process.exit(1) +} + +if (existsSync(dest)) + await rm(dest, { recursive: true }) +await cp(src, dest, { recursive: true }) + +console.log(`[copy-skills] ${src} → ${dest}`) diff --git a/packages/node-modules-inspector/src/app/state/maintainer-actions.ts b/packages/node-modules-inspector/src/app/state/maintainer-actions.ts index 6645c55..2a3640a 100644 --- a/packages/node-modules-inspector/src/app/state/maintainer-actions.ts +++ b/packages/node-modules-inspector/src/app/state/maintainer-actions.ts @@ -1,231 +1,41 @@ -import type { PackageNode, PublintMessage } from 'node-modules-tools' -import type { ParsedAuthor } from 'node-modules-tools/utils' -import semver from 'semver' +import type { PackageNode } from 'node-modules-tools' +import type { + MaintainerActionGroup, + MaintainerActionItem, + MaintainerActionSortMode, +} from '../../shared/reports/maintainers' import { computed } from 'vue' -import { compareSemver } from '../utils/semver' +import { + collectMaintainerActionAuthors, + computeMaintainerActions, + groupMaintainerActions, +} from '../../shared/reports/maintainers' import { rawPayload, rawPublintMessages } from './data' import { getNpmMetaLatest, getPublishTime, payloads } from './payload' import { query } from './query' -export function authorKey(author: ParsedAuthor): string { - return author.type === 'github' ? `@${author.github}` : author.name -} - -interface BaseAction { - consumer: PackageNode - depth: number - key: string -} - -export interface DepUpgradeAction extends BaseAction { - kind: 'dep-upgrade' - depName: string - depType: 'peer' | 'prod' - /** The effective semver range (after resolving any catalog reference). */ - declaredRange: string - /** The raw range as written in package.json, when it differs (e.g. `catalog:deps`). */ - rawRange?: string - /** Catalog name when the raw range was a catalog reference. */ - catalogName?: string - installedHighestVersion: string - installedHighest: PackageNode - installedVersions: string[] - migratedCount: number - totalCount: number - migrationRatio: number -} - -export interface PublintAction extends BaseAction { - kind: 'publint' - messages: PublintMessage[] - counts: { error: number, warning: number, suggestion: number } -} - -export type MaintainerActionItem = DepUpgradeAction | PublintAction - -interface DepStats { - highestVersion: string - highestPkg: PackageNode - versions: string[] - migrated: number - behind: number -} - -const NON_SEMVER_PREFIXES = ['workspace:', 'link:', 'file:', 'npm:', 'git+', 'git:', 'http:', 'https:', 'github:'] - -function resolveCatalogRange( - range: string, - depName: string, - catalogs: Record> | undefined, -): string | undefined { - if (!range.startsWith('catalog:')) - return range - if (!catalogs) - return undefined - const name = range.slice('catalog:'.length) || 'default' - return catalogs[name]?.[depName] -} - -function isPlainSemverRange(range: string | undefined): range is string { - if (!range || range === '*' || range === 'latest' || range === 'x') - return false - return !NON_SEMVER_PREFIXES.some(p => range.startsWith(p)) -} - -function safeSatisfies(version: string, range: string) { - try { - return semver.satisfies(version, range) - } - catch { - return null - } -} - -function safeGtr(version: string, range: string) { - try { - return semver.gtr(version, range) - } - catch { - return null - } -} - -function isStable(version: string) { - return semver.prerelease(version) === null -} - -function getPublintMessagesFor(pkg: PackageNode): PublintMessage[] | null { - if (pkg.resolved.publint) - return pkg.resolved.publint - const fetched = rawPublintMessages.value.get(pkg.spec) - return fetched ? [...fetched] : null -} +export { + authorKey, +} from '../../shared/reports/maintainers' +export type { + DepUpgradeAction, + MaintainerActionAuthorEntry, + MaintainerActionGroup, + MaintainerActionItem, + MaintainerActionSortMode, + PublintAction, +} from '../../shared/reports/maintainers' export const maintainerActions = computed(() => { - const packages = payloads.filtered.packages - const versions = payloads.filtered.versions - const catalogs = rawPayload.value?.catalogs - - const stats = new Map() - - function getStats(depName: string): DepStats | null { - if (stats.has(depName)) - return stats.get(depName)! - const installed = versions.get(depName) - if (!installed?.length) { - stats.set(depName, null) - return null - } - const sortedAll = installed.slice().sort((a, b) => compareSemver(a.version, b.version)) - const stable = sortedAll.filter(p => isStable(p.version)) - if (!stable.length) { - stats.set(depName, null) - return null - } - const highestPkg = stable.at(-1)! - const entry: DepStats = { - highestVersion: highestPkg.version, - highestPkg, - versions: sortedAll.map(p => p.version), - migrated: 0, - behind: 0, - } - stats.set(depName, entry) - return entry - } - - for (const consumer of packages) { - const pj = consumer.resolved.packageJson - const blocks = [pj.peerDependencies, pj.dependencies] - for (const block of blocks) { - if (!block) - continue - for (const [depName, rawRange] of Object.entries(block)) { - const range = resolveCatalogRange(rawRange, depName, catalogs) - if (!isPlainSemverRange(range)) - continue - const entry = getStats(depName) - if (!entry) - continue - if (safeSatisfies(entry.highestVersion, range)) { - entry.migrated++ - } - else if (safeGtr(entry.highestVersion, range)) { - entry.behind++ - } - // else: declared range is ahead of highest stable — ignore (not part of this cohort) - } - } - } - - const items: MaintainerActionItem[] = [] - - for (const consumer of packages) { - const pj = consumer.resolved.packageJson - const blocks: Array<[Record | undefined, 'peer' | 'prod']> = [ - [pj.peerDependencies, 'peer'], - [pj.dependencies, 'prod'], - ] - for (const [block, depType] of blocks) { - if (!block) - continue - for (const [depName, rawRange] of Object.entries(block)) { - const declaredRange = resolveCatalogRange(rawRange, depName, catalogs) - if (!isPlainSemverRange(declaredRange)) - continue - const entry = stats.get(depName) - if (!entry) - continue - if (safeGtr(entry.highestVersion, declaredRange) !== true) - continue - // Skip when consumer and dep share the same repository (monorepo siblings). - const consumerRepo = consumer.resolved.repository?.url - const depRepo = entry.highestPkg.resolved.repository?.url - if (consumerRepo && depRepo && consumerRepo === depRepo) - continue - const total = entry.migrated + entry.behind - const catalogName = rawRange.startsWith('catalog:') - ? (rawRange.slice('catalog:'.length) || 'default') - : undefined - items.push({ - kind: 'dep-upgrade', - consumer, - depName, - depType, - declaredRange, - rawRange: rawRange === declaredRange ? undefined : rawRange, - catalogName, - installedHighestVersion: entry.highestVersion, - installedHighest: entry.highestPkg, - installedVersions: entry.versions, - migratedCount: entry.migrated, - totalCount: total, - migrationRatio: total ? entry.migrated / total : 0, - depth: consumer.depth, - key: `${consumer.spec}::${depType}::${depName}`, - }) - } - } - } - - for (const consumer of packages) { - const messages = getPublintMessagesFor(consumer) - if (!messages?.length) - continue - const counts = { error: 0, warning: 0, suggestion: 0 } - for (const m of messages) - counts[m.type]++ - items.push({ - kind: 'publint', - consumer, - depth: consumer.depth, - key: `${consumer.spec}::publint`, - messages, - counts, - }) - } - - return items + return computeMaintainerActions({ + packages: payloads.filtered.packages, + versions: payloads.filtered.versions, + catalogs: rawPayload.value?.catalogs, + publintFallback: (pkg) => { + const fetched = rawPublintMessages.value.get(pkg.spec) + return fetched ? [...fetched] : null + }, + }) }) export function findMaintainerActionByKey(key: string | undefined): MaintainerActionItem | undefined { @@ -234,17 +44,6 @@ export function findMaintainerActionByKey(key: string | undefined): MaintainerAc return maintainerActions.value.find(i => i.key === key) } -export interface MaintainerActionGroup { - consumer: PackageNode - depth: number - authors: ParsedAuthor[] - items: MaintainerActionItem[] - maxMigrationRatio: number - latestReleasedAt: number -} - -export type MaintainerActionSortMode = 'depth' | 'migration' | 'latest' - export const maintainerActionSortMode = computed({ get: () => { const v = query.actionSort @@ -255,84 +54,15 @@ export const maintainerActionSortMode = computed({ }, }) -function getConsumerAuthors(pkg: PackageNode): ParsedAuthor[] { - const list = pkg.resolved.authors - if (!list?.length) - return [] - return list.filter(a => a.type === 'github' || !!a.name?.trim()) -} - -function actionSortKey(a: MaintainerActionItem, b: MaintainerActionItem): number { - if (a.kind !== b.kind) - return a.kind === 'publint' ? -1 : 1 - if (a.kind === 'dep-upgrade' && b.kind === 'dep-upgrade') { - return (b.migrationRatio - a.migrationRatio) - || a.depName.localeCompare(b.depName) - } - return 0 -} - export const maintainerActionGroups = computed(() => { - const byConsumer = new Map() - for (const item of maintainerActions.value) { - let group = byConsumer.get(item.consumer.spec) - if (!group) { - group = { - consumer: item.consumer, - depth: item.consumer.depth, - authors: getConsumerAuthors(item.consumer), - items: [], - maxMigrationRatio: 0, - latestReleasedAt: getPublishTime(item.consumer)?.getTime() ?? 0, - } - byConsumer.set(item.consumer.spec, group) - } - group.items.push(item) - if (item.kind === 'dep-upgrade' && item.migrationRatio > group.maxMigrationRatio) - group.maxMigrationRatio = item.migrationRatio - } - - for (const group of byConsumer.values()) - group.items.sort(actionSortKey) - - const byName = new Map() - for (const g of byConsumer.values()) { - const cur = byName.get(g.consumer.name) - if (!cur || compareSemver(cur.consumer.version, g.consumer.version) < 0) - byName.set(g.consumer.name, g) - } - const groups = Array.from(byName.values()) - const mode = maintainerActionSortMode.value - const nameTie = (a: MaintainerActionGroup, b: MaintainerActionGroup) => - a.consumer.name.localeCompare(b.consumer.name) - const cmp: (a: MaintainerActionGroup, b: MaintainerActionGroup) => number - = mode === 'migration' - ? (a, b) => (b.maxMigrationRatio - a.maxMigrationRatio) || (a.depth - b.depth) || nameTie(a, b) - : mode === 'latest' - ? (a, b) => (b.latestReleasedAt - a.latestReleasedAt) || (a.depth - b.depth) || nameTie(a, b) - : (a, b) => (a.depth - b.depth) || (b.maxMigrationRatio - a.maxMigrationRatio) || nameTie(a, b) - return groups.sort(cmp) + return groupMaintainerActions(maintainerActions.value, { + sort: maintainerActionSortMode.value, + publishTimeOf: pkg => getPublishTime(pkg) ?? undefined, + }) }) -export interface MaintainerActionAuthorEntry { - author: ParsedAuthor - count: number -} - -export const maintainerActionAuthors = computed(() => { - const map = new Map() - for (const group of maintainerActionGroups.value) { - for (const author of group.authors) { - const key = authorKey(author) - const entry = map.get(key) - if (entry) - entry.count++ - else - map.set(key, { author, count: 1 }) - } - } - return Array.from(map.values()) - .sort((a, b) => (b.count - a.count) || authorKey(a.author).localeCompare(authorKey(b.author))) +export const maintainerActionAuthors = computed(() => { + return collectMaintainerActionAuthors(maintainerActionGroups.value) }) export const maintainerFilter = computed({ @@ -364,38 +94,14 @@ export const maintainerActionLatestOnly = computed({ }) export const filteredMaintainerActionGroups = computed(() => { - let groups = maintainerActionGroups.value - - const selected = maintainerFilter.value - if (selected.length) { - const set = new Set(selected) - groups = groups.filter(g => g.authors.some(a => set.has(authorKey(a)))) - } - - if (!maintainerActionIncludePublint.value) { - groups = groups - .map((g) => { - const items = g.items.filter(i => i.kind !== 'publint') - return items.length === g.items.length ? g : { ...g, items } - }) - .filter(g => g.items.length > 0) - } - - if (maintainerActionLatestOnly.value) { - groups = groups.filter((g) => { - const latest = getNpmMetaLatest(g.consumer) - if (!latest?.version) - return true - try { - return semver.major(g.consumer.version) === semver.major(latest.version) - } - catch { - return true - } - }) - } - - return groups + return groupMaintainerActions(maintainerActions.value, { + sort: maintainerActionSortMode.value, + authorFilter: maintainerFilter.value, + includePublint: maintainerActionIncludePublint.value, + latestOnly: maintainerActionLatestOnly.value, + latestVersionOf: pkg => getNpmMetaLatest(pkg)?.version, + publishTimeOf: pkg => getPublishTime(pkg) ?? undefined, + }) }) export function getMaintainerActionsFor(pkg: PackageNode): MaintainerActionItem[] { diff --git a/packages/node-modules-inspector/src/app/utils/semver.ts b/packages/node-modules-inspector/src/app/utils/semver.ts index cb03c7b..3f8c39b 100644 --- a/packages/node-modules-inspector/src/app/utils/semver.ts +++ b/packages/node-modules-inspector/src/app/utils/semver.ts @@ -1,4 +1,7 @@ import semver from 'semver' +import { compareSemver } from '../../shared/semver' + +export { compareSemver } export interface ParsedSemver { valid: boolean @@ -55,18 +58,6 @@ export function parseSemverRange(range: string) { return result } -export function compareSemver(a: string, b: string) { - if (a === b) - return 0 - try { - return semver.compare(a, b) - } - catch (e) { - console.error('Failed to compare semver ', e) - return 0 - } -} - export function compareSemverRange(a = '*', b = '*') { if (a === b) return 0 diff --git a/packages/node-modules-inspector/src/node/cli-report/format-duplicates.test.ts b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.test.ts new file mode 100644 index 0000000..db1de5d --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.test.ts @@ -0,0 +1,49 @@ +import type { DuplicatesEntry } from '../../shared/reports/dto' +import { describe, expect, it } from 'vitest' +import { formatDuplicates } from './format-duplicates' +import { stripAnsi } from './format-util' + +describe('formatDuplicates', () => { + it('renders empty state', () => { + expect(stripAnsi(formatDuplicates([]))).toMatchInlineSnapshot(` + "No duplicated packages found. + " + `) + }) + + it('renders a populated table', () => { + const entries: DuplicatesEntry[] = [ + { + name: '@typescript-eslint/scope-manager', + versions: ['8.56.1', '8.59.1', '8.59.2', '8.59.4'], + specs: [ + '@typescript-eslint/scope-manager@8.56.1', + '@typescript-eslint/scope-manager@8.59.1', + '@typescript-eslint/scope-manager@8.59.2', + '@typescript-eslint/scope-manager@8.59.4', + ], + }, + { + name: 'esbuild', + versions: ['0.27.7', '0.28.0'], + specs: ['esbuild@0.27.7', 'esbuild@0.28.0'], + }, + ] + expect(stripAnsi(formatDuplicates(entries))).toMatchInlineSnapshot(` + "Package # Versions + ──────────────────────────────── ─ ────────────────────────────────── + @typescript-eslint/scope-manager 4 v8.56.1, v8.59.1, v8.59.2, v8.59.4 + esbuild 2 v0.27.7, v0.28.0 + + 2 packages with multiple versions + " + `) + }) + + it('renders singular summary for one entry', () => { + const entries: DuplicatesEntry[] = [ + { name: 'foo', versions: ['1.0.0', '2.0.0'], specs: ['foo@1.0.0', 'foo@2.0.0'] }, + ] + expect(stripAnsi(formatDuplicates(entries))).toContain('1 package with multiple versions') + }) +}) diff --git a/packages/node-modules-inspector/src/node/cli-report/format-duplicates.ts b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.ts new file mode 100644 index 0000000..2138b19 --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/format-duplicates.ts @@ -0,0 +1,23 @@ +import type { DuplicatesEntry } from '../../shared/reports/dto' +import c from 'ansis' +import { renderTable } from './format-util' + +export function formatDuplicates(entries: DuplicatesEntry[]): string { + if (!entries.length) + return c.green('No duplicated packages found.\n') + + const rows = entries.map(e => [ + e.name, + String(e.versions.length), + e.versions.map(v => `v${v}`).join(', '), + ]) + + const table = renderTable([ + { header: 'Package' }, + { header: '#', align: 'right' }, + { header: 'Versions' }, + ], rows) + + const summary = c.dim(`\n${entries.length} package${entries.length === 1 ? '' : 's'} with multiple versions\n`) + return `${table}\n${summary}` +} diff --git a/packages/node-modules-inspector/src/node/cli-report/format-maintainers.test.ts b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.test.ts new file mode 100644 index 0000000..634a00b --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.test.ts @@ -0,0 +1,78 @@ +import type { MaintainersGroupDto } from '../../shared/reports/dto' +import { describe, expect, it } from 'vitest' +import { formatMaintainers } from './format-maintainers' +import { stripAnsi } from './format-util' + +describe('formatMaintainers', () => { + it('renders empty state', () => { + expect(stripAnsi(formatMaintainers([]))).toMatchInlineSnapshot(` + "No maintainer actions found. + " + `) + }) + + it('renders dep-upgrade + publint groups', () => { + const groups: MaintainersGroupDto[] = [ + { + consumer: { spec: 'rollup-plugin-esbuild@6.2.1', name: 'rollup-plugin-esbuild', version: '6.2.1', depth: 1 }, + authors: [{ type: 'github', github: 'egoist', avatar: 'https://avatars.githubusercontent.com/egoist' }], + items: [ + { + kind: 'publint', + messages: [], + counts: { error: 0, warning: 1, suggestion: 2 }, + }, + { + kind: 'dep-upgrade', + depName: 'unplugin-utils', + depType: 'prod', + declaredRange: '^0.2.4', + installedHighestVersion: '0.3.1', + installedHighestSpec: 'unplugin-utils@0.3.1', + installedVersions: ['0.2.4', '0.3.1'], + migratedCount: 10, + totalCount: 11, + migrationRatio: 10 / 11, + }, + ], + maxMigrationRatio: 10 / 11, + latestReleasedAt: 0, + }, + { + consumer: { spec: 'eslint@10.4.0', name: 'eslint', version: '10.4.0', depth: 1 }, + authors: [{ type: 'github', github: 'eslint', avatar: 'https://avatars.githubusercontent.com/eslint' }], + items: [ + { + kind: 'dep-upgrade', + depName: 'ajv', + depType: 'prod', + declaredRange: '^6.14.0', + catalogName: 'deps', + rawRange: 'catalog:deps', + installedHighestVersion: '8.17.1', + installedHighestSpec: 'ajv@8.17.1', + installedVersions: ['6.14.0', '8.17.1'], + migratedCount: 3, + totalCount: 5, + migrationRatio: 3 / 5, + }, + ], + maxMigrationRatio: 3 / 5, + latestReleasedAt: 0, + }, + ] + expect(stripAnsi(formatMaintainers(groups))).toMatchInlineSnapshot(` + "rollup-plugin-esbuild v6.2.1 · depth 1 + by @egoist + publint 1 warning, 2 suggestions + prod unplugin-utils ^0.2.4 → v0.3.1 migration 10/11 (91%) + + eslint v10.4.0 · depth 1 + by @eslint + prod ajv ^6.14.0 → v8.17.1 catalog:deps migration 3/5 (60%) + + 2 consumers with actions + " + `) + }) +}) diff --git a/packages/node-modules-inspector/src/node/cli-report/format-maintainers.ts b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.ts new file mode 100644 index 0000000..de4404a --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/format-maintainers.ts @@ -0,0 +1,49 @@ +import type { MaintainersGroupDto } from '../../shared/reports/dto' +import c from 'ansis' + +function authorLabel(author: MaintainersGroupDto['authors'][number]): string { + if (author.type === 'github') + return `@${author.github}` + return author.name +} + +function formatGroup(group: MaintainersGroupDto): string { + const header = `${c.bold(group.consumer.name)} ${c.dim(`v${group.consumer.version} · depth ${group.consumer.depth}`)}` + const authors = group.authors.length + ? c.dim(` by ${group.authors.map(authorLabel).join(', ')}`) + : '' + + const lines: string[] = [] + for (const item of group.items) { + if (item.kind === 'publint') { + const counts = item.counts + const parts: string[] = [] + if (counts.error) + parts.push(c.red(`${counts.error} error${counts.error === 1 ? '' : 's'}`)) + if (counts.warning) + parts.push(c.yellow(`${counts.warning} warning${counts.warning === 1 ? '' : 's'}`)) + if (counts.suggestion) + parts.push(c.blue(`${counts.suggestion} suggestion${counts.suggestion === 1 ? '' : 's'}`)) + lines.push(` ${c.magenta('publint')} ${parts.join(', ')}`) + } + else { + const ratio = item.totalCount + ? `${item.migratedCount}/${item.totalCount} (${Math.round(item.migrationRatio * 100)}%)` + : '—' + const type = item.depType === 'peer' ? c.cyan('peer') : c.green('prod') + const catalog = item.catalogName ? c.dim(` catalog:${item.catalogName}`) : '' + lines.push(` ${type} ${c.bold(item.depName)} ${c.dim(item.declaredRange)} → ${c.green(`v${item.installedHighestVersion}`)}${catalog} ${c.dim(`migration ${ratio}`)}`) + } + } + + return [header, authors, ...lines].filter(Boolean).join('\n') +} + +export function formatMaintainers(groups: MaintainersGroupDto[]): string { + if (!groups.length) + return c.green('No maintainer actions found.\n') + + const blocks = groups.map(formatGroup).join('\n\n') + const summary = c.dim(`\n${groups.length} consumer${groups.length === 1 ? '' : 's'} with actions\n`) + return `${blocks}\n${summary}` +} diff --git a/packages/node-modules-inspector/src/node/cli-report/format-sizes.test.ts b/packages/node-modules-inspector/src/node/cli-report/format-sizes.test.ts new file mode 100644 index 0000000..50c84f6 --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/format-sizes.test.ts @@ -0,0 +1,62 @@ +import type { SizesEntry } from '../../shared/reports/dto' +import { describe, expect, it } from 'vitest' +import { formatSizes } from './format-sizes' +import { formatBytes, stripAnsi } from './format-util' + +describe('formatBytes', () => { + it('formats common ranges', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1024)).toBe('1.00 KB') + expect(formatBytes(1536)).toBe('1.50 KB') + expect(formatBytes(1024 * 1024)).toBe('1.00 MB') + expect(formatBytes(23_237_120)).toBe('22.2 MB') + expect(formatBytes(1024 * 1024 * 1024)).toBe('1.00 GB') + }) +}) + +describe('formatSizes', () => { + it('renders empty state', () => { + expect(stripAnsi(formatSizes([]))).toMatchInlineSnapshot(` + "No install-size data available. + " + `) + }) + + it('renders a populated table', () => { + const entries: SizesEntry[] = [ + { + spec: 'typescript@6.0.3', + name: 'typescript', + version: '6.0.3', + workspace: false, + bytes: 24_346_827, + categories: { + js: { bytes: 15_344_521, count: 200 }, + dts: { bytes: 7_002_306, count: 150 }, + other: { bytes: 2_000_000, count: 50 }, + }, + }, + { + spec: 'esbuild@0.28.0', + name: 'esbuild', + version: '0.28.0', + workspace: false, + bytes: 10_678_299, + categories: { + bin: { bytes: 10_500_000, count: 1 }, + js: { bytes: 178_299, count: 10 }, + }, + }, + ] + expect(stripAnsi(formatSizes(entries))).toMatchInlineSnapshot(` + "Package Size Largest file type + ──────────────── ─────── ───────────────── + typescript@6.0.3 23.2 MB js (63%) + esbuild@0.28.0 10.2 MB bin (98%) + + 2 packages · total 33.4 MB + " + `) + }) +}) diff --git a/packages/node-modules-inspector/src/node/cli-report/format-sizes.ts b/packages/node-modules-inspector/src/node/cli-report/format-sizes.ts new file mode 100644 index 0000000..36c3adf --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/format-sizes.ts @@ -0,0 +1,42 @@ +import type { SizesEntry } from '../../shared/reports/dto' +import c from 'ansis' +import { formatBytes, renderTable } from './format-util' + +function topCategory(entry: SizesEntry): string { + const categories = entry.categories + let bestKey: string | undefined + let bestBytes = 0 + for (const [key, value] of Object.entries(categories)) { + if (!value) + continue + if (value.bytes > bestBytes) { + bestBytes = value.bytes + bestKey = key + } + } + if (!bestKey) + return '' + const pct = entry.bytes ? Math.round((bestBytes / entry.bytes) * 100) : 0 + return `${bestKey} ${c.dim(`(${pct}%)`)}` +} + +export function formatSizes(entries: SizesEntry[]): string { + if (!entries.length) + return c.dim('No install-size data available.\n') + + const rows = entries.map(e => [ + e.spec, + formatBytes(e.bytes), + topCategory(e), + ]) + + const table = renderTable([ + { header: 'Package' }, + { header: 'Size', align: 'right' }, + { header: 'Largest file type' }, + ], rows) + + const total = entries.reduce((sum, e) => sum + e.bytes, 0) + const summary = c.dim(`\n${entries.length} package${entries.length === 1 ? '' : 's'} · total ${formatBytes(total)}\n`) + return `${table}\n${summary}` +} diff --git a/packages/node-modules-inspector/src/node/cli-report/format-util.ts b/packages/node-modules-inspector/src/node/cli-report/format-util.ts new file mode 100644 index 0000000..dc7ed53 --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/format-util.ts @@ -0,0 +1,75 @@ +import c from 'ansis' + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] + +export function formatBytes(bytes: number): string { + if (bytes === 0) + return '0 B' + let value = bytes + let unit = 0 + while (value >= 1024 && unit < UNITS.length - 1) { + value /= 1024 + unit++ + } + const precision = value < 10 ? 2 : value < 100 ? 1 : 0 + return `${value.toFixed(precision)} ${UNITS[unit]}` +} + +const ESC = String.fromCharCode(0x1B) +const ANSI_RE = new RegExp(`${ESC.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[[0-9;]*m`, 'g') + +export function stripAnsi(s: string): string { + return s.replace(ANSI_RE, '') +} + +export function visualWidth(s: string): number { + return stripAnsi(s).length +} + +export function padRight(s: string, width: number): string { + const diff = width - visualWidth(s) + return diff > 0 ? s + ' '.repeat(diff) : s +} + +export function padLeft(s: string, width: number): string { + const diff = width - visualWidth(s) + return diff > 0 ? ' '.repeat(diff) + s : s +} + +export type Alignment = 'left' | 'right' + +export interface Column { + header: string + align?: Alignment +} + +export function renderTable(columns: Column[], rows: string[][]): string { + const widths: number[] = columns.map((col, i) => { + let max = visualWidth(col.header) + for (const row of rows) + max = Math.max(max, visualWidth(row[i] ?? '')) + return max + }) + + const lastIndex = columns.length - 1 + const pad = (s: string, i: number): string => { + const align = columns[i]?.align + const width = widths[i] ?? 0 + if (align === 'right') + return padLeft(s, width) + // Skip right-padding the last left-aligned column to avoid trailing whitespace. + if (i === lastIndex) + return s + return padRight(s, width) + } + + const headerLine = columns.map((col, i) => c.bold(pad(col.header, i))).join(' ') + const rule = widths.map(w => c.dim('─'.repeat(w))).join(' ') + const rowLines = rows.map(r => r.map((cell, i) => pad(cell ?? '', i)).join(' ')) + + return [headerLine, rule, ...rowLines].join('\n') +} + +export function section(title: string, body: string): string { + return `${c.bold.cyan(title)}\n${body}\n` +} diff --git a/packages/node-modules-inspector/src/node/cli-report/run-report.ts b/packages/node-modules-inspector/src/node/cli-report/run-report.ts new file mode 100644 index 0000000..5254b6b --- /dev/null +++ b/packages/node-modules-inspector/src/node/cli-report/run-report.ts @@ -0,0 +1,121 @@ +import type { PackageNode } from 'node-modules-tools' +import process from 'node:process' +import c from 'ansis' +import { toMaintainersGroupDto } from '../../shared/reports/dto' +import { computeDuplicates } from '../../shared/reports/duplicates' +import { + computeMaintainerActions, + groupMaintainerActions, +} from '../../shared/reports/maintainers' +import { computeInstallSizes } from '../../shared/reports/sizes' +import { createInspectorRpcHandlers } from '../rpc/handlers' +import { storageNpmMeta, storageNpmMetaLatest, storagePublint } from '../storage' +import { formatDuplicates } from './format-duplicates' +import { formatMaintainers } from './format-maintainers' +import { formatSizes } from './format-sizes' + +export type ReportType = 'maintainers' | 'duplicates' | 'sizes' + +export interface RunReportOptions { + type: ReportType + root: string + config?: string + depth: number + json: boolean + /** Common to all */ + limit?: number + /** Maintainers-only */ + sort?: 'depth' | 'migration' | 'latest' + authors?: string[] + includePublint?: boolean + latestOnly?: boolean + /** Duplicates-only */ + minVersions?: number + /** Sizes-only */ + includeWorkspace?: boolean +} + +function buildVersionsMap(packages: Iterable): Map { + const map = new Map() + for (const pkg of packages) { + let bucket = map.get(pkg.name) + if (!bucket) { + bucket = [] + map.set(pkg.name, bucket) + } + bucket.push(pkg) + } + return map +} + +export async function runReport(options: RunReportOptions): Promise { + const handlers = createInspectorRpcHandlers({ + cwd: options.root, + depth: options.depth, + configFile: options.config, + mode: 'build', + quiet: true, + storageNpmMeta, + storageNpmMetaLatest, + storagePublint, + }) + + let payload + try { + payload = await handlers.getPayload() + } + catch (error: any) { + console.error(c.red`✖ ${error.message || error}`) + process.exit(1) + } + + if (options.type === 'duplicates') { + const data = computeDuplicates(payload.packages.values(), { + minVersions: options.minVersions, + limit: options.limit, + }) + write(options.json ? toJson(data) : formatDuplicates(data)) + return + } + + if (options.type === 'sizes') { + const data = computeInstallSizes(payload.packages.values(), { + limit: options.limit, + includeWorkspace: options.includeWorkspace, + }) + write(options.json ? toJson(data) : formatSizes(data)) + return + } + + if (options.type === 'maintainers') { + const packages = Array.from(payload.packages.values()) + const versions = buildVersionsMap(packages) + const items = computeMaintainerActions({ + packages, + versions, + catalogs: payload.catalogs, + }) + const groups = groupMaintainerActions(items, { + sort: options.sort, + authorFilter: options.authors?.length ? options.authors : undefined, + includePublint: options.includePublint, + latestOnly: options.latestOnly, + latestVersionOf: pkg => pkg.resolved.npmMetaLatest?.version, + }) + const limited = options.limit && options.limit > 0 ? groups.slice(0, options.limit) : groups + const dto = limited.map(toMaintainersGroupDto) + write(options.json ? toJson(dto) : formatMaintainers(dto)) + return + } + + console.error(c.red`✖ Unknown report type "${options.type}". Expected one of: maintainers, duplicates, sizes.`) + process.exit(1) +} + +function toJson(data: unknown): string { + return `${JSON.stringify(data, null, 2)}\n` +} + +function write(text: string): void { + process.stdout.write(text.endsWith('\n') ? text : `${text}\n`) +} diff --git a/packages/node-modules-inspector/src/node/cli.ts b/packages/node-modules-inspector/src/node/cli.ts index efc6674..f8cfbb0 100644 --- a/packages/node-modules-inspector/src/node/cli.ts +++ b/packages/node-modules-inspector/src/node/cli.ts @@ -172,5 +172,68 @@ cli } }) +cli + .command('report ', 'Run an inspector report (maintainers | duplicates | sizes)') + .option('--root ', 'Root directory', { default: process.cwd() }) + .option('--config ', 'Config file') + .option('--depth ', 'Max depth to list dependencies', { default: 8 }) + .option('--json', 'Emit JSON to stdout (machine-readable)') + .option('--limit ', 'Cap the number of returned entries') + .option('--sort ', '[maintainers] Sort mode: depth | migration | latest', { default: 'depth' }) + .option('--author ', '[maintainers] Filter by author handle (repeatable)') + .option('--no-publint', '[maintainers] Exclude publint actions') + .option('--no-latest-only', '[maintainers] Include consumers that are not on the latest major') + .option('--min-versions ', '[duplicates] Only include packages installed at this many versions or more', { default: 2 }) + .option('--include-workspace', '[sizes] Include workspace packages') + .action(async (type: string, options: Record) => { + const valid = ['maintainers', 'duplicates', 'sizes'] + if (!valid.includes(type)) { + console.error(c.red`✖ Unknown report type "${type}". Expected one of: ${valid.join(', ')}.`) + process.exit(1) + } + const { runReport } = await import('./cli-report/run-report') + const authors = Array.isArray(options.author) + ? options.author + : options.author + ? [options.author] + : [] + await runReport({ + type: type as 'maintainers' | 'duplicates' | 'sizes', + root: options.root, + config: options.config, + depth: Number(options.depth), + json: !!options.json, + limit: options.limit != null ? Number(options.limit) : undefined, + sort: options.sort, + authors, + includePublint: options.publint !== false, + latestOnly: options.latestOnly !== false, + minVersions: options.minVersions != null ? Number(options.minVersions) : undefined, + includeWorkspace: !!options.includeWorkspace, + }) + }) + +cli + .command('mcp', 'Start an MCP stdio server exposing report tools to coding agents (experimental)') + .option('--root ', 'Root directory', { default: process.cwd() }) + .option('--config ', 'Config file') + .option('--depth ', 'Max depth to list dependencies', { default: 8 }) + .action(async (options) => { + if (options.config) + process.env.NMI_CLI_CONFIG = options.config + process.env.NMI_CLI_DEPTH = String(Number(options.depth)) + process.env.NMI_CLI_QUIET = '1' + if (options.root && options.root !== process.cwd()) + process.chdir(options.root) + + const { createMcpServer } = await import('devframe/adapters/mcp') + await createMcpServer(devframe, { + transport: 'stdio', + onReady: ({ transport }) => { + console.error(c.green`${MARK_CHECK} ${devframe.id} MCP server ready (${transport})`) + }, + }) + }) + cli.help() cli.parse() diff --git a/packages/node-modules-inspector/src/node/devframe.ts b/packages/node-modules-inspector/src/node/devframe.ts index 08aee12..ae00db9 100644 --- a/packages/node-modules-inspector/src/node/devframe.ts +++ b/packages/node-modules-inspector/src/node/devframe.ts @@ -1,3 +1,4 @@ +import process from 'node:process' import { defineDevframe } from 'devframe/types' import { distDir } from '../dirs' import { getPackagesNpmMetaRpc } from './rpc/get-packages-npm-meta' @@ -7,12 +8,16 @@ import { getPublintRpc } from './rpc/get-publint' import { createInspectorRpcHandlers } from './rpc/handlers' import { openInEditorRpc } from './rpc/open-in-editor' import { openInFinderRpc } from './rpc/open-in-finder' +import { reportDuplicatesRpc } from './rpc/report-duplicates' +import { reportMaintainersRpc } from './rpc/report-maintainers' +import { reportSizesRpc } from './rpc/report-sizes' import { storageNpmMeta, storageNpmMetaLatest, storagePublint } from './storage' export interface InspectorDevframeFlags { root?: string config?: string depth?: number + quiet?: boolean } export default defineDevframe({ @@ -25,11 +30,14 @@ export default defineDevframe({ }, setup(ctx, info) { const flags = (info?.flags ?? {}) as InspectorDevframeFlags + // MCP adapter calls setup() without flags. CLI mcp subcommand sets these env vars as a bridge. + const envDepth = process.env.NMI_CLI_DEPTH ? Number(process.env.NMI_CLI_DEPTH) : undefined const handlers = createInspectorRpcHandlers({ cwd: flags.root ?? ctx.cwd, - depth: flags.depth ?? 8, - configFile: flags.config, + depth: flags.depth ?? envDepth ?? 8, + configFile: flags.config ?? process.env.NMI_CLI_CONFIG, mode: ctx.mode, + quiet: flags.quiet ?? process.env.NMI_CLI_QUIET === '1', storageNpmMeta, storageNpmMetaLatest, storagePublint, @@ -41,5 +49,8 @@ export default defineDevframe({ ctx.rpc.register(getPublintRpc(handlers)) ctx.rpc.register(openInEditorRpc(handlers)) ctx.rpc.register(openInFinderRpc(handlers)) + ctx.rpc.register(reportDuplicatesRpc(handlers)) + ctx.rpc.register(reportMaintainersRpc(handlers)) + ctx.rpc.register(reportSizesRpc(handlers)) }, }) diff --git a/packages/node-modules-inspector/src/node/rpc/handlers.ts b/packages/node-modules-inspector/src/node/rpc/handlers.ts index 73ae373..e4e66df 100644 --- a/packages/node-modules-inspector/src/node/rpc/handlers.ts +++ b/packages/node-modules-inspector/src/node/rpc/handlers.ts @@ -21,6 +21,8 @@ export interface CreateInspectorRpcHandlersOptions extends mode: 'dev' | 'build' storagePublint?: Storage configFile?: string + /** Route progress logs to stderr (keeps stdout clean for JSON/MCP). */ + quiet?: boolean } export interface InspectorRpcHandlers { @@ -33,6 +35,10 @@ export interface InspectorRpcHandlers { } export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOptions): InspectorRpcHandlers { + const log = options.quiet + ? (msg: string) => process.stderr.write(`${msg}\n`) + : (msg: string) => console.log(msg) + let _config: Promise | null = null let _payload: Promise | null = null @@ -59,39 +65,39 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp merge: true, }) if (result.sources.length) - console.log(c.green`${MARK_CHECK} Config loaded from ${result.sources.join(', ')}`) + log(c.green`${MARK_CHECK} Config loaded from ${result.sources.join(', ')}`) return result.config } - async function getPackagesNpmMeta(specs: string[], log = true) { + async function getPackagesNpmMeta(specs: string[], verbose = true) { const config = await getConfig() if (!config.fetchNpmMeta) return new Map() - if (log) - console.log(c.cyan`${MARK_NODE} Fetching npm meta for ${specs.length} packages...`) + if (verbose) + log(c.cyan`${MARK_NODE} Fetching npm meta for ${specs.length} packages...`) const result = await _getPackagesNpmMeta(specs, { storageNpmMeta: options.storageNpmMeta }) - if (log) - console.log(c.green`${MARK_CHECK} npm meta fetched for ${specs.length} packages`) + if (verbose) + log(c.green`${MARK_CHECK} npm meta fetched for ${specs.length} packages`) return result } - async function getPackagesNpmMetaLatest(pkgNames: string[], log = true) { + async function getPackagesNpmMetaLatest(pkgNames: string[], verbose = true) { const config = await getConfig() if (!config.fetchNpmMeta) return new Map() - if (log) - console.log(c.cyan`${MARK_NODE} Fetching npm meta latest for ${pkgNames.length} packages...`) + if (verbose) + log(c.cyan`${MARK_NODE} Fetching npm meta latest for ${pkgNames.length} packages...`) const result = await _getPackagesNpmMetaLatest(pkgNames, { storageNpmMetaLatest: options.storageNpmMetaLatest }) - if (log) - console.log(c.green`${MARK_CHECK} npm meta latest fetched for ${pkgNames.length} packages`) + if (verbose) + log(c.green`${MARK_CHECK} npm meta latest fetched for ${pkgNames.length} packages`) return result } - async function getPublint(pkg: Pick, log = true) { + async function getPublint(pkg: Pick, verbose = true) { if (pkg.workspace || pkg.private || !pkg.filepath) return null - if (log) - console.log(c.cyan`${MARK_NODE} Running publint for ${pkg.spec}...`) + if (verbose) + log(c.cyan`${MARK_NODE} Running publint for ${pkg.spec}...`) try { let result = await options.storagePublint?.getItem(pkg.spec) || undefined const { publint } = await import('publint') @@ -103,8 +109,8 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp }).then(r => r.messages) || [] await options.storagePublint?.setItem(pkg.spec, result) } - if (log) - console.log(c.green`${MARK_CHECK} Publint for ${pkg.spec} finished with ${result.length} messages`) + if (verbose) + log(c.green`${MARK_CHECK} Publint for ${pkg.spec} finished with ${result.length} messages`) return result } catch (e) { @@ -128,7 +134,7 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp const config = await getConfig() const excludeFilter = constructPackageFilters(config.excludePackages || [], 'some') const depsFilter = constructPackageFilters(config.excludeDependenciesOf || [], 'some') - console.log(c.cyan`${MARK_NODE} Reading node_modules...`) + log(c.cyan`${MARK_NODE} Reading node_modules...`) const result = await listPackageDependencies({ cwd: process.cwd(), depth: 8, @@ -148,19 +154,19 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp if (options.mode === 'build' && config.publint) { buildTasks.push((async () => { - console.log(c.cyan`${MARK_NODE} Running publint...`) + log(c.cyan`${MARK_NODE} Running publint...`) const limit = pLimit(20) await Promise.all([...result.packages.values()] .map(pkg => limit(async () => { pkg.resolved.publint ||= await getPublint(pkg, false) }))) - console.log(c.green`${MARK_CHECK} Publint finished`) + log(c.green`${MARK_CHECK} Publint finished`) })()) } if (options.mode === 'build' && config.fetchNpmMeta) { buildTasks.push((async () => { - console.log(c.cyan`${MARK_NODE} Fetching npm meta...`) + log(c.cyan`${MARK_NODE} Fetching npm meta...`) try { await Promise.allSettled([ getPackagesNpmMeta(Array.from(result.packages.keys()), false), @@ -171,7 +177,7 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp console.error(c.red`${MARK_NODE} Failed to fetch npm meta`) console.error(e) } - console.log(c.green`${MARK_CHECK} npm meta fetched`) + log(c.green`${MARK_CHECK} npm meta fetched`) })()) } @@ -188,7 +194,7 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp pkg.resolved.npmMetaLatest = metaLatest })) - console.log(c.green`${MARK_CHECK} node_modules read finished`) + log(c.green`${MARK_CHECK} node_modules read finished`) const payload: NodeModulesInspectorPayload = { hash, @@ -197,9 +203,9 @@ export function createInspectorRpcHandlers(options: CreateInspectorRpcHandlersOp config, } if (config.onPayloadReady) { - console.log(c.cyan`${MARK_NODE} Running config hook...`) + log(c.cyan`${MARK_NODE} Running config hook...`) await config.onPayloadReady(payload) - console.log(c.green`${MARK_CHECK} Config hook finished`) + log(c.green`${MARK_CHECK} Config hook finished`) } return payload } diff --git a/packages/node-modules-inspector/src/node/rpc/report-duplicates.ts b/packages/node-modules-inspector/src/node/rpc/report-duplicates.ts new file mode 100644 index 0000000..81f8cfd --- /dev/null +++ b/packages/node-modules-inspector/src/node/rpc/report-duplicates.ts @@ -0,0 +1,33 @@ +import type { InspectorRpcHandlers } from './handlers' +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { computeDuplicates } from '../../shared/reports/duplicates' + +const argsSchema = v.optional(v.object({ + minVersions: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2)), 2), + limit: v.optional(v.number()), +}), {}) + +const returnsSchema = v.array(v.object({ + name: v.string(), + versions: v.array(v.string()), + specs: v.array(v.string()), +})) + +export function reportDuplicatesRpc(handlers: InspectorRpcHandlers) { + return defineRpcFunction({ + name: 'nmi:report-duplicates', + type: 'query', + jsonSerializable: true, + args: [argsSchema], + returns: returnsSchema, + agent: { + description: 'List packages that are installed in multiple versions across the project. Read-only.', + }, + // Cast: devframe's handler type expects sync RETURN, but async is allowed at runtime (devframe awaits). + handler: (async (opts = {}) => { + const payload = await handlers.getPayload() + return computeDuplicates(payload.packages.values(), opts) + }) as any, + }) +} diff --git a/packages/node-modules-inspector/src/node/rpc/report-maintainers.ts b/packages/node-modules-inspector/src/node/rpc/report-maintainers.ts new file mode 100644 index 0000000..69c7ac3 --- /dev/null +++ b/packages/node-modules-inspector/src/node/rpc/report-maintainers.ts @@ -0,0 +1,92 @@ +import type { PackageNode } from 'node-modules-tools' +import type { InspectorRpcHandlers } from './handlers' +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { toMaintainersGroupDto } from '../../shared/reports/dto' +import { + computeMaintainerActions, + groupMaintainerActions, +} from '../../shared/reports/maintainers' + +const argsSchema = v.optional(v.object({ + sort: v.optional(v.picklist(['depth', 'migration', 'latest']), 'depth'), + authors: v.optional(v.array(v.string()), []), + includePublint: v.optional(v.boolean(), true), + latestOnly: v.optional(v.boolean(), true), + limit: v.optional(v.number()), +}), {}) + +const returnsSchema = v.array(v.object({ + consumer: v.object({ + spec: v.string(), + name: v.string(), + version: v.string(), + depth: v.number(), + }), + authors: v.array(v.unknown()), + items: v.array(v.unknown()), + maxMigrationRatio: v.number(), + latestReleasedAt: v.number(), +})) + +function buildVersionsMap(packages: Iterable): Map { + const map = new Map() + for (const pkg of packages) { + let bucket = map.get(pkg.name) + if (!bucket) { + bucket = [] + map.set(pkg.name, bucket) + } + bucket.push(pkg) + } + return map +} + +export function reportMaintainersRpc(handlers: InspectorRpcHandlers) { + return defineRpcFunction({ + name: 'nmi:report-maintainers', + type: 'query', + jsonSerializable: true, + args: [argsSchema], + returns: returnsSchema, + agent: { + description: 'Maintenance actions per consumer package: dep-upgrade opportunities (when an installed dependency has a newer version that the consumer\'s range does not satisfy) and publint findings. Grouped by consumer, sorted by depth/migration/latest. Read-only.', + }, + handler: (async (raw: { + sort?: 'depth' | 'migration' | 'latest' + authors?: string[] + includePublint?: boolean + latestOnly?: boolean + limit?: number + } = {}) => { + const opts = { + sort: 'depth' as 'depth' | 'migration' | 'latest', + authors: [] as string[], + includePublint: true, + latestOnly: true, + limit: undefined as number | undefined, + ...raw, + } + const payload = await handlers.getPayload() + const packages = Array.from(payload.packages.values()) + const versions = buildVersionsMap(packages) + + const items = computeMaintainerActions({ + packages, + versions, + catalogs: payload.catalogs, + }) + + const groups = groupMaintainerActions(items, { + sort: opts.sort, + authorFilter: opts.authors.length ? opts.authors : undefined, + includePublint: opts.includePublint, + latestOnly: opts.latestOnly, + latestVersionOf: pkg => pkg.resolved.npmMetaLatest?.version, + }) + + const limited = opts.limit && opts.limit > 0 ? groups.slice(0, opts.limit) : groups + return limited.map(toMaintainersGroupDto) + }) as any, + }) +} diff --git a/packages/node-modules-inspector/src/node/rpc/report-schemas.test.ts b/packages/node-modules-inspector/src/node/rpc/report-schemas.test.ts new file mode 100644 index 0000000..0baadce --- /dev/null +++ b/packages/node-modules-inspector/src/node/rpc/report-schemas.test.ts @@ -0,0 +1,246 @@ +import { toJsonSchema } from '@valibot/to-json-schema' +import { describe, expect, it } from 'vitest' +import { reportDuplicatesRpc } from './report-duplicates' +import { reportMaintainersRpc } from './report-maintainers' +import { reportSizesRpc } from './report-sizes' + +/** + * Locks down the MCP-facing JSON Schemas for each report tool. devframe runs + * the same conversion via @valibot/to-json-schema before exposing tools via + * `tools/list`, so these snapshots are the contract agents see. Any drift here + * is a public-API change. + */ + +const stubHandlers = {} as any + +function schemasOf(def: { args?: readonly unknown[], returns?: unknown }) { + return { + input: def.args?.[0] ? toJsonSchema(def.args[0] as any) : null, + output: def.returns ? toJsonSchema(def.returns as any) : null, + } +} + +describe('report RPC schemas', () => { + it('duplicates', () => { + expect(schemasOf(reportDuplicatesRpc(stubHandlers))).toMatchInlineSnapshot(` + { + "input": { + "$schema": "http://json-schema.org/draft-07/schema#", + "default": {}, + "properties": { + "limit": { + "type": "number", + }, + "minVersions": { + "default": 2, + "minimum": 2, + "type": "integer", + }, + }, + "required": [], + "type": "object", + }, + "output": { + "$schema": "http://json-schema.org/draft-07/schema#", + "items": { + "properties": { + "name": { + "type": "string", + }, + "specs": { + "items": { + "type": "string", + }, + "type": "array", + }, + "versions": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "name", + "versions", + "specs", + ], + "type": "object", + }, + "type": "array", + }, + } + `) + }) + + it('sizes', () => { + expect(schemasOf(reportSizesRpc(stubHandlers))).toMatchInlineSnapshot(` + { + "input": { + "$schema": "http://json-schema.org/draft-07/schema#", + "default": {}, + "properties": { + "includeWorkspace": { + "default": false, + "type": "boolean", + }, + "limit": { + "default": 50, + "type": "integer", + }, + }, + "required": [], + "type": "object", + }, + "output": { + "$schema": "http://json-schema.org/draft-07/schema#", + "items": { + "properties": { + "bytes": { + "type": "number", + }, + "categories": { + "additionalProperties": { + "properties": { + "bytes": { + "type": "number", + }, + "count": { + "type": "number", + }, + }, + "required": [ + "bytes", + "count", + ], + "type": "object", + }, + "propertyNames": { + "type": "string", + }, + "type": "object", + }, + "name": { + "type": "string", + }, + "spec": { + "type": "string", + }, + "version": { + "type": "string", + }, + "workspace": { + "type": "boolean", + }, + }, + "required": [ + "spec", + "name", + "version", + "workspace", + "bytes", + "categories", + ], + "type": "object", + }, + "type": "array", + }, + } + `) + }) + + it('maintainers', () => { + expect(schemasOf(reportMaintainersRpc(stubHandlers))).toMatchInlineSnapshot(` + { + "input": { + "$schema": "http://json-schema.org/draft-07/schema#", + "default": {}, + "properties": { + "authors": { + "default": [], + "items": { + "type": "string", + }, + "type": "array", + }, + "includePublint": { + "default": true, + "type": "boolean", + }, + "latestOnly": { + "default": true, + "type": "boolean", + }, + "limit": { + "type": "number", + }, + "sort": { + "default": "depth", + "enum": [ + "depth", + "migration", + "latest", + ], + "type": "string", + }, + }, + "required": [], + "type": "object", + }, + "output": { + "$schema": "http://json-schema.org/draft-07/schema#", + "items": { + "properties": { + "authors": { + "items": {}, + "type": "array", + }, + "consumer": { + "properties": { + "depth": { + "type": "number", + }, + "name": { + "type": "string", + }, + "spec": { + "type": "string", + }, + "version": { + "type": "string", + }, + }, + "required": [ + "spec", + "name", + "version", + "depth", + ], + "type": "object", + }, + "items": { + "items": {}, + "type": "array", + }, + "latestReleasedAt": { + "type": "number", + }, + "maxMigrationRatio": { + "type": "number", + }, + }, + "required": [ + "consumer", + "authors", + "items", + "maxMigrationRatio", + "latestReleasedAt", + ], + "type": "object", + }, + "type": "array", + }, + } + `) + }) +}) diff --git a/packages/node-modules-inspector/src/node/rpc/report-sizes.ts b/packages/node-modules-inspector/src/node/rpc/report-sizes.ts new file mode 100644 index 0000000..2dea408 --- /dev/null +++ b/packages/node-modules-inspector/src/node/rpc/report-sizes.ts @@ -0,0 +1,38 @@ +import type { InspectorRpcHandlers } from './handlers' +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { computeInstallSizes } from '../../shared/reports/sizes' + +const argsSchema = v.optional(v.object({ + limit: v.optional(v.pipe(v.number(), v.integer()), 50), + includeWorkspace: v.optional(v.boolean(), false), +}), {}) + +const returnsSchema = v.array(v.object({ + spec: v.string(), + name: v.string(), + version: v.string(), + workspace: v.boolean(), + bytes: v.number(), + categories: v.record(v.string(), v.object({ + bytes: v.number(), + count: v.number(), + })), +})) + +export function reportSizesRpc(handlers: InspectorRpcHandlers) { + return defineRpcFunction({ + name: 'nmi:report-sizes', + type: 'query', + jsonSerializable: true, + args: [argsSchema], + returns: returnsSchema, + agent: { + description: 'List packages sorted by install size (largest first). Read-only.', + }, + handler: (async (opts = {}) => { + const payload = await handlers.getPayload() + return computeInstallSizes(payload.packages.values(), opts) + }) as any, + }) +} diff --git a/packages/node-modules-inspector/src/shared/reports/dto.ts b/packages/node-modules-inspector/src/shared/reports/dto.ts new file mode 100644 index 0000000..4454b38 --- /dev/null +++ b/packages/node-modules-inspector/src/shared/reports/dto.ts @@ -0,0 +1,100 @@ +import type { PackageInstallSizeInfo, PublintMessage } from 'node-modules-tools' +import type { ParsedAuthor } from 'node-modules-tools/utils' +import type { + MaintainerActionGroup, + MaintainerActionItem, +} from './maintainers' + +export interface ReportConsumerDto { + spec: string + name: string + version: string + depth: number +} + +export type MaintainerActionItemDto + = | MaintainerActionDepUpgradeDto + | MaintainerActionPublintDto + +export interface MaintainerActionDepUpgradeDto { + kind: 'dep-upgrade' + depName: string + depType: 'peer' | 'prod' + declaredRange: string + rawRange?: string + catalogName?: string + installedHighestVersion: string + installedHighestSpec: string + installedVersions: string[] + migratedCount: number + totalCount: number + migrationRatio: number +} + +export interface MaintainerActionPublintDto { + kind: 'publint' + messages: PublintMessage[] + counts: { error: number, warning: number, suggestion: number } +} + +export interface MaintainersGroupDto { + consumer: ReportConsumerDto + authors: ParsedAuthor[] + items: MaintainerActionItemDto[] + maxMigrationRatio: number + latestReleasedAt: number +} + +export function toMaintainerItemDto(item: MaintainerActionItem): MaintainerActionItemDto { + if (item.kind === 'publint') { + return { + kind: 'publint', + messages: item.messages, + counts: item.counts, + } + } + return { + kind: 'dep-upgrade', + depName: item.depName, + depType: item.depType, + declaredRange: item.declaredRange, + rawRange: item.rawRange, + catalogName: item.catalogName, + installedHighestVersion: item.installedHighestVersion, + installedHighestSpec: item.installedHighest.spec, + installedVersions: item.installedVersions, + migratedCount: item.migratedCount, + totalCount: item.totalCount, + migrationRatio: item.migrationRatio, + } +} + +export function toMaintainersGroupDto(group: MaintainerActionGroup): MaintainersGroupDto { + return { + consumer: { + spec: group.consumer.spec, + name: group.consumer.name, + version: group.consumer.version, + depth: group.depth, + }, + authors: group.authors, + items: group.items.map(toMaintainerItemDto), + maxMigrationRatio: group.maxMigrationRatio, + latestReleasedAt: group.latestReleasedAt, + } +} + +export interface DuplicatesEntry { + name: string + versions: string[] + specs: string[] +} + +export interface SizesEntry { + spec: string + name: string + version: string + workspace: boolean + bytes: number + categories: PackageInstallSizeInfo['categories'] +} diff --git a/packages/node-modules-inspector/src/shared/reports/duplicates.test.ts b/packages/node-modules-inspector/src/shared/reports/duplicates.test.ts new file mode 100644 index 0000000..4388822 --- /dev/null +++ b/packages/node-modules-inspector/src/shared/reports/duplicates.test.ts @@ -0,0 +1,64 @@ +import type { PackageNode } from 'node-modules-tools' +import { describe, expect, it } from 'vitest' +import { computeDuplicates } from './duplicates' + +function pkg(name: string, version: string): PackageNode { + return { + name, + version, + spec: `${name}@${version}`, + } as unknown as PackageNode +} + +describe('computeDuplicates', () => { + it('returns empty when no duplicates', () => { + expect(computeDuplicates([pkg('a', '1.0.0'), pkg('b', '1.0.0')])).toEqual([]) + }) + + it('groups by name and sorts versions ascending', () => { + const result = computeDuplicates([ + pkg('a', '2.0.0'), + pkg('a', '1.0.0'), + pkg('b', '1.0.0'), + ]) + expect(result).toEqual([ + { name: 'a', versions: ['1.0.0', '2.0.0'], specs: ['a@1.0.0', 'a@2.0.0'] }, + ]) + }) + + it('sorts entries by version count descending', () => { + const result = computeDuplicates([ + pkg('a', '1.0.0'), + pkg('a', '2.0.0'), + pkg('b', '1.0.0'), + pkg('b', '2.0.0'), + pkg('b', '3.0.0'), + ]) + expect(result.map(e => e.name)).toEqual(['b', 'a']) + }) + + it('respects minVersions threshold', () => { + const packages = [ + pkg('a', '1.0.0'), + pkg('a', '2.0.0'), + pkg('b', '1.0.0'), + pkg('b', '2.0.0'), + pkg('b', '3.0.0'), + ] + expect(computeDuplicates(packages, { minVersions: 3 })).toEqual([ + { name: 'b', versions: ['1.0.0', '2.0.0', '3.0.0'], specs: ['b@1.0.0', 'b@2.0.0', 'b@3.0.0'] }, + ]) + }) + + it('respects limit', () => { + const packages = [ + pkg('a', '1.0.0'), + pkg('a', '2.0.0'), + pkg('b', '1.0.0'), + pkg('b', '2.0.0'), + pkg('c', '1.0.0'), + pkg('c', '2.0.0'), + ] + expect(computeDuplicates(packages, { limit: 2 })).toHaveLength(2) + }) +}) diff --git a/packages/node-modules-inspector/src/shared/reports/duplicates.ts b/packages/node-modules-inspector/src/shared/reports/duplicates.ts new file mode 100644 index 0000000..0cf6380 --- /dev/null +++ b/packages/node-modules-inspector/src/shared/reports/duplicates.ts @@ -0,0 +1,43 @@ +import type { PackageNode } from 'node-modules-tools' +import type { DuplicatesEntry } from './dto' +import { compareSemver } from '../semver' + +export interface ComputeDuplicatesOptions { + /** Only include packages installed at this many versions or more. Default `2`. */ + minVersions?: number + /** Cap the number of returned entries. */ + limit?: number +} + +export function computeDuplicates( + packages: Iterable, + options: ComputeDuplicatesOptions = {}, +): DuplicatesEntry[] { + const minVersions = options.minVersions ?? 2 + + const byName = new Map() + for (const pkg of packages) { + let bucket = byName.get(pkg.name) + if (!bucket) { + bucket = [] + byName.set(pkg.name, bucket) + } + bucket.push(pkg) + } + + const entries: DuplicatesEntry[] = [] + for (const [name, pkgs] of byName) { + if (pkgs.length < minVersions) + continue + const sorted = pkgs.slice().sort((a, b) => compareSemver(a.version, b.version)) + entries.push({ + name, + versions: sorted.map(p => p.version), + specs: sorted.map(p => p.spec), + }) + } + + entries.sort((a, b) => (b.versions.length - a.versions.length) || a.name.localeCompare(b.name)) + + return options.limit ? entries.slice(0, options.limit) : entries +} diff --git a/packages/node-modules-inspector/src/shared/reports/maintainers.test.ts b/packages/node-modules-inspector/src/shared/reports/maintainers.test.ts new file mode 100644 index 0000000..1ebbe40 --- /dev/null +++ b/packages/node-modules-inspector/src/shared/reports/maintainers.test.ts @@ -0,0 +1,170 @@ +import type { PackageNode } from 'node-modules-tools' +import { describe, expect, it } from 'vitest' +import { + computeMaintainerActions, + groupMaintainerActions, +} from './maintainers' + +interface PkgInput { + name: string + version: string + depth?: number + dependencies?: Record + peerDependencies?: Record +} + +function pkg(input: PkgInput): PackageNode { + return { + name: input.name, + version: input.version, + spec: `${input.name}@${input.version}`, + depth: input.depth ?? 1, + resolved: { + packageJson: { + dependencies: input.dependencies, + peerDependencies: input.peerDependencies, + }, + }, + } as unknown as PackageNode +} + +function buildVersions(packages: PackageNode[]): Map { + const map = new Map() + for (const p of packages) { + let bucket = map.get(p.name) + if (!bucket) { + bucket = [] + map.set(p.name, bucket) + } + bucket.push(p) + } + return map +} + +describe('computeMaintainerActions', () => { + it('emits dep-upgrade when installed highest is gtr than declared range', () => { + const dep1 = pkg({ name: 'dep', version: '1.0.0' }) + const dep2 = pkg({ name: 'dep', version: '2.0.0' }) + const consumer = pkg({ + name: 'consumer', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + }) + const all = [consumer, dep1, dep2] + const items = computeMaintainerActions({ + packages: all, + versions: buildVersions(all), + }) + const upgrades = items.filter(i => i.kind === 'dep-upgrade') + expect(upgrades).toHaveLength(1) + expect(upgrades[0]).toMatchObject({ + kind: 'dep-upgrade', + depName: 'dep', + depType: 'prod', + declaredRange: '^1.0.0', + installedHighestVersion: '2.0.0', + }) + }) + + it('does not emit when range is satisfied', () => { + const dep = pkg({ name: 'dep', version: '1.5.0' }) + const consumer = pkg({ + name: 'consumer', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + }) + const all = [consumer, dep] + const items = computeMaintainerActions({ + packages: all, + versions: buildVersions(all), + }) + expect(items).toEqual([]) + }) + + it('resolves catalog: ranges', () => { + const dep1 = pkg({ name: 'dep', version: '1.0.0' }) + const dep2 = pkg({ name: 'dep', version: '2.0.0' }) + const consumer = pkg({ + name: 'consumer', + version: '1.0.0', + dependencies: { dep: 'catalog:deps' }, + }) + const all = [consumer, dep1, dep2] + const items = computeMaintainerActions({ + packages: all, + versions: buildVersions(all), + catalogs: { deps: { dep: '^1.0.0' } }, + }) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ + kind: 'dep-upgrade', + declaredRange: '^1.0.0', + rawRange: 'catalog:deps', + catalogName: 'deps', + }) + }) + + it('emits publint action when publint messages are present', () => { + const consumer = pkg({ name: 'consumer', version: '1.0.0' }) + ;(consumer as any).resolved.publint = [ + { type: 'error', code: 'X', message: 'm', args: {} }, + { type: 'warning', code: 'Y', message: 'm', args: {} }, + ] + const items = computeMaintainerActions({ + packages: [consumer], + versions: buildVersions([consumer]), + }) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ + kind: 'publint', + counts: { error: 1, warning: 1, suggestion: 0 }, + }) + }) +}) + +describe('groupMaintainerActions', () => { + it('groups items by consumer and applies includePublint filter', () => { + const dep2 = pkg({ name: 'dep', version: '2.0.0' }) + const consumer = pkg({ + name: 'consumer', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + }) + ;(consumer as any).resolved.publint = [{ type: 'error', code: 'X', message: 'm', args: {} }] + const all = [consumer, dep2] + const items = computeMaintainerActions({ + packages: all, + versions: buildVersions(all), + }) + + const withPublint = groupMaintainerActions(items, { includePublint: true }) + expect(withPublint).toHaveLength(1) + expect(withPublint[0]!.items.map(i => i.kind).sort()).toEqual(['dep-upgrade', 'publint']) + + const withoutPublint = groupMaintainerActions(items, { includePublint: false }) + expect(withoutPublint).toHaveLength(1) + expect(withoutPublint[0]!.items.map(i => i.kind)).toEqual(['dep-upgrade']) + }) + + it('deduplicates by name keeping highest version', () => { + const dep2 = pkg({ name: 'dep', version: '2.0.0' }) + const consumerV1 = pkg({ + name: 'consumer', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + }) + const consumerV2 = pkg({ + name: 'consumer', + version: '2.0.0', + dependencies: { dep: '^1.0.0' }, + }) + const all = [consumerV1, consumerV2, dep2] + const items = computeMaintainerActions({ + packages: all, + versions: buildVersions(all), + }) + const groups = groupMaintainerActions(items) + expect(groups).toHaveLength(1) + expect(groups[0]!.consumer.version).toBe('2.0.0') + }) +}) diff --git a/packages/node-modules-inspector/src/shared/reports/maintainers.ts b/packages/node-modules-inspector/src/shared/reports/maintainers.ts new file mode 100644 index 0000000..27f666e --- /dev/null +++ b/packages/node-modules-inspector/src/shared/reports/maintainers.ts @@ -0,0 +1,376 @@ +import type { PackageNode, PublintMessage } from 'node-modules-tools' +import type { ParsedAuthor } from 'node-modules-tools/utils' +import semver from 'semver' +import { compareSemver } from '../semver' + +export function authorKey(author: ParsedAuthor): string { + return author.type === 'github' ? `@${author.github}` : author.name +} + +interface BaseAction { + consumer: PackageNode + depth: number + key: string +} + +export interface DepUpgradeAction extends BaseAction { + kind: 'dep-upgrade' + depName: string + depType: 'peer' | 'prod' + /** The effective semver range (after resolving any catalog reference). */ + declaredRange: string + /** The raw range as written in package.json, when it differs (e.g. `catalog:deps`). */ + rawRange?: string + /** Catalog name when the raw range was a catalog reference. */ + catalogName?: string + installedHighestVersion: string + installedHighest: PackageNode + installedVersions: string[] + migratedCount: number + totalCount: number + migrationRatio: number +} + +export interface PublintAction extends BaseAction { + kind: 'publint' + messages: PublintMessage[] + counts: { error: number, warning: number, suggestion: number } +} + +export type MaintainerActionItem = DepUpgradeAction | PublintAction + +export type MaintainerActionSortMode = 'depth' | 'migration' | 'latest' + +export interface MaintainerActionGroup { + consumer: PackageNode + depth: number + authors: ParsedAuthor[] + items: MaintainerActionItem[] + maxMigrationRatio: number + latestReleasedAt: number +} + +export interface MaintainerActionAuthorEntry { + author: ParsedAuthor + count: number +} + +interface DepStats { + highestVersion: string + highestPkg: PackageNode + versions: string[] + migrated: number + behind: number +} + +const NON_SEMVER_PREFIXES = ['workspace:', 'link:', 'file:', 'npm:', 'git+', 'git:', 'http:', 'https:', 'github:'] + +function resolveCatalogRange( + range: string, + depName: string, + catalogs: Record> | undefined, +): string | undefined { + if (!range.startsWith('catalog:')) + return range + if (!catalogs) + return undefined + const name = range.slice('catalog:'.length) || 'default' + return catalogs[name]?.[depName] +} + +function isPlainSemverRange(range: string | undefined): range is string { + if (!range || range === '*' || range === 'latest' || range === 'x') + return false + return !NON_SEMVER_PREFIXES.some(p => range.startsWith(p)) +} + +function safeSatisfies(version: string, range: string) { + try { + return semver.satisfies(version, range) + } + catch { + return null + } +} + +function safeGtr(version: string, range: string) { + try { + return semver.gtr(version, range) + } + catch { + return null + } +} + +function isStable(version: string) { + return semver.prerelease(version) === null +} + +function getPublintMessagesFor( + pkg: PackageNode, + fallback?: (pkg: PackageNode) => PublintMessage[] | null, +): PublintMessage[] | null { + if (pkg.resolved.publint) + return pkg.resolved.publint + return fallback?.(pkg) ?? null +} + +export interface MaintainerActionsInput { + packages: Iterable + versions: Map + catalogs?: Record> + publintFallback?: (pkg: PackageNode) => PublintMessage[] | null +} + +export function computeMaintainerActions(input: MaintainerActionsInput): MaintainerActionItem[] { + const { catalogs, publintFallback, versions } = input + const packages = Array.from(input.packages) + const stats = new Map() + + function getStats(depName: string): DepStats | null { + if (stats.has(depName)) + return stats.get(depName)! + const installed = versions.get(depName) + if (!installed?.length) { + stats.set(depName, null) + return null + } + const sortedAll = installed.slice().sort((a, b) => compareSemver(a.version, b.version)) + const stable = sortedAll.filter(p => isStable(p.version)) + if (!stable.length) { + stats.set(depName, null) + return null + } + const highestPkg = stable.at(-1)! + const entry: DepStats = { + highestVersion: highestPkg.version, + highestPkg, + versions: sortedAll.map(p => p.version), + migrated: 0, + behind: 0, + } + stats.set(depName, entry) + return entry + } + + for (const consumer of packages) { + const pj = consumer.resolved.packageJson + const blocks = [pj.peerDependencies, pj.dependencies] + for (const block of blocks) { + if (!block) + continue + for (const [depName, rawRange] of Object.entries(block)) { + const range = resolveCatalogRange(rawRange, depName, catalogs) + if (!isPlainSemverRange(range)) + continue + const entry = getStats(depName) + if (!entry) + continue + if (safeSatisfies(entry.highestVersion, range)) { + entry.migrated++ + } + else if (safeGtr(entry.highestVersion, range)) { + entry.behind++ + } + // else: declared range is ahead of highest stable — ignore (not part of this cohort) + } + } + } + + const items: MaintainerActionItem[] = [] + + for (const consumer of packages) { + const pj = consumer.resolved.packageJson + const blocks: Array<[Record | undefined, 'peer' | 'prod']> = [ + [pj.peerDependencies, 'peer'], + [pj.dependencies, 'prod'], + ] + for (const [block, depType] of blocks) { + if (!block) + continue + for (const [depName, rawRange] of Object.entries(block)) { + const declaredRange = resolveCatalogRange(rawRange, depName, catalogs) + if (!isPlainSemverRange(declaredRange)) + continue + const entry = stats.get(depName) + if (!entry) + continue + if (safeGtr(entry.highestVersion, declaredRange) !== true) + continue + // Skip when consumer and dep share the same repository (monorepo siblings). + const consumerRepo = consumer.resolved.repository?.url + const depRepo = entry.highestPkg.resolved.repository?.url + if (consumerRepo && depRepo && consumerRepo === depRepo) + continue + const total = entry.migrated + entry.behind + const catalogName = rawRange.startsWith('catalog:') + ? (rawRange.slice('catalog:'.length) || 'default') + : undefined + items.push({ + kind: 'dep-upgrade', + consumer, + depName, + depType, + declaredRange, + rawRange: rawRange === declaredRange ? undefined : rawRange, + catalogName, + installedHighestVersion: entry.highestVersion, + installedHighest: entry.highestPkg, + installedVersions: entry.versions, + migratedCount: entry.migrated, + totalCount: total, + migrationRatio: total ? entry.migrated / total : 0, + depth: consumer.depth, + key: `${consumer.spec}::${depType}::${depName}`, + }) + } + } + } + + for (const consumer of packages) { + const messages = getPublintMessagesFor(consumer, publintFallback) + if (!messages?.length) + continue + const counts = { error: 0, warning: 0, suggestion: 0 } + for (const m of messages) + counts[m.type]++ + items.push({ + kind: 'publint', + consumer, + depth: consumer.depth, + key: `${consumer.spec}::publint`, + messages, + counts, + }) + } + + return items +} + +function getConsumerAuthors(pkg: PackageNode): ParsedAuthor[] { + const list = pkg.resolved.authors + if (!list?.length) + return [] + return list.filter(a => a.type === 'github' || !!a.name?.trim()) +} + +function actionSortKey(a: MaintainerActionItem, b: MaintainerActionItem): number { + if (a.kind !== b.kind) + return a.kind === 'publint' ? -1 : 1 + if (a.kind === 'dep-upgrade' && b.kind === 'dep-upgrade') { + return (b.migrationRatio - a.migrationRatio) + || a.depName.localeCompare(b.depName) + } + return 0 +} + +function defaultPublishTimeOf(pkg: PackageNode): Date | undefined { + const t = pkg.resolved.npmMeta?.publishedAt + return t ? new Date(t) : undefined +} + +export interface MaintainerGroupOptions { + sort?: MaintainerActionSortMode + authorFilter?: string[] + includePublint?: boolean + latestOnly?: boolean + /** Resolve the latest published version for the package; required for `latestOnly` filter. */ + latestVersionOf?: (pkg: PackageNode) => string | undefined + /** Resolve the publish time for the package; defaults to `pkg.resolved.npmMeta.publishedAt`. */ + publishTimeOf?: (pkg: PackageNode) => Date | undefined | null +} + +export function groupMaintainerActions( + items: MaintainerActionItem[], + options: MaintainerGroupOptions = {}, +): MaintainerActionGroup[] { + const publishTimeOf = options.publishTimeOf ?? defaultPublishTimeOf + const byConsumer = new Map() + for (const item of items) { + let group = byConsumer.get(item.consumer.spec) + if (!group) { + group = { + consumer: item.consumer, + depth: item.consumer.depth, + authors: getConsumerAuthors(item.consumer), + items: [], + maxMigrationRatio: 0, + latestReleasedAt: publishTimeOf(item.consumer)?.getTime() ?? 0, + } + byConsumer.set(item.consumer.spec, group) + } + group.items.push(item) + if (item.kind === 'dep-upgrade' && item.migrationRatio > group.maxMigrationRatio) + group.maxMigrationRatio = item.migrationRatio + } + + for (const group of byConsumer.values()) + group.items.sort(actionSortKey) + + const byName = new Map() + for (const g of byConsumer.values()) { + const cur = byName.get(g.consumer.name) + if (!cur || compareSemver(cur.consumer.version, g.consumer.version) < 0) + byName.set(g.consumer.name, g) + } + let groups = Array.from(byName.values()) + + const selected = options.authorFilter + if (selected?.length) { + const set = new Set(selected) + groups = groups.filter(g => g.authors.some(a => set.has(authorKey(a)))) + } + + if (options.includePublint === false) { + groups = groups + .map((g) => { + const items = g.items.filter(i => i.kind !== 'publint') + return items.length === g.items.length ? g : { ...g, items } + }) + .filter(g => g.items.length > 0) + } + + if (options.latestOnly && options.latestVersionOf) { + const latestVersionOf = options.latestVersionOf + groups = groups.filter((g) => { + const latest = latestVersionOf(g.consumer) + if (!latest) + return true + try { + return semver.major(g.consumer.version) === semver.major(latest) + } + catch { + return true + } + }) + } + + const mode = options.sort ?? 'depth' + const nameTie = (a: MaintainerActionGroup, b: MaintainerActionGroup) => + a.consumer.name.localeCompare(b.consumer.name) + const cmp: (a: MaintainerActionGroup, b: MaintainerActionGroup) => number + = mode === 'migration' + ? (a, b) => (b.maxMigrationRatio - a.maxMigrationRatio) || (a.depth - b.depth) || nameTie(a, b) + : mode === 'latest' + ? (a, b) => (b.latestReleasedAt - a.latestReleasedAt) || (a.depth - b.depth) || nameTie(a, b) + : (a, b) => (a.depth - b.depth) || (b.maxMigrationRatio - a.maxMigrationRatio) || nameTie(a, b) + return groups.sort(cmp) +} + +export function collectMaintainerActionAuthors( + groups: MaintainerActionGroup[], +): MaintainerActionAuthorEntry[] { + const map = new Map() + for (const group of groups) { + for (const author of group.authors) { + const key = authorKey(author) + const entry = map.get(key) + if (entry) + entry.count++ + else + map.set(key, { author, count: 1 }) + } + } + return Array.from(map.values()) + .sort((a, b) => (b.count - a.count) || authorKey(a.author).localeCompare(authorKey(b.author))) +} diff --git a/packages/node-modules-inspector/src/shared/reports/sizes.test.ts b/packages/node-modules-inspector/src/shared/reports/sizes.test.ts new file mode 100644 index 0000000..c583d4e --- /dev/null +++ b/packages/node-modules-inspector/src/shared/reports/sizes.test.ts @@ -0,0 +1,71 @@ +import type { PackageNode } from 'node-modules-tools' +import { describe, expect, it } from 'vitest' +import { computeInstallSizes } from './sizes' + +interface PkgInput { + name: string + version: string + bytes?: number + workspace?: boolean +} + +function pkg(input: PkgInput): PackageNode { + return { + name: input.name, + version: input.version, + spec: `${input.name}@${input.version}`, + workspace: input.workspace, + resolved: { + installSize: input.bytes != null + ? { bytes: input.bytes, categories: { js: { bytes: input.bytes, count: 1 } } } + : undefined, + }, + } as unknown as PackageNode +} + +describe('computeInstallSizes', () => { + it('filters out packages without installSize', () => { + const result = computeInstallSizes([ + pkg({ name: 'a', version: '1.0.0', bytes: 100 }), + pkg({ name: 'b', version: '1.0.0' }), + ]) + expect(result.map(e => e.name)).toEqual(['a']) + }) + + it('sorts by bytes descending', () => { + const result = computeInstallSizes([ + pkg({ name: 'a', version: '1.0.0', bytes: 100 }), + pkg({ name: 'b', version: '1.0.0', bytes: 500 }), + pkg({ name: 'c', version: '1.0.0', bytes: 200 }), + ]) + expect(result.map(e => e.name)).toEqual(['b', 'c', 'a']) + }) + + it('excludes workspace packages by default', () => { + const result = computeInstallSizes([ + pkg({ name: 'a', version: '1.0.0', bytes: 100 }), + pkg({ name: 'ws', version: '1.0.0', bytes: 100, workspace: true }), + ]) + expect(result.map(e => e.name)).toEqual(['a']) + }) + + it('includes workspace packages when includeWorkspace=true', () => { + const result = computeInstallSizes([ + pkg({ name: 'a', version: '1.0.0', bytes: 100 }), + pkg({ name: 'ws', version: '1.0.0', bytes: 100, workspace: true }), + ], { includeWorkspace: true }) + expect(result.map(e => e.name).sort()).toEqual(['a', 'ws']) + }) + + it('respects limit', () => { + const result = computeInstallSizes( + [ + pkg({ name: 'a', version: '1.0.0', bytes: 100 }), + pkg({ name: 'b', version: '1.0.0', bytes: 200 }), + pkg({ name: 'c', version: '1.0.0', bytes: 300 }), + ], + { limit: 2 }, + ) + expect(result).toHaveLength(2) + }) +}) diff --git a/packages/node-modules-inspector/src/shared/reports/sizes.ts b/packages/node-modules-inspector/src/shared/reports/sizes.ts new file mode 100644 index 0000000..6737ca6 --- /dev/null +++ b/packages/node-modules-inspector/src/shared/reports/sizes.ts @@ -0,0 +1,37 @@ +import type { PackageNode } from 'node-modules-tools' +import type { SizesEntry } from './dto' + +export interface ComputeSizesOptions { + /** Cap the number of returned entries. Default `50`. */ + limit?: number + /** Include workspace packages (default: false — workspace packages have no meaningful install size). */ + includeWorkspace?: boolean +} + +export function computeInstallSizes( + packages: Iterable, + options: ComputeSizesOptions = {}, +): SizesEntry[] { + const includeWorkspace = options.includeWorkspace ?? false + const limit = options.limit ?? 50 + + const entries: SizesEntry[] = [] + for (const pkg of packages) { + const info = pkg.resolved.installSize + if (!info?.bytes) + continue + if (!includeWorkspace && pkg.workspace) + continue + entries.push({ + spec: pkg.spec, + name: pkg.name, + version: pkg.version, + workspace: pkg.workspace === true, + bytes: info.bytes, + categories: info.categories, + }) + } + + entries.sort((a, b) => b.bytes - a.bytes) + return limit > 0 ? entries.slice(0, limit) : entries +} diff --git a/packages/node-modules-inspector/src/shared/semver.ts b/packages/node-modules-inspector/src/shared/semver.ts new file mode 100644 index 0000000..48a79fc --- /dev/null +++ b/packages/node-modules-inspector/src/shared/semver.ts @@ -0,0 +1,13 @@ +import semver from 'semver' + +export function compareSemver(a: string, b: string): number { + if (a === b) + return 0 + try { + return semver.compare(a, b) + } + catch (e) { + console.error('Failed to compare semver ', e) + return 0 + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72858ab..9d14bc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ catalogs: specifier: ^8.0.13 version: 8.0.13 deps: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0 ansis: specifier: ^4.3.0 version: 4.3.0 @@ -100,6 +103,9 @@ catalogs: unstorage: specifier: ^1.17.5 version: 1.17.5 + valibot: + specifier: ^1.4.1 + version: 1.4.1 dev: '@antfu/ni': specifier: ^30.1.0 @@ -217,6 +223,9 @@ catalogs: '@playwright/test': specifier: ^1.60.0 version: 1.60.0 + '@valibot/to-json-schema': + specifier: ^1.7.0 + version: 1.7.0 mlly: specifier: ^1.8.2 version: 1.8.2 @@ -328,7 +337,7 @@ importers: version: 11.1.0 devframe: specifier: catalog:deps - version: 0.5.2(typescript@6.0.3) + version: 0.5.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(typescript@6.0.3) esbuild: specifier: catalog:bundling version: 0.28.0 @@ -395,6 +404,9 @@ importers: packages/node-modules-inspector: dependencies: + '@modelcontextprotocol/sdk': + specifier: catalog:deps + version: 1.29.0(zod@4.4.3) ansis: specifier: catalog:deps version: 4.3.0 @@ -403,7 +415,7 @@ importers: version: 7.0.0 devframe: specifier: catalog:deps - version: 0.5.2(typescript@6.0.3) + version: 0.5.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(typescript@6.0.3) fast-npm-meta: specifier: catalog:deps version: 1.5.1 @@ -449,6 +461,9 @@ importers: unstorage: specifier: catalog:deps version: 1.17.5(db0@0.3.4)(idb-keyval@6.2.2)(ioredis@5.10.1) + valibot: + specifier: catalog:deps + version: 1.4.1(typescript@6.0.3) devDependencies: '@types/semver': specifier: catalog:types @@ -456,6 +471,9 @@ importers: '@unocss/nuxt': specifier: catalog:bundling version: 66.6.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(magicast@0.5.2)(vite@8.0.13(@types/node@22.13.9)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.4))(webpack@5.98.0(esbuild@0.28.0)) + '@valibot/to-json-schema': + specifier: catalog:testing + version: 1.7.0(valibot@1.4.1(typescript@6.0.3)) '@vueuse/nuxt': specifier: catalog:bundling version: 14.3.0(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.13.9)(@vue/compiler-sfc@3.5.33)(cac@7.0.0)(db0@0.3.4)(eslint@10.4.0(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.1)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.1)(rollup@4.60.4))(rollup@4.60.4)(terser@5.39.0)(typescript@6.0.3)(vite@8.0.13(@types/node@22.13.9)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.4))(vue-tsc@3.3.0(typescript@6.0.3))(yaml@2.8.4))(vue@3.5.33(typescript@6.0.3)) @@ -1372,6 +1390,12 @@ packages: resolution: {integrity: sha512-7bQW+gkKa2kKZPeJf6+c6gFK9ARxQfn+FKy9ScTBppyKRWH2KzsmweXUoklqeEiHiNVWaeP5csIdsNq6w7QhzA==} engines: {node: '>=12.20'} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1457,6 +1481,16 @@ packages: engines: {node: '>=18'} hasBin: true + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3680,6 +3714,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -3707,6 +3745,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -3825,6 +3871,10 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + bole@5.0.17: resolution: {integrity: sha512-q6F82qEcUQTP178ZEY4WI1zdVzxy+fOnSF1dOMyC16u1fc0c24YrDPbgxA6N5wGHayCUdSBWsF8Oy7r2AKtQdA==} @@ -3879,6 +3929,10 @@ packages: peerDependencies: esbuild: '>=0.18' + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -3895,6 +3949,14 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -4009,6 +4071,18 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -4021,12 +4095,24 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -4347,6 +4433,10 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -4397,12 +4487,24 @@ packages: errx@0.1.0: resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -4724,6 +4826,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4736,6 +4846,16 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -4804,6 +4924,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -4843,6 +4967,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -4886,6 +5014,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-npm-tarball-url@2.1.0: resolution: {integrity: sha512-ro+DiMu5DXgRBabqXupW38h7WPZ9+Ad8UjwhvsmmN8w1sU7ab0nzAXvVZ4kqYg57OrqomRtJvepX5/xvFKNtjA==} engines: {node: '>=12.17'} @@ -4893,6 +5025,10 @@ packages: get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -4958,6 +5094,10 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -4990,10 +5130,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.12.23: + resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -5015,6 +5163,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-shutdown@1.2.2: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -5038,6 +5190,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} @@ -5084,6 +5240,14 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -5151,6 +5315,9 @@ packages: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -5199,6 +5366,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5241,6 +5411,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5455,6 +5628,10 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -5500,6 +5677,14 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -5738,6 +5923,10 @@ packages: engines: {node: '>=10'} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -5835,9 +6024,17 @@ packages: engines: {node: '>=18'} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -5858,6 +6055,9 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -5976,6 +6176,9 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -5996,6 +6199,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -6221,6 +6428,10 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} @@ -6230,6 +6441,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -6249,6 +6464,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -6380,6 +6599,10 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.0.0: resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} engines: {node: '>=18'} @@ -6468,6 +6691,22 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -6566,6 +6805,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -6787,6 +7030,10 @@ packages: resolution: {integrity: sha512-wQ531tuWvB6oK+pchHIu5lHe5f5wpSCqB8Kf4dWQRbOYc9HTge7JL0G4Qd44bh6QuJCccIzL3bugb8GI0MwHrg==} engines: {node: '>=20'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + type-level-regexp@0.1.17: resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} @@ -6889,6 +7136,10 @@ packages: '@unocss/webpack': optional: true + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin-utils@0.2.4: resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==} engines: {node: '>=18.12.0'} @@ -7018,6 +7269,10 @@ packages: resolution: {integrity: sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-dev-rpc@1.1.0: resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} peerDependencies: @@ -7317,6 +7572,9 @@ packages: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@5.0.1: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7414,6 +7672,14 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -8075,6 +8341,10 @@ snapshots: '@gwhitney/detect-indent@7.0.1': {} + '@hono/node-server@1.19.14(hono@4.12.23)': + dependencies: + hono: 4.12.23 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -8180,6 +8450,28 @@ snapshots: - encoding - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.23) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.23 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -10751,6 +11043,11 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -10767,6 +11064,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@5.1.0(ajv@8.17.1): dependencies: ajv: 8.17.1 @@ -10894,6 +11195,20 @@ snapshots: birpc@4.0.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.0 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bole@5.0.17: dependencies: fast-safe-stringify: 2.1.1 @@ -10957,6 +11272,8 @@ snapshots: esbuild: 0.27.7 load-tsconfig: 0.2.5 + bytes@3.1.2: {} + c12@3.3.4(magicast@0.5.2): dependencies: chokidar: 5.0.0 @@ -10978,6 +11295,16 @@ snapshots: cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-api@3.0.0: dependencies: browserslist: 4.28.2 @@ -11068,6 +11395,12 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-es@1.2.3: {} @@ -11076,12 +11409,21 @@ snapshots: cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-js-compat@3.49.0: dependencies: browserslist: 4.28.2 core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -11418,7 +11760,7 @@ snapshots: devalue@5.7.1: {} - devframe@0.5.2(typescript@6.0.3): + devframe@0.5.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(typescript@6.0.3): dependencies: '@valibot/to-json-schema': 1.7.0(valibot@1.4.1(typescript@6.0.3)) birpc: 4.0.0 @@ -11429,6 +11771,8 @@ snapshots: pathe: 2.0.3 valibot: 1.4.1(typescript@6.0.3) ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - crossws @@ -11467,6 +11811,12 @@ snapshots: dotenv@17.4.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -11502,10 +11852,18 @@ snapshots: errx@0.1.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} es-module-lexer@2.0.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -11976,6 +12334,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -12002,6 +12366,44 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.1 + statuses: 2.0.1 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} extend-shallow@2.0.1: @@ -12062,6 +12464,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + find-up-simple@1.0.1: {} find-up@5.0.0: @@ -12102,6 +12515,8 @@ snapshots: format@0.2.2: {} + forwarded@0.2.0: {} + fraction.js@5.3.4: {} fresh@2.0.0: {} @@ -12130,10 +12545,28 @@ snapshots: get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-npm-tarball-url@2.1.0: {} get-port-please@3.2.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-stream@6.0.1: {} get-stream@8.0.1: {} @@ -12194,6 +12627,8 @@ snapshots: globrex@0.1.2: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} gray-matter@4.0.3: @@ -12230,10 +12665,14 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 + hono@4.12.23: {} + hookable@5.5.3: {} hookable@6.1.1: {} @@ -12256,6 +12695,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-shutdown@1.2.2: {} https-proxy-agent@7.0.6: @@ -12275,6 +12722,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} ieee754@1.2.1: {} @@ -12319,6 +12770,10 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + iron-webcrypto@1.2.1: {} is-arrayish@0.2.1: {} @@ -12364,6 +12819,8 @@ snapshots: is-plain-obj@2.1.0: {} + is-promise@4.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -12404,6 +12861,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -12436,6 +12895,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -12637,6 +13098,8 @@ snapshots: markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -12766,6 +13229,10 @@ snapshots: mdn-data@2.27.1: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -13086,6 +13553,8 @@ snapshots: split2: 3.2.2 through2: 4.0.2 + negotiator@1.0.0: {} + neo-async@2.6.2: {} nitropack@2.13.4(idb-keyval@6.2.2)(oxc-parser@0.117.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rolldown@1.0.1): @@ -13517,8 +13986,12 @@ snapshots: pathe: 2.0.3 tinyexec: 1.1.2 + object-assign@4.1.1: {} + object-deep-merge@2.0.0: {} + object-inspect@1.13.4: {} + obug@2.1.1: {} ofetch@1.5.1: @@ -13537,6 +14010,10 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -13776,6 +14253,8 @@ snapshots: lru-cache: 11.3.5 minipass: 7.1.2 + path-to-regexp@8.4.2: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -13788,6 +14267,8 @@ snapshots: picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -14150,6 +14631,11 @@ snapshots: process@0.11.10: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + publint@0.3.21: dependencies: '@publint/pack': 0.1.4 @@ -14159,6 +14645,10 @@ snapshots: punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} quansync@1.0.0: {} @@ -14173,6 +14663,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.7 @@ -14349,6 +14846,16 @@ snapshots: rou3@0.8.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.0.0: {} run-parallel@1.2.0: @@ -14444,6 +14951,34 @@ snapshots: shell-quote@1.8.3: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -14539,6 +15074,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} std-env@4.1.0: {} @@ -14750,6 +15287,12 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.1 + type-level-regexp@0.1.17: {} typescript@6.0.3: {} @@ -14935,6 +15478,8 @@ snapshots: - '@emnapi/runtime' - vite + unpipe@1.0.0: {} + unplugin-utils@0.2.4: dependencies: pathe: 2.0.3 @@ -15057,6 +15602,8 @@ snapshots: dependencies: builtins: 5.1.0 + vary@1.1.2: {} + vite-dev-rpc@1.1.0(vite@8.0.13(@types/node@22.13.9)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.4)): dependencies: birpc: 2.9.0 @@ -15348,6 +15895,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + write-file-atomic@5.0.1: dependencies: imurmurhash: 0.1.4 @@ -15424,4 +15973,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e6404ed..bf891b4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,6 +31,7 @@ catalogs: unbuild: ^3.6.1 vite: ^8.0.13 deps: + '@modelcontextprotocol/sdk': ^1.29.0 ansis: ^4.3.0 cac: ^7.0.0 devframe: ^0.5.2 @@ -52,6 +53,7 @@ catalogs: tinyglobby: ^0.2.16 unconfig: ^7.5.0 unstorage: ^1.17.5 + valibot: ^1.4.1 dev: '@antfu/ni': ^30.1.0 '@nuxt/devtools': ^3.2.4 @@ -96,6 +98,7 @@ catalogs: testing: '@axe-core/playwright': ^4.11.3 '@playwright/test': ^1.60.0 + '@valibot/to-json-schema': ^1.7.0 mlly: ^1.8.2 type-fest: 4.41.0 vitest: ^4.1.6 diff --git a/skills/node-modules-inspector/SKILL.md b/skills/node-modules-inspector/SKILL.md new file mode 100644 index 0000000..7e90544 --- /dev/null +++ b/skills/node-modules-inspector/SKILL.md @@ -0,0 +1,196 @@ +--- +name: node-modules-inspector +description: > + Inspects a project's installed node_modules and produces three reports: + duplicated packages (installed in multiple versions), packages sorted by + install size, and maintenance actions (dep-upgrade opportunities + publint + findings, grouped by consumer/author). Use when the user wants to audit + dependencies, find duplicate packages, check what's taking up disk space in + node_modules, identify outdated peer/prod dependencies that newer dependents + could upgrade past, or list publint problems. Available as a CLI + (`npx node-modules-inspector report [--json]`) + or an MCP stdio server (`npx node-modules-inspector mcp`) exposing the same + three reports as agent tools. Works with pnpm, npm, and bun. +--- + +# node-modules-inspector + +`node-modules-inspector` is a CLI + MCP server that inspects the installed `node_modules` of the current project and produces structured reports. Three reports, same underlying analysis pipeline: + +| Report | Answers | +|---|---| +| `duplicates` | Which packages are installed in multiple versions? | +| `sizes` | Which packages take up the most disk space? | +| `maintainers` | Which consumers have dep-upgrade opportunities or publint issues, grouped by package and author? | + +Reports run against the real on-disk `node_modules` — no registry calls are required for the basic shape; npm metadata is fetched only to enrich the maintainers report (gated by config). + +Works with pnpm, npm, and bun. The default `npx node-modules-inspector` (with no subcommand) opens a Vue web UI for humans; agents should use the `report` and `mcp` subcommands below. + +## When to reach for this + +Trigger on any of: +- "audit my dependencies", "find duplicate packages", "node_modules cleanup" +- "what's taking up disk space in node_modules" +- "which deps are outdated" / "what dep-upgrade opportunities are there" +- "show me publint issues across my deps" +- "who maintains my dependencies" + +Don't reach for it for: registry-only questions (use `fast-npm-meta`), bundle-size analysis of a single package (use a bundler-specific tool), security audits (use `npm audit` / `osv-scanner`). + +## CLI mode + +All subcommands share these options: +- `--root ` — project root (default: cwd) +- `--config ` — config file (default: `node-modules-inspector.config.{ts,js,json}`) +- `--depth ` — max dependency depth to traverse (default: `8`) +- `--json` — emit JSON to stdout; pretty ANSI table otherwise + +Progress logs always go to stderr, so `... --json` is pipe-safe. + +### duplicates + +```sh +npx node-modules-inspector report duplicates --json +``` + +Options: +- `--min-versions ` — only include packages installed at this many versions or more (default: `2`) +- `--limit ` — cap result count + +Output shape: +```json +[ + { + "name": "@typescript-eslint/scope-manager", + "versions": ["8.56.1", "8.59.1", "8.59.2", "8.59.4"], + "specs": ["@typescript-eslint/scope-manager@8.56.1", "..."] + } +] +``` + +Versions are sorted ascending by semver. Entries are sorted by version-count descending. Use this to find dedupe targets — `pnpm dedupe` / `npm dedupe` resolves these where ranges overlap. + +### sizes + +```sh +npx node-modules-inspector report sizes --json --limit 20 +``` + +Options: +- `--limit ` — cap result count (default: `50`) +- `--include-workspace` — include workspace packages (default: excluded; they have no meaningful install size) + +Output shape: +```json +[ + { + "spec": "typescript@6.0.3", + "name": "typescript", + "version": "6.0.3", + "workspace": false, + "bytes": 24346827, + "categories": { + "js": { "bytes": 15344521, "count": 200 }, + "dts": { "bytes": 7002306, "count": 150 } + } + } +] +``` + +`categories` keys come from a fixed set: `js`, `ts`, `dts`, `json`, `bin`, `wasm`, `map`, `image`, `css`, `html`, `comp`, `doc`, `test`, `flow`, `other`. Entries are sorted by `bytes` descending. + +### maintainers + +```sh +npx node-modules-inspector report maintainers --json +``` + +Options: +- `--sort ` — sort by consumer depth, max migration ratio, or latest release time (default: `depth`) +- `--author ` — filter to consumers maintained by this author; repeatable +- `--no-publint` — exclude publint findings +- `--no-latest-only` — include consumer packages that are not on their latest major +- `--limit ` — cap result count + +Output shape: +```json +[ + { + "consumer": { "spec": "rollup-plugin-esbuild@6.2.1", "name": "rollup-plugin-esbuild", "version": "6.2.1", "depth": 1 }, + "authors": [{ "type": "github", "github": "egoist", "avatar": "..." }], + "items": [ + { + "kind": "dep-upgrade", + "depName": "unplugin-utils", + "depType": "prod", + "declaredRange": "^0.2.4", + "rawRange": "catalog:deps", + "catalogName": "deps", + "installedHighestVersion": "0.3.1", + "installedHighestSpec": "unplugin-utils@0.3.1", + "installedVersions": ["0.2.4", "0.3.1"], + "migratedCount": 10, + "totalCount": 11, + "migrationRatio": 0.909 + }, + { + "kind": "publint", + "messages": [/* publint Message objects */], + "counts": { "error": 0, "warning": 1, "suggestion": 2 } + } + ], + "maxMigrationRatio": 0.909, + "latestReleasedAt": 1739000000000 + } +] +``` + +How to read this: +- A `dep-upgrade` item means: this consumer declares `depName` at `declaredRange`, but there's a newer installed version (`installedHighestVersion`) that the range does not satisfy. `migrationRatio` is the fraction of consumers in the same cohort that already migrated — a high ratio (e.g. 0.9) means most other consumers already moved on, so this one is lagging. +- `rawRange` differs from `declaredRange` only when the consumer used a pnpm catalog reference (`catalog:deps`); `declaredRange` is the resolved range. +- A `publint` item carries the raw publint messages, partitioned by severity in `counts`. +- `authors` come from the consumer's `package.json` author/maintainers fields, with GitHub-handle detection. + +Publint findings only appear when `pkg.resolved.publint` was populated. Enable that by adding `publint: true` to `node-modules-inspector.config.ts` (or by using the project's web UI which runs publint async). + +## MCP mode + +```sh +npx node-modules-inspector mcp +``` + +Starts an MCP stdio server. Exposes three tools, identical surface to the CLI: + +- `nmi:report-duplicates` +- `nmi:report-sizes` +- `nmi:report-maintainers` + +When configured in an MCP client (e.g. Claude Code) under server name `node-modules-inspector`, address them as `node-modules-inspector:nmi:report-duplicates`, etc. + +Tool input schemas mirror the CLI options. Tool output is JSON in the exact shape shown above for each report. + +Prefer MCP when: +- Multiple queries are expected in one session — the dependency tree is read once and cached across tool calls. +- The agent needs structured output schemas to drive validation. + +Prefer the CLI (`report ... --json`) when shell-pipelining (`jq`, redirect, etc.) is more convenient. + +## Flags the agent should know + +- The first run reads `node_modules` end-to-end and caches npm metadata on disk (under `~/.node-modules-inspector` or similar). Subsequent runs are much faster. +- Workspace packages are excluded from `sizes` by default — pass `--include-workspace` if you actually want them. +- `--depth 8` is enough for almost all real projects. Increase only if the user explicitly asks about deeply-nested transitive dependencies. +- For very large monorepos, running against a single workspace package via `--root packages/` is faster than the whole repo. + +## Failure modes + +- "No package manager detected" — the project has no `node_modules` directory, or none of pnpm/npm/bun lockfiles. Suggest the user run install first. +- Empty `duplicates` result — fine, it means everything is deduped (mention `pnpm dedupe` etc. only if user wants to verify). +- Empty `maintainers` result — usually means there are no dep-upgrade opportunities AND `publint: true` is not set in the config; if the user expected publint output, point them at the config. + +## Web UI (skip for agent tasks) + +`npx node-modules-inspector` (no subcommand) starts a Vue dev server on port `9999` with a full visual explorer (graph view, filters, multi-version compare, maintainer-action dashboard). It's for humans; don't suggest it for an agent task. The `report` CLI and `mcp` server above cover the same data programmatically. + +`npx node-modules-inspector build` produces a static SPA of the analysis into `dist/__node-modules-inspector/` — useful for CI artifacts but not for agent consumption.