diff --git a/app/components/Terminal/Install.vue b/app/components/Terminal/Install.vue index 5fe249ea0..a2346ac42 100644 --- a/app/components/Terminal/Install.vue +++ b/app/components/Terminal/Install.vue @@ -1,11 +1,13 @@ @@ -131,6 +159,41 @@ const copyCreateCommand = () => copyCreate(getFullCreateCommand()) + + + + # {{ $t('package.get_started.dev_dependency_hint') }} + + + $ + {{ i > 0 ? ' ' : '' }}{{ part }} + + {{ + devInstallCopied ? $t('common.copied') : $t('common.copy') + }} + + + + { const pkg = getRouterParam(event, 'pkg') ?? '' - return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}` + return `analysis:v2:${pkg.replace(/\/+$/, '').trim()}` }, }, ) @@ -209,4 +215,5 @@ function hasSameRepositoryOwner( export interface PackageAnalysisResponse extends PackageAnalysis { package: string version: string + devDependencySuggestion: DevDependencySuggestion } diff --git a/shared/utils/dev-dependency.ts b/shared/utils/dev-dependency.ts new file mode 100644 index 000000000..f45449ba5 --- /dev/null +++ b/shared/utils/dev-dependency.ts @@ -0,0 +1,110 @@ +export type DevDependencySuggestionReason = 'known-package' | 'readme-hint' + +export interface DevDependencySuggestion { + recommended: boolean + reason?: DevDependencySuggestionReason +} + +const KNOWN_DEV_DEPENDENCY_PACKAGES = new Set([ + 'biome', + 'chai', + 'eslint', + 'esbuild', + 'husky', + 'jest', + 'lint-staged', + 'mocha', + 'oxc', + 'oxfmt', + 'oxlint', + 'playwright', + 'prettier', + 'rolldown', + 'rollup', + 'stylelint', + 'ts-jest', + 'ts-node', + 'tsx', + 'turbo', + 'typescript', + 'vite', + 'vitest', + 'webpack', +]) + +const KNOWN_DEV_DEPENDENCY_PACKAGE_PREFIXES = [ + '@typescript-eslint/', + 'eslint-', + 'prettier-', + 'vite-', + 'webpack-', + 'babel-', +] + +function isKnownDevDependencyPackage(packageName: string): boolean { + const normalized = packageName.toLowerCase() + if (normalized.startsWith('@types/')) { + return true + } + // Match scoped packages by name segment, e.g. @scope/eslint-config + const namePart = normalized.includes('/') ? normalized.split('/').pop() : normalized + if (!namePart) return false + + return ( + KNOWN_DEV_DEPENDENCY_PACKAGES.has(normalized) || + KNOWN_DEV_DEPENDENCY_PACKAGES.has(namePart) || + KNOWN_DEV_DEPENDENCY_PACKAGE_PREFIXES.some(prefix => + prefix.startsWith('@') ? normalized.startsWith(prefix) : namePart.startsWith(prefix), + ) + ) +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function hasReadmeDevInstallHint(packageName: string, readmeContent?: string | null): boolean { + if (!readmeContent) return false + + const escapedName = escapeRegExp(packageName) + const escapedNpmName = escapeRegExp(`npm:${packageName}`) + const packageSpec = `(?:${escapedName}|${escapedNpmName})(?:@[\\w.-]+)?` + + const patterns = [ + // npm install -D pkg / pnpm add --save-dev pkg + new RegExp( + String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+(?:--save-dev|--dev|-d)\s+${packageSpec}`, + 'i', + ), + // npm install pkg --save-dev / pnpm add pkg -D + new RegExp( + String.raw`(?:npm|pnpm|yarn|bun|vlt)\s+(?:install|add|i)\s+${packageSpec}\s+(?:--save-dev|--dev|-d)`, + 'i', + ), + // deno add -D npm:pkg + new RegExp(String.raw`deno\s+add\s+(?:--dev|-D)\s+${packageSpec}`, 'i'), + ] + + return patterns.some(pattern => pattern.test(readmeContent)) +} + +export function getDevDependencySuggestion( + packageName: string, + readmeContent?: string | null, +): DevDependencySuggestion { + if (isKnownDevDependencyPackage(packageName)) { + return { + recommended: true, + reason: 'known-package', + } + } + + if (hasReadmeDevInstallHint(packageName, readmeContent)) { + return { + recommended: true, + reason: 'readme-hint', + } + } + + return { recommended: false } +} diff --git a/shared/utils/package-analysis.ts b/shared/utils/package-analysis.ts index 864038bca..312739020 100644 --- a/shared/utils/package-analysis.ts +++ b/shared/utils/package-analysis.ts @@ -34,6 +34,8 @@ export interface ExtendedPackageJson { dependencies?: Record devDependencies?: Record peerDependencies?: Record + readme?: string + readmeFilename?: string /** npm maintainers (returned by registry API) */ maintainers?: Array<{ name: string; email?: string }> /** Repository info (returned by registry API) */ diff --git a/test/unit/app/utils/install-command.spec.ts b/test/unit/app/utils/install-command.spec.ts index bbe0cb9ad..fb35bb94d 100644 --- a/test/unit/app/utils/install-command.spec.ts +++ b/test/unit/app/utils/install-command.spec.ts @@ -5,6 +5,7 @@ import { getPackageSpecifier, getExecuteCommand, getExecuteCommandParts, + getDevDependencyFlag, } from '../../../../app/utils/install-command' import type { JsrPackageInfo } from '../../../../shared/types/jsr' @@ -124,6 +125,26 @@ describe('install command generation', () => { }) }) + describe('dev dependency installs', () => { + it.each([ + ['npm', 'npm install -D eslint'], + ['pnpm', 'pnpm add -D eslint'], + ['yarn', 'yarn add -D eslint'], + ['bun', 'bun add -d eslint'], + ['deno', 'deno add -D npm:eslint'], + ['vlt', 'vlt install -D eslint'], + ] as const)('%s → %s', (pm, expected) => { + expect( + getInstallCommand({ + packageName: 'eslint', + packageManager: pm, + jsrInfo: jsrNotAvailable, + dev: true, + }), + ).toBe(expected) + }) + }) + describe('scoped package on JSR without version', () => { it.each([ ['npm', 'npm install @trpc/server'], @@ -203,6 +224,16 @@ describe('install command generation', () => { expect(parts).toEqual(['npm', 'install', 'lodash@4.17.21']) }) + it('returns correct parts for npm with dev flag', () => { + const parts = getInstallCommandParts({ + packageName: 'eslint', + packageManager: 'npm', + jsrInfo: jsrNotAvailable, + dev: true, + }) + expect(parts).toEqual(['npm', 'install', '-D', 'eslint']) + }) + it('returns correct parts for deno with jsr: prefix when available', () => { const parts = getInstallCommandParts({ packageName: '@trpc/server', @@ -212,6 +243,16 @@ describe('install command generation', () => { expect(parts).toEqual(['deno', 'add', 'jsr:@trpc/server']) }) + it('returns correct parts for bun with lowercase dev flag', () => { + const parts = getInstallCommandParts({ + packageName: 'eslint', + packageManager: 'bun', + jsrInfo: jsrNotAvailable, + dev: true, + }) + expect(parts).toEqual(['bun', 'add', '-d', 'eslint']) + }) + it('returns correct parts for deno with npm: prefix when not on JSR', () => { const parts = getInstallCommandParts({ packageName: 'lodash', @@ -243,6 +284,14 @@ describe('install command generation', () => { }) }) + describe('getDevDependencyFlag', () => { + it('returns lowercase flag only for bun', () => { + expect(getDevDependencyFlag('bun')).toBe('-d') + expect(getDevDependencyFlag('npm')).toBe('-D') + expect(getDevDependencyFlag('deno')).toBe('-D') + }) + }) + describe('edge cases', () => { it('handles null jsrInfo same as not available for deno', () => { expect( diff --git a/test/unit/shared/utils/dev-dependency.spec.ts b/test/unit/shared/utils/dev-dependency.spec.ts new file mode 100644 index 000000000..b59d8e8dd --- /dev/null +++ b/test/unit/shared/utils/dev-dependency.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { getDevDependencySuggestion } from '../../../../shared/utils/dev-dependency' + +describe('getDevDependencySuggestion', () => { + it('suggests dev dependency for known tooling packages', () => { + expect(getDevDependencySuggestion('eslint')).toEqual({ + recommended: true, + reason: 'known-package', + }) + expect(getDevDependencySuggestion('@types/node')).toEqual({ + recommended: true, + reason: 'known-package', + }) + expect(getDevDependencySuggestion('@typescript-eslint/parser')).toEqual({ + recommended: true, + reason: 'known-package', + }) + }) + + it('suggests dev dependency from README install command hints', () => { + const readme = 'Install with npm install --save-dev some-tool' + + expect(getDevDependencySuggestion('some-tool', readme)).toEqual({ + recommended: true, + reason: 'readme-hint', + }) + }) + + it('suggests dev dependency from README --dev flag hints', () => { + const readme = 'yarn add --dev some-tool' + + expect(getDevDependencySuggestion('some-tool', readme)).toEqual({ + recommended: true, + reason: 'readme-hint', + }) + }) + + it('suggests dev dependency from README deno -D hints', () => { + const readme = 'deno add -D npm:some-tool' + + expect(getDevDependencySuggestion('some-tool', readme)).toEqual({ + recommended: true, + reason: 'readme-hint', + }) + }) + + it('does not suggest dev dependency for runtime packages without hints', () => { + expect(getDevDependencySuggestion('react')).toEqual({ + recommended: false, + }) + }) + + it('does not suggest when README hint targets a different package', () => { + const readme = 'Install with yarn add -D bar' + + expect(getDevDependencySuggestion('foo', readme)).toEqual({ + recommended: false, + }) + }) +})
{{ i > 0 ? ' ' : '' }}{{ part }}
Install with npm install --save-dev some-tool
npm install --save-dev some-tool
yarn add --dev some-tool
deno add -D npm:some-tool
Install with yarn add -D bar
yarn add -D bar