[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add binary run scripts to package page (#209)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Vincent Taverna
Daniel Roe
and committed by
GitHub
3c026bc0 34e90777

+1294 -102
+2 -6
app/components/ConnectorModal.vue
··· 6 6 7 7 const tokenInput = shallowRef('') 8 8 const portInput = shallowRef('31415') 9 - const copied = shallowRef(false) 9 + const { copied, copy } = useClipboard({ copiedDuring: 2000 }) 10 10 11 11 async function handleConnect() { 12 12 const port = Number.parseInt(portInput.value, 10) || 31415 ··· 26 26 if (portInput.value !== '31415') { 27 27 command += ` --port ${portInput.value}` 28 28 } 29 - navigator.clipboard.writeText(command) 30 - copied.value = true 31 - setTimeout(() => { 32 - copied.value = false 33 - }, 2000) 29 + copy(command) 34 30 } 35 31 36 32 const selectedPM = useSelectedPackageManager()
+2 -4
app/composables/useInstallCommand.ts
··· 76 76 }) 77 77 78 78 // Copy state 79 - const copied = ref(false) 79 + const { copied, copy } = useClipboard({ copiedDuring: 2000 }) 80 80 81 81 async function copyInstallCommand() { 82 82 if (!fullInstallCommand.value) return 83 - await navigator.clipboard.writeText(fullInstallCommand.value) 84 - copied.value = true 85 - setTimeout(() => (copied.value = false), 2000) 83 + await copy(fullInstallCommand.value) 86 84 } 87 85 88 86 return {
+2 -1
app/composables/usePackageAnalysis.ts
··· 1 - import type { ModuleFormat, TypesStatus } from '#shared/utils/package-analysis' 1 + import type { ModuleFormat, TypesStatus, CreatePackageInfo } from '#shared/utils/package-analysis' 2 2 3 3 export interface PackageAnalysisResponse { 4 4 package: string ··· 9 9 node?: string 10 10 npm?: string 11 11 } 12 + createPackage?: CreatePackageInfo 12 13 } 13 14 14 15 /**
+312 -15
app/pages/[...package].vue
··· 241 241 copyInstallCommand, 242 242 } = useInstallCommand(packageName, requestedVersion, jsrInfo, typesPackageName) 243 243 244 + // Executable detection for run command 245 + const executableInfo = computed(() => { 246 + if (!displayVersion.value || !pkg.value) return null 247 + return getExecutableInfo(pkg.value.name, displayVersion.value.bin) 248 + }) 249 + 250 + // Detect if package is binary-only (show only execute commands, no install) 251 + const isBinaryOnly = computed(() => { 252 + if (!displayVersion.value || !pkg.value) return false 253 + return isBinaryOnlyPackage({ 254 + name: pkg.value.name, 255 + bin: displayVersion.value.bin, 256 + main: displayVersion.value.main, 257 + module: displayVersion.value.module, 258 + exports: displayVersion.value.exports, 259 + }) 260 + }) 261 + 262 + // Detect if package uses create-* naming convention 263 + const isCreatePkg = computed(() => { 264 + if (!pkg.value) return false 265 + return isCreatePackage(pkg.value.name) 266 + }) 267 + 268 + // Run command parts for a specific command (local execute after install) 269 + function getRunParts(command?: string) { 270 + if (!pkg.value) return [] 271 + return getRunCommandParts({ 272 + packageName: pkg.value.name, 273 + packageManager: selectedPM.value, 274 + jsrInfo: jsrInfo.value, 275 + command, 276 + isBinaryOnly: false, // Local execute 277 + }) 278 + } 279 + 280 + // Execute command parts for binary-only packages (remote execute) 281 + const executeCommandParts = computed(() => { 282 + if (!pkg.value) return [] 283 + return getExecuteCommandParts({ 284 + packageName: pkg.value.name, 285 + packageManager: selectedPM.value, 286 + jsrInfo: jsrInfo.value, 287 + isBinaryOnly: true, 288 + isCreatePackage: isCreatePkg.value, 289 + }) 290 + }) 291 + 292 + // Full execute command string for copying 293 + const executeCommand = computed(() => { 294 + if (!pkg.value) return '' 295 + return getExecuteCommand({ 296 + packageName: pkg.value.name, 297 + packageManager: selectedPM.value, 298 + jsrInfo: jsrInfo.value, 299 + isBinaryOnly: true, 300 + isCreatePackage: isCreatePkg.value, 301 + }) 302 + }) 303 + 304 + // Copy execute command (for binary-only packages) 305 + const { copied: executeCopied, copy: copyExecute } = useClipboard({ copiedDuring: 2000 }) 306 + const copyExecuteCommand = () => copyExecute(executeCommand.value) 307 + 308 + // Get associated create-* package info (e.g., vite -> create-vite) 309 + const createPackageInfo = computed(() => { 310 + if (!packageAnalysis.value?.createPackage) return null 311 + // Don't show if deprecated 312 + if (packageAnalysis.value.createPackage.deprecated) return null 313 + return packageAnalysis.value.createPackage 314 + }) 315 + 316 + // Create command parts for associated create-* package 317 + const createCommandParts = computed(() => { 318 + if (!createPackageInfo.value) return [] 319 + const pm = packageManagers.find(p => p.id === selectedPM.value) 320 + if (!pm) return [] 321 + 322 + // Extract short name: create-vite -> vite 323 + const createPkgName = createPackageInfo.value.packageName 324 + let shortName: string 325 + if (createPkgName.startsWith('@')) { 326 + // @scope/create-foo -> foo 327 + const slashIndex = createPkgName.indexOf('/') 328 + const name = createPkgName.slice(slashIndex + 1) 329 + shortName = name.startsWith('create-') ? name.slice('create-'.length) : name 330 + } else { 331 + // create-vite -> vite 332 + shortName = createPkgName.startsWith('create-') 333 + ? createPkgName.slice('create-'.length) 334 + : createPkgName 335 + } 336 + 337 + return [...pm.create.split(' '), shortName] 338 + }) 339 + 340 + // Full create command string for copying 341 + const createCommand = computed(() => { 342 + return createCommandParts.value.join(' ') 343 + }) 344 + 345 + // Copy create command 346 + const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 }) 347 + const copyCreateCommand = () => copyCreate(createCommand.value) 348 + 349 + // Primary run command parts 350 + const runCommandParts = computed(() => { 351 + if (!executableInfo.value?.hasExecutable) return [] 352 + return getRunParts(executableInfo.value.primaryCommand) 353 + }) 354 + 355 + // Full run command string for copying 356 + function getFullRunCommand(command?: string) { 357 + if (!pkg.value) return '' 358 + return getRunCommand({ 359 + packageName: pkg.value.name, 360 + packageManager: selectedPM.value, 361 + jsrInfo: jsrInfo.value, 362 + command, 363 + }) 364 + } 365 + 366 + // Copy run command 367 + const { copied: runCopied, copy: copyRun } = useClipboard({ copiedDuring: 2000 }) 368 + const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command)) 369 + 244 370 // Expandable description 245 371 const descriptionExpanded = ref(false) 246 372 const descriptionRef = useTemplateRef('descriptionRef') ··· 684 810 class="area-vulns" 685 811 /> 686 812 687 - <!-- Install command with package manager selector --> 688 - <section id="install" aria-labelledby="install-heading" class="area-install scroll-mt-20"> 813 + <!-- Binary-only packages: Show only execute command (no install) --> 814 + <section v-if="isBinaryOnly" aria-labelledby="run-heading" class="area-install"> 815 + <div class="flex flex-wrap items-center justify-between mb-3"> 816 + <h2 id="run-heading" class="text-xs text-fg-subtle uppercase tracking-wider">Run</h2> 817 + <!-- Package manager tabs --> 818 + <div 819 + class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md" 820 + role="tablist" 821 + aria-label="Package manager" 822 + > 823 + <ClientOnly> 824 + <button 825 + v-for="pm in packageManagers" 826 + :key="pm.id" 827 + role="tab" 828 + :aria-selected="selectedPM === pm.id" 829 + class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 inline-flex items-center gap-1.5" 830 + :class=" 831 + selectedPM === pm.id 832 + ? 'bg-bg shadow text-fg border-border' 833 + : 'text-fg-subtle hover:text-fg border-transparent' 834 + " 835 + @click="selectedPM = pm.id" 836 + > 837 + <span class="inline-block h-3 w-3" :class="pm.icon" aria-hidden="true" /> 838 + {{ pm.label }} 839 + </button> 840 + <template #fallback> 841 + <span 842 + v-for="pm in packageManagers" 843 + :key="pm.id" 844 + class="px-2 py-1 font-mono text-xs rounded" 845 + :class="pm.id === 'npm' ? 'bg-bg-elevated text-fg' : 'text-fg-subtle'" 846 + > 847 + {{ pm.label }} 848 + </span> 849 + </template> 850 + </ClientOnly> 851 + </div> 852 + </div> 853 + <div class="relative group"> 854 + <!-- Terminal-style execute command --> 855 + <div class="bg-bg-subtle border border-border rounded-lg overflow-hidden"> 856 + <div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3"> 857 + <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 858 + <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 859 + <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 860 + </div> 861 + <div class="px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 space-y-1"> 862 + <!-- Execute command --> 863 + <div class="flex items-center gap-2 group/executecmd"> 864 + <span class="text-fg-subtle font-mono text-sm select-none">$</span> 865 + <code class="font-mono text-sm" 866 + ><ClientOnly 867 + ><span 868 + v-for="(part, i) in executeCommandParts" 869 + :key="i" 870 + :class="i === 0 ? 'text-fg' : 'text-fg-muted'" 871 + >{{ i > 0 ? ' ' : '' }}{{ part }}</span 872 + ><template #fallback 873 + ><span class="text-fg">npx</span 874 + ><span class="text-fg-muted"> {{ pkg.name }}</span></template 875 + ></ClientOnly 876 + ></code 877 + > 878 + <button 879 + type="button" 880 + class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/executecmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 881 + @click.stop="copyExecuteCommand" 882 + > 883 + {{ executeCopied ? 'copied!' : 'copy' }} 884 + </button> 885 + </div> 886 + </div> 887 + </div> 888 + </div> 889 + </section> 890 + 891 + <!-- Regular packages: Install command with optional run command --> 892 + <section 893 + v-else 894 + id="install" 895 + aria-labelledby="install-heading" 896 + class="area-install scroll-mt-20" 897 + > 689 898 <div class="flex flex-wrap items-center justify-between mb-3"> 690 899 <h2 id="install-heading" class="group text-xs text-fg-subtle uppercase tracking-wider"> 691 900 <a 692 901 href="#install" 693 - class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 902 + class="inline-flex items-center gap-1.5 py-1 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 694 903 > 695 904 {{ $t('package.install.title') }} 696 905 <span ··· 711 920 :key="pm.id" 712 921 role="tab" 713 922 :aria-selected="selectedPM === pm.id" 714 - class="px-2 py-1 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 inline-flex items-center gap-1.5" 923 + class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 inline-flex items-center gap-1.5" 715 924 :class=" 716 925 selectedPM === pm.id 717 926 ? 'bg-bg shadow text-fg border-border' ··· 743 952 <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 744 953 <span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" /> 745 954 </div> 746 - <div class="space-y-1 px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 overflow-x-auto"> 747 - <!-- Main package install --> 748 - <div class="flex items-center gap-2 min-w-0"> 955 + <div class="px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 space-y-1 overflow-x-auto"> 956 + <!-- Install command --> 957 + <div class="flex items-center gap-2 group/installcmd min-w-0"> 749 958 <span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span> 750 959 <code class="font-mono text-sm min-w-0" 751 960 ><ClientOnly ··· 760 969 ></ClientOnly 761 970 ></code 762 971 > 972 + <button 973 + type="button" 974 + class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/installcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 975 + :aria-label="$t('package.install.copy_command')" 976 + @click.stop="copyInstallCommand" 977 + > 978 + <span aria-live="polite">{{ 979 + copied ? $t('common.copied') : $t('common.copy') 980 + }}</span> 981 + </button> 763 982 </div> 983 + 764 984 <!-- @types package install (when enabled) --> 765 985 <div v-if="showTypesInInstall" class="flex items-center gap-2 min-w-0"> 766 986 <span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span> ··· 782 1002 <span class="sr-only">View {{ typesPackageName }}</span> 783 1003 </NuxtLink> 784 1004 </div> 1005 + 1006 + <!-- Run command (only if package has executables) --> 1007 + <template v-if="executableInfo?.hasExecutable"> 1008 + <!-- Comment line --> 1009 + <div class="flex items-center gap-2 pt-1"> 1010 + <span class="text-fg-subtle font-mono text-sm select-none" 1011 + ># {{ $t('package.run.locally') }}</span 1012 + > 1013 + </div> 1014 + 1015 + <!-- Primary run command --> 1016 + <div class="flex items-center gap-2 group/runcmd"> 1017 + <span class="text-fg-subtle font-mono text-sm select-none">$</span> 1018 + <code class="font-mono text-sm" 1019 + ><ClientOnly 1020 + ><span 1021 + v-for="(part, i) in runCommandParts" 1022 + :key="i" 1023 + :class="i === 0 ? 'text-fg' : 'text-fg-muted'" 1024 + >{{ i > 0 ? ' ' : '' }}{{ part }}</span 1025 + ><template #fallback 1026 + ><span class="text-fg">npx</span>{{ ' ' 1027 + }}<span class="text-fg-muted">{{ 1028 + executableInfo?.primaryCommand 1029 + }}</span></template 1030 + ></ClientOnly 1031 + ></code 1032 + > 1033 + <button 1034 + type="button" 1035 + class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/runcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 1036 + @click.stop="copyRunCommand(executableInfo?.primaryCommand)" 1037 + > 1038 + {{ runCopied ? $t('common.copied') : $t('common.copy') }} 1039 + </button> 1040 + </div> 1041 + </template> 1042 + 1043 + <!-- Create command (for packages with associated create-* package) --> 1044 + <template v-if="createPackageInfo"> 1045 + <!-- Comment line --> 1046 + <div class="flex items-center gap-2 pt-1"> 1047 + <span class="text-fg-subtle font-mono text-sm select-none" 1048 + ># {{ $t('package.create.title') }}</span 1049 + > 1050 + </div> 1051 + 1052 + <!-- Create command --> 1053 + <div class="flex items-center gap-2 group/createcmd"> 1054 + <span class="text-fg-subtle font-mono text-sm select-none">$</span> 1055 + <code class="font-mono text-sm" 1056 + ><ClientOnly 1057 + ><span 1058 + v-for="(part, i) in createCommandParts" 1059 + :key="i" 1060 + :class="i === 0 ? 'text-fg' : 'text-fg-muted'" 1061 + >{{ i > 0 ? ' ' : '' }}{{ part }}</span 1062 + ><template #fallback 1063 + ><span class="text-fg">npm</span 1064 + ><span class="text-fg-muted"> 1065 + create {{ createPackageInfo.packageName.replace('create-', '') }}</span 1066 + ></template 1067 + ></ClientOnly 1068 + ></code 1069 + > 1070 + <button 1071 + type="button" 1072 + class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/createcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 1073 + :aria-label="$t('package.create.copy_command')" 1074 + @click.stop="copyCreateCommand" 1075 + > 1076 + <span aria-live="polite">{{ 1077 + createCopied ? $t('common.copied') : $t('common.copy') 1078 + }}</span> 1079 + </button> 1080 + <NuxtLink 1081 + :to="`/${createPackageInfo.packageName}`" 1082 + class="text-fg-subtle hover:text-fg-muted text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 1083 + :title="`View ${createPackageInfo.packageName}`" 1084 + > 1085 + <span class="i-carbon-arrow-right w-3 h-3" aria-hidden="true" /> 1086 + <span class="sr-only">View {{ createPackageInfo.packageName }}</span> 1087 + </NuxtLink> 1088 + </div> 1089 + </template> 785 1090 </div> 786 1091 </div> 787 - <button 788 - type="button" 789 - class="absolute top-3 right-3 px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 hover:(text-fg border-border-hover) active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 790 - :aria-label="$t('package.install.copy_command')" 791 - @click="copyInstallCommand" 792 - > 793 - <span aria-live="polite">{{ copied ? $t('common.copied') : $t('common.copy') }}</span> 794 - </button> 795 1092 </div> 796 1093 </section> 797 1094
+5 -4
app/pages/code/[...path].vue
··· 228 228 } 229 229 230 230 // Copy link to current line(s) 231 - async function copyPermalink() { 231 + const { copied: permalinkCopied, copy: copyPermalink } = useClipboard({ copiedDuring: 2000 }) 232 + function copyPermalinkUrl() { 232 233 const url = new URL(window.location.href) 233 - await navigator.clipboard.writeText(url.toString()) 234 + copyPermalink(url.toString()) 234 235 } 235 236 236 237 // Canonical URL for this code page ··· 373 374 v-if="selectedLines" 374 375 type="button" 375 376 class="px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded hover:text-fg hover:border-border-hover transition-colors" 376 - @click="copyPermalink" 377 + @click="copyPermalinkUrl" 377 378 > 378 - {{ $t('code.copy_link') }} 379 + {{ permalinkCopied ? $t('common.copied') : $t('code.copy_link') }} 379 380 </button> 380 381 <a 381 382 :href="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`"
+52 -9
app/utils/install-command.ts
··· 1 1 import type { JsrPackageInfo } from '#shared/types/jsr' 2 + import { getCreateShortName } from '#shared/utils/package-analysis' 2 3 3 4 // @unocss-include 4 5 export const packageManagers = [ ··· 6 7 id: 'npm', 7 8 label: 'npm', 8 9 action: 'install', 9 - execute: 'npx', 10 + executeLocal: 'npx', 11 + executeRemote: 'npx', 12 + create: 'npm create', 10 13 icon: 'i-simple-icons:npm', 11 14 }, 12 15 { 13 16 id: 'pnpm', 14 17 label: 'pnpm', 15 18 action: 'add', 16 - execute: 'pnpm dlx', 19 + executeLocal: 'pnpm exec', 20 + executeRemote: 'pnpm dlx', 21 + create: 'pnpm create', 17 22 icon: 'i-simple-icons:pnpm', 18 23 }, 19 24 { 20 25 id: 'yarn', 21 26 label: 'yarn', 22 27 action: 'add', 23 - execute: 'yarn dlx', 28 + executeLocal: 'yarn', 29 + executeRemote: 'yarn dlx', 30 + create: 'yarn create', 24 31 icon: 'i-simple-icons:yarn', 25 32 }, 26 - { id: 'bun', label: 'bun', action: 'add', execute: 'bunx', icon: 'i-simple-icons:bun' }, 33 + { 34 + id: 'bun', 35 + label: 'bun', 36 + action: 'add', 37 + executeLocal: 'bunx', 38 + executeRemote: 'bunx', 39 + create: 'bun create', 40 + icon: 'i-simple-icons:bun', 41 + }, 27 42 { 28 43 id: 'deno', 29 44 label: 'deno', 30 45 action: 'add', 31 - execute: 'deno run', 46 + executeLocal: 'deno run', 47 + executeRemote: 'deno run', 48 + create: 'deno run', 32 49 icon: 'i-simple-icons:deno', 33 50 }, 34 - { id: 'vlt', label: 'vlt', action: 'install', execute: 'vlt x', icon: 'i-custom-vlt' }, 51 + { 52 + id: 'vlt', 53 + label: 'vlt', 54 + action: 'install', 55 + executeLocal: 'vlt x', 56 + executeRemote: 'vlt x', 57 + create: 'vlt x', 58 + icon: 'i-custom-vlt', 59 + }, 35 60 ] as const 36 61 37 62 export type PackageManagerId = (typeof packageManagers)[number]['id'] ··· 85 110 return [pm.label, pm.action, `${spec}${version}`] 86 111 } 87 112 88 - export function getExecuteCommand(options: InstallCommandOptions): string { 113 + export interface ExecuteCommandOptions extends InstallCommandOptions { 114 + /** Whether this is a binary-only package (download & run vs local run) */ 115 + isBinaryOnly?: boolean 116 + /** Whether this is a create-* package (uses shorthand create command) */ 117 + isCreatePackage?: boolean 118 + } 119 + 120 + export function getExecuteCommand(options: ExecuteCommandOptions): string { 89 121 return getExecuteCommandParts(options).join(' ') 90 122 } 91 123 92 - export function getExecuteCommandParts(options: InstallCommandOptions): string[] { 124 + export function getExecuteCommandParts(options: ExecuteCommandOptions): string[] { 93 125 const pm = packageManagers.find(p => p.id === options.packageManager) 94 126 if (!pm) return [] 95 - return [pm.execute, getPackageSpecifier(options)] 127 + 128 + // For create-* packages, use the shorthand create command 129 + if (options.isCreatePackage) { 130 + const shortName = getCreateShortName(options.packageName) 131 + if (shortName !== options.packageName) { 132 + return [...pm.create.split(' '), shortName] 133 + } 134 + } 135 + 136 + // Choose remote or local execute based on package type 137 + const executeCmd = options.isBinaryOnly ? pm.executeRemote : pm.executeLocal 138 + return [...executeCmd.split(' '), getPackageSpecifier(options)] 96 139 }
+153
app/utils/run-command.ts
··· 1 + import type { JsrPackageInfo } from '#shared/types/jsr' 2 + import { getPackageSpecifier, packageManagers } from './install-command' 3 + import type { PackageManagerId } from './install-command' 4 + 5 + /** 6 + * Metadata needed to determine if a package is binary-only. 7 + */ 8 + export interface PackageMetadata { 9 + name: string 10 + bin?: string | Record<string, string> 11 + main?: string 12 + module?: unknown 13 + exports?: unknown 14 + } 15 + 16 + /** 17 + * Determine if a package is "binary-only" (executable without library entry points). 18 + * Binary-only packages should show execute commands without install commands. 19 + * 20 + * A package is binary-only if: 21 + * - Name starts with "create-" (e.g., create-vite) 22 + * - Scoped name contains "/create-" (e.g., @vue/create-app) 23 + * - Has bin field but no main, module, or exports fields 24 + */ 25 + export function isBinaryOnlyPackage(pkg: PackageMetadata): boolean { 26 + const baseName = pkg.name.startsWith('@') ? pkg.name.split('/')[1] : pkg.name 27 + 28 + // Check create-* patterns 29 + if (baseName?.startsWith('create-') || pkg.name.includes('/create-')) { 30 + return true 31 + } 32 + 33 + // Has bin but no entry points 34 + const hasBin = 35 + pkg.bin !== undefined && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0) 36 + const hasEntryPoint = !!pkg.main || !!pkg.module || !!pkg.exports 37 + 38 + return hasBin && !hasEntryPoint 39 + } 40 + 41 + /** 42 + * Check if a package uses the create-* naming convention. 43 + */ 44 + export function isCreatePackage(packageName: string): boolean { 45 + const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName 46 + return baseName?.startsWith('create-') || packageName.includes('/create-') || false 47 + } 48 + 49 + /** 50 + * Information about executable commands provided by a package. 51 + */ 52 + export interface ExecutableInfo { 53 + /** Primary command name (typically the package name or first bin key) */ 54 + primaryCommand: string 55 + /** All available command names */ 56 + commands: string[] 57 + /** Whether this package has any executables */ 58 + hasExecutable: boolean 59 + } 60 + 61 + /** 62 + * Extract executable command information from a package's bin field. 63 + * Handles both string format ("bin": "./cli.js") and object format ("bin": { "cmd": "./cli.js" }). 64 + */ 65 + export function getExecutableInfo( 66 + packageName: string, 67 + bin: string | Record<string, string> | undefined, 68 + ): ExecutableInfo { 69 + if (!bin) { 70 + return { primaryCommand: '', commands: [], hasExecutable: false } 71 + } 72 + 73 + // String format: package name becomes the command 74 + if (typeof bin === 'string') { 75 + return { 76 + primaryCommand: packageName, 77 + commands: [packageName], 78 + hasExecutable: true, 79 + } 80 + } 81 + 82 + // Object format: keys are command names 83 + const commands = Object.keys(bin) 84 + const firstCommand = commands[0] 85 + if (!firstCommand) { 86 + return { primaryCommand: '', commands: [], hasExecutable: false } 87 + } 88 + 89 + // Prefer command matching package name if it exists, otherwise use first 90 + const baseName = packageName.startsWith('@') ? packageName.split('/')[1] : packageName 91 + const primaryCommand = baseName && commands.includes(baseName) ? baseName : firstCommand 92 + 93 + return { 94 + primaryCommand, 95 + commands, 96 + hasExecutable: true, 97 + } 98 + } 99 + 100 + export interface RunCommandOptions { 101 + packageName: string 102 + packageManager: PackageManagerId 103 + version?: string | null 104 + jsrInfo?: JsrPackageInfo | null 105 + /** Specific command to run (for packages with multiple bin entries) */ 106 + command?: string 107 + /** Whether this is a binary-only package (affects which execute command to use) */ 108 + isBinaryOnly?: boolean 109 + } 110 + 111 + /** 112 + * Generate run command as an array of parts. 113 + * First element is the package manager label (e.g., "pnpm"), rest are arguments. 114 + * For example: ["pnpm", "exec", "eslint"] or ["pnpm", "dlx", "create-vite"] 115 + */ 116 + export function getRunCommandParts(options: RunCommandOptions): string[] { 117 + const pm = packageManagers.find(p => p.id === options.packageManager) 118 + if (!pm) return [] 119 + 120 + const spec = getPackageSpecifier(options) 121 + 122 + // Choose execute command based on package type 123 + const executeCmd = options.isBinaryOnly ? pm.executeRemote : pm.executeLocal 124 + const executeParts = executeCmd.split(' ') 125 + 126 + // For deno, always use the package specifier 127 + if (options.packageManager === 'deno') { 128 + return [...executeParts, spec] 129 + } 130 + 131 + // For local execute with specific command name different from package name 132 + // e.g., `pnpm exec tsc` for typescript package 133 + if (options.command && options.command !== options.packageName) { 134 + const baseName = options.packageName.startsWith('@') 135 + ? options.packageName.split('/')[1] 136 + : options.packageName 137 + // If command matches base package name, use the package spec 138 + if (options.command === baseName) { 139 + return [...executeParts, spec] 140 + } 141 + // Otherwise use the command name directly 142 + return [...executeParts, options.command] 143 + } 144 + 145 + return [...executeParts, spec] 146 + } 147 + 148 + /** 149 + * Generate the full run command for a package. 150 + */ 151 + export function getRunCommand(options: RunCommandOptions): string { 152 + return getRunCommandParts(options).join(' ') 153 + }
+8
i18n/locales/en.json
··· 113 113 "copy_command": "Copy install command", 114 114 "view_types": "View {package}" 115 115 }, 116 + "create": { 117 + "title": "Create new project", 118 + "copy_command": "Copy create command" 119 + }, 120 + "run": { 121 + "title": "Run", 122 + "locally": "Run locally" 123 + }, 116 124 "readme": { 117 125 "title": "Readme", 118 126 "no_readme": "No README available.",
+8
i18n/locales/zh-CN.json
··· 112 112 "copy_command": "复制安装命令", 113 113 "view_types": "查看 {package}" 114 114 }, 115 + "create": { 116 + "title": "创建新项目", 117 + "copy_command": "复制创建命令" 118 + }, 119 + "run": { 120 + "title": "运行", 121 + "locally": "本地运行" 122 + }, 115 123 "readme": { 116 124 "title": "Readme", 117 125 "no_readme": "没有可用的 README。",
+102 -1
server/api/registry/analysis/[...pkg].get.ts
··· 4 4 PackageAnalysis, 5 5 ExtendedPackageJson, 6 6 TypesPackageInfo, 7 + CreatePackageInfo, 7 8 } from '#shared/utils/package-analysis' 8 9 import { 9 10 analyzePackage, 10 11 getTypesPackageName, 12 + getCreatePackageName, 11 13 hasBuiltInTypes, 12 14 } from '#shared/utils/package-analysis' 13 15 import { ··· 15 17 CACHE_MAX_AGE_ONE_DAY, 16 18 ERROR_PACKAGE_ANALYSIS_FAILED, 17 19 } from '#shared/utils/constants' 20 + import { parseRepoUrl } from '#shared/utils/git-providers' 18 21 19 22 /** Minimal packument data needed to check deprecation status */ 20 23 interface MinimalPackument { ··· 51 54 typesPackage = await fetchTypesPackageInfo(typesPkgName) 52 55 } 53 56 54 - const analysis = analyzePackage(pkg, { typesPackage }) 57 + // Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app) 58 + // Only show if the packages are actually associated (same maintainers or same org) 59 + const createPackage = await findAssociatedCreatePackage(packageName, pkg) 60 + 61 + const analysis = analyzePackage(pkg, { typesPackage, createPackage }) 55 62 56 63 return { 57 64 package: packageName, ··· 108 115 } catch { 109 116 return undefined 110 117 } 118 + } 119 + 120 + /** Package metadata needed for association validation */ 121 + interface PackageWithMeta { 122 + maintainers?: Array<{ name: string }> 123 + repository?: { url?: string } 124 + deprecated?: string 125 + } 126 + 127 + /** 128 + * Get all possible create-* package name patterns for a given package. 129 + * e.g., "next" -> ["create-next", "create-next-app"] 130 + * e.g., "@scope/foo" -> ["@scope/create-foo", "@scope/create-foo-app"] 131 + */ 132 + function getCreatePackageNameCandidates(packageName: string): string[] { 133 + const baseName = getCreatePackageName(packageName) 134 + return [baseName, `${baseName}-app`] 135 + } 136 + 137 + /** 138 + * Find an associated create-* package by trying multiple naming patterns in parallel. 139 + * Returns the first associated package found (preferring create-{name} over create-{name}-app). 140 + */ 141 + async function findAssociatedCreatePackage( 142 + packageName: string, 143 + basePkg: ExtendedPackageJson, 144 + ): Promise<CreatePackageInfo | undefined> { 145 + const candidates = getCreatePackageNameCandidates(packageName) 146 + const results = await Promise.all(candidates.map(name => fetchCreatePackageInfo(name, basePkg))) 147 + return results.find(r => r !== undefined) 148 + } 149 + 150 + /** 151 + * Fetch create-* package info including deprecation status. 152 + * Validates that the create-* package is actually associated with the base package. 153 + * Returns undefined if the package doesn't exist or isn't associated. 154 + */ 155 + async function fetchCreatePackageInfo( 156 + createPkgName: string, 157 + basePkg: ExtendedPackageJson, 158 + ): Promise<CreatePackageInfo | undefined> { 159 + try { 160 + const encodedName = encodePackageName(createPkgName) 161 + // Fetch /latest to get maintainers and repository for association validation 162 + const createPkg = await $fetch<PackageWithMeta>(`${NPM_REGISTRY}/${encodedName}/latest`) 163 + 164 + // Validate that the packages are actually associated 165 + if (!isAssociatedPackage(basePkg, createPkg)) { 166 + return undefined 167 + } 168 + 169 + return { 170 + packageName: createPkgName, 171 + deprecated: createPkg.deprecated, 172 + } 173 + } catch { 174 + return undefined 175 + } 176 + } 177 + 178 + /** 179 + * Check if two packages are associated (share maintainers or same repo owner). 180 + */ 181 + function isAssociatedPackage( 182 + basePkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } }, 183 + createPkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } }, 184 + ): boolean { 185 + const baseMaintainers = new Set(basePkg.maintainers?.map(m => m.name.toLowerCase()) ?? []) 186 + const createMaintainers = createPkg.maintainers?.map(m => m.name.toLowerCase()) ?? [] 187 + const hasSharedMaintainer = createMaintainers.some(name => baseMaintainers.has(name)) 188 + 189 + return ( 190 + hasSharedMaintainer || 191 + hasSameRepositoryOwner(basePkg.repository?.url, createPkg.repository?.url) 192 + ) 193 + } 194 + 195 + /** 196 + * Check if two repository URLs have the same owner (works with any git provider). 197 + */ 198 + function hasSameRepositoryOwner( 199 + baseRepoUrl: string | undefined, 200 + createRepoUrl: string | undefined, 201 + ): boolean { 202 + if (!baseRepoUrl || !createRepoUrl) return false 203 + 204 + const baseRef = parseRepoUrl(baseRepoUrl) 205 + const createRef = parseRepoUrl(createRepoUrl) 206 + 207 + if (!baseRef || !createRef) return false 208 + if (baseRef.provider !== createRef.provider) return false 209 + if (baseRef.host && createRef.host && baseRef.host !== createRef.host) return false 210 + 211 + return baseRef.owner.toLowerCase() === createRef.owner.toLowerCase() 111 212 } 112 213 113 214 export interface PackageAnalysisResponse extends PackageAnalysis {
+49 -4
shared/utils/package-analysis.ts
··· 16 16 node?: string 17 17 npm?: string 18 18 } 19 + /** Associated create-* package if it exists */ 20 + createPackage?: CreatePackageInfo 19 21 } 20 22 21 23 /** ··· 35 37 dependencies?: Record<string, string> 36 38 devDependencies?: Record<string, string> 37 39 peerDependencies?: Record<string, string> 40 + /** npm maintainers (returned by registry API) */ 41 + maintainers?: Array<{ name: string; email?: string }> 42 + /** Repository info (returned by registry API) */ 43 + repository?: { url?: string; type?: string; directory?: string } 38 44 } 39 45 40 46 export type PackageExports = string | null | { [key: string]: PackageExports } | PackageExports[] ··· 169 175 target.hasTypes = target.hasTypes || source.hasTypes 170 176 } 171 177 178 + /** Info about a related package (@types or create-*) */ 179 + export interface RelatedPackageInfo { 180 + packageName: string 181 + deprecated?: string 182 + } 183 + 184 + export type TypesPackageInfo = RelatedPackageInfo 185 + export type CreatePackageInfo = RelatedPackageInfo 186 + 172 187 /** 173 - * Options for @types package info 188 + * Get the create-* package name for a given package. 189 + * e.g., "vite" -> "create-vite", "@scope/foo" -> "@scope/create-foo" 190 + */ 191 + export function getCreatePackageName(packageName: string): string { 192 + if (packageName.startsWith('@')) { 193 + // Scoped package: @scope/name -> @scope/create-name 194 + const slashIndex = packageName.indexOf('/') 195 + const scope = packageName.slice(0, slashIndex) 196 + const name = packageName.slice(slashIndex + 1) 197 + return `${scope}/create-${name}` 198 + } 199 + return `create-${packageName}` 200 + } 201 + 202 + /** 203 + * Extract the short name from a create-* package for display. 204 + * e.g., "create-vite" -> "vite", "@scope/create-foo" -> "foo" 174 205 */ 175 - export interface TypesPackageInfo { 176 - packageName: string 177 - deprecated?: string 206 + export function getCreateShortName(createPackageName: string): string { 207 + if (createPackageName.startsWith('@')) { 208 + // @scope/create-foo -> foo 209 + const slashIndex = createPackageName.indexOf('/') 210 + const name = createPackageName.slice(slashIndex + 1) 211 + if (name.startsWith('create-')) { 212 + return name.slice('create-'.length) 213 + } 214 + return name 215 + } 216 + // create-vite -> vite 217 + if (createPackageName.startsWith('create-')) { 218 + return createPackageName.slice('create-'.length) 219 + } 220 + return createPackageName 178 221 } 179 222 180 223 /** ··· 246 289 */ 247 290 export interface AnalyzePackageOptions { 248 291 typesPackage?: TypesPackageInfo 292 + createPackage?: CreatePackageInfo 249 293 } 250 294 251 295 /** ··· 268 312 npm: pkg.engines.npm, 269 313 } 270 314 : undefined, 315 + createPackage: options?.createPackage, 271 316 } 272 317 }
+10 -9
test/nuxt/composables/use-install-command.spec.ts
··· 261 261 262 262 describe('copyInstallCommand', () => { 263 263 it('should copy command to clipboard and set copied state', async () => { 264 - const writeText = vi.fn().mockResolvedValue(undefined) 265 - vi.stubGlobal('navigator', { clipboard: { writeText } }) 264 + vi.useFakeTimers() 266 265 267 266 const { copyInstallCommand, copied, fullInstallCommand } = useInstallCommand( 268 267 'vue', ··· 271 270 null, 272 271 ) 273 272 273 + expect(fullInstallCommand.value).toBe('npm install vue') 274 274 expect(copied.value).toBe(false) 275 + 275 276 await copyInstallCommand() 276 277 277 - expect(writeText).toHaveBeenCalledWith(fullInstallCommand.value) 278 + // useClipboard sets copied to true after successful copy 278 279 expect(copied.value).toBe(true) 279 280 280 - // Wait for the timeout to reset copied 281 - await new Promise(resolve => setTimeout(resolve, 2100)) 281 + // Advance timers to reset copied (copiedDuring: 2000) 282 + await vi.advanceTimersByTimeAsync(2100) 282 283 expect(copied.value).toBe(false) 284 + 285 + vi.useRealTimers() 283 286 }) 284 287 285 288 it('should not copy when command is empty', async () => { 286 - const writeText = vi.fn().mockResolvedValue(undefined) 287 - vi.stubGlobal('navigator', { clipboard: { writeText } }) 288 - 289 289 const { copyInstallCommand, copied } = useInstallCommand(null, null, null, null) 290 290 291 + expect(copied.value).toBe(false) 291 292 await copyInstallCommand() 292 293 293 - expect(writeText).not.toHaveBeenCalled() 294 + // Should remain false since there was nothing to copy 294 295 expect(copied.value).toBe(false) 295 296 }) 296 297 })
+90 -27
test/unit/install-command.spec.ts
··· 287 287 }) 288 288 }) 289 289 290 - describe('getExecuteCommand', () => { 291 - it('returns correct execute command for npm', () => { 292 - const command = getExecuteCommand({ 293 - packageName: 'esbuild', 294 - packageManager: 'npm', 295 - jsrInfo: jsrNotAvailable, 290 + describe('getExecuteCommandParts', () => { 291 + describe('local execute (isBinaryOnly: false)', () => { 292 + it.each([ 293 + ['npm', ['npx', 'eslint']], 294 + ['pnpm', ['pnpm', 'exec', 'eslint']], 295 + ['yarn', ['yarn', 'eslint']], 296 + ['bun', ['bunx', 'eslint']], 297 + ['deno', ['deno', 'run', 'npm:eslint']], 298 + ['vlt', ['vlt', 'x', 'eslint']], 299 + ] as const)('%s → %s', (pm, expected) => { 300 + expect( 301 + getExecuteCommandParts({ 302 + packageName: 'eslint', 303 + packageManager: pm, 304 + isBinaryOnly: false, 305 + }), 306 + ).toEqual(expected) 296 307 }) 297 - expect(command).toBe('npx esbuild') 298 308 }) 299 309 300 - it('returns package manager specific execute command', () => { 301 - const command = getExecuteCommand({ 302 - packageName: 'esbuild', 303 - packageManager: 'pnpm', 304 - jsrInfo: jsrNotAvailable, 310 + describe('remote execute (isBinaryOnly: true)', () => { 311 + it.each([ 312 + ['npm', ['npx', 'degit']], 313 + ['pnpm', ['pnpm', 'dlx', 'degit']], 314 + ['yarn', ['yarn', 'dlx', 'degit']], 315 + ['bun', ['bunx', 'degit']], 316 + ['deno', ['deno', 'run', 'npm:degit']], 317 + ['vlt', ['vlt', 'x', 'degit']], 318 + ] as const)('%s → %s', (pm, expected) => { 319 + expect( 320 + getExecuteCommandParts({ 321 + packageName: 'degit', 322 + packageManager: pm, 323 + isBinaryOnly: true, 324 + }), 325 + ).toEqual(expected) 305 326 }) 306 - expect(command).toBe('pnpm dlx esbuild') 307 327 }) 308 - }) 309 328 310 - describe('getExecuteCommandParts', () => { 311 - it('returns correct parts for npm', () => { 312 - const parts = getExecuteCommandParts({ 313 - packageName: 'esbuild', 314 - packageManager: 'npm', 315 - jsrInfo: jsrNotAvailable, 329 + describe('create-* packages (isCreatePackage: true)', () => { 330 + it.each([ 331 + ['npm', ['npm', 'create', 'vite']], 332 + ['pnpm', ['pnpm', 'create', 'vite']], 333 + ['yarn', ['yarn', 'create', 'vite']], 334 + ['bun', ['bun', 'create', 'vite']], 335 + ['deno', ['deno', 'run', 'vite']], 336 + ['vlt', ['vlt', 'x', 'vite']], 337 + ] as const)('%s → %s', (pm, expected) => { 338 + expect( 339 + getExecuteCommandParts({ 340 + packageName: 'create-vite', 341 + packageManager: pm, 342 + isCreatePackage: true, 343 + }), 344 + ).toEqual(expected) 316 345 }) 317 - expect(parts).toEqual(['npx', 'esbuild']) 318 346 }) 319 347 320 - it('returns empty for unknown package manager', () => { 321 - const parts = getExecuteCommandParts({ 322 - packageName: 'esbuild', 323 - packageManager: 'unknown' as never, 324 - jsrInfo: jsrNotAvailable, 348 + describe('scoped create-* packages', () => { 349 + it('handles @scope/create-app pattern', () => { 350 + expect( 351 + getExecuteCommandParts({ 352 + packageName: '@vue/create-app', 353 + packageManager: 'npm', 354 + isCreatePackage: true, 355 + }), 356 + ).toEqual(['npm', 'create', 'app']) 325 357 }) 326 - expect(parts).toEqual([]) 358 + }) 359 + }) 360 + 361 + describe('getExecuteCommand', () => { 362 + it('generates full execute command string for local execute', () => { 363 + expect( 364 + getExecuteCommand({ 365 + packageName: 'eslint', 366 + packageManager: 'pnpm', 367 + isBinaryOnly: false, 368 + }), 369 + ).toBe('pnpm exec eslint') 370 + }) 371 + 372 + it('generates full execute command string for remote execute', () => { 373 + expect( 374 + getExecuteCommand({ 375 + packageName: 'degit', 376 + packageManager: 'pnpm', 377 + isBinaryOnly: true, 378 + }), 379 + ).toBe('pnpm dlx degit') 380 + }) 381 + 382 + it('generates create command for create-* packages', () => { 383 + expect( 384 + getExecuteCommand({ 385 + packageName: 'create-vite', 386 + packageManager: 'pnpm', 387 + isCreatePackage: true, 388 + }), 389 + ).toBe('pnpm create vite') 327 390 }) 328 391 }) 329 392 })
+63
test/unit/package-analysis.spec.ts
··· 3 3 analyzePackage, 4 4 detectModuleFormat, 5 5 detectTypesStatus, 6 + getCreatePackageName, 7 + getCreateShortName, 6 8 getTypesPackageName, 7 9 hasBuiltInTypes, 8 10 } from '../../shared/utils/package-analysis' ··· 243 245 packageName: '@types/express', 244 246 deprecated: 'Use included types instead', 245 247 }) 248 + }) 249 + 250 + it('includes createPackage when provided', () => { 251 + const result = analyzePackage( 252 + { name: 'vite', main: 'index.js' }, 253 + { createPackage: { packageName: 'create-vite' } }, 254 + ) 255 + 256 + expect(result.createPackage).toEqual({ packageName: 'create-vite' }) 257 + }) 258 + 259 + it('includes deprecation info for createPackage', () => { 260 + const result = analyzePackage( 261 + { name: 'foo', main: 'index.js' }, 262 + { createPackage: { packageName: 'create-foo', deprecated: 'Use different tool' } }, 263 + ) 264 + 265 + expect(result.createPackage).toEqual({ 266 + packageName: 'create-foo', 267 + deprecated: 'Use different tool', 268 + }) 269 + }) 270 + }) 271 + 272 + describe('getCreatePackageName', () => { 273 + it('handles unscoped package', () => { 274 + expect(getCreatePackageName('vite')).toBe('create-vite') 275 + }) 276 + 277 + it('handles scoped package', () => { 278 + expect(getCreatePackageName('@nuxt/app')).toBe('@nuxt/create-app') 279 + }) 280 + 281 + it('handles single-word package', () => { 282 + expect(getCreatePackageName('next')).toBe('create-next') 283 + }) 284 + 285 + it('handles hyphenated package', () => { 286 + expect(getCreatePackageName('solid-js')).toBe('create-solid-js') 287 + }) 288 + }) 289 + 290 + describe('getCreateShortName', () => { 291 + it('extracts name from unscoped create-* package', () => { 292 + expect(getCreateShortName('create-vite')).toBe('vite') 293 + }) 294 + 295 + it('extracts name from scoped create-* package', () => { 296 + expect(getCreateShortName('@vue/create-app')).toBe('app') 297 + }) 298 + 299 + it('returns full name if not a create-* package', () => { 300 + expect(getCreateShortName('vite')).toBe('vite') 301 + }) 302 + 303 + it('handles scoped package without create- prefix', () => { 304 + expect(getCreateShortName('@scope/foo')).toBe('foo') 305 + }) 306 + 307 + it('extracts name from create-next-app style packages', () => { 308 + expect(getCreateShortName('create-next-app')).toBe('next-app') 246 309 }) 247 310 })
+249
test/unit/run-command.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { 3 + getExecutableInfo, 4 + getRunCommand, 5 + getRunCommandParts, 6 + isBinaryOnlyPackage, 7 + isCreatePackage, 8 + } from '../../app/utils/run-command' 9 + import type { JsrPackageInfo } from '../../shared/types/jsr' 10 + 11 + describe('executable detection and run commands', () => { 12 + const jsrNotAvailable: JsrPackageInfo = { exists: false } 13 + 14 + describe('getExecutableInfo', () => { 15 + it('returns hasExecutable: false for undefined bin', () => { 16 + const info = getExecutableInfo('some-package', undefined) 17 + expect(info).toEqual({ 18 + primaryCommand: '', 19 + commands: [], 20 + hasExecutable: false, 21 + }) 22 + }) 23 + 24 + it('handles string bin format (package name becomes command)', () => { 25 + const info = getExecutableInfo('eslint', './bin/eslint.js') 26 + expect(info).toEqual({ 27 + primaryCommand: 'eslint', 28 + commands: ['eslint'], 29 + hasExecutable: true, 30 + }) 31 + }) 32 + 33 + it('handles object bin format with single command', () => { 34 + const info = getExecutableInfo('cowsay', { cowsay: './index.js' }) 35 + expect(info).toEqual({ 36 + primaryCommand: 'cowsay', 37 + commands: ['cowsay'], 38 + hasExecutable: true, 39 + }) 40 + }) 41 + 42 + it('handles object bin format with multiple commands', () => { 43 + const info = getExecutableInfo('typescript', { 44 + tsc: './bin/tsc', 45 + tsserver: './bin/tsserver', 46 + }) 47 + expect(info).toEqual({ 48 + primaryCommand: 'tsc', 49 + commands: ['tsc', 'tsserver'], 50 + hasExecutable: true, 51 + }) 52 + }) 53 + 54 + it('prefers command matching package name as primary', () => { 55 + const info = getExecutableInfo('eslint', { 56 + 'eslint-cli': './cli.js', 57 + 'eslint': './index.js', 58 + }) 59 + expect(info.primaryCommand).toBe('eslint') 60 + }) 61 + 62 + it('prefers command matching base name for scoped packages', () => { 63 + const info = getExecutableInfo('@scope/myapp', { 64 + 'myapp': './index.js', 65 + 'myapp-extra': './extra.js', 66 + }) 67 + expect(info.primaryCommand).toBe('myapp') 68 + }) 69 + 70 + it('returns empty for empty bin object', () => { 71 + const info = getExecutableInfo('some-package', {}) 72 + expect(info).toEqual({ 73 + primaryCommand: '', 74 + commands: [], 75 + hasExecutable: false, 76 + }) 77 + }) 78 + }) 79 + 80 + describe('getRunCommandParts', () => { 81 + // Default behavior uses local execute (for installed packages) 82 + it.each([ 83 + ['npm', ['npx', 'eslint']], 84 + ['pnpm', ['pnpm', 'exec', 'eslint']], 85 + ['yarn', ['yarn', 'eslint']], 86 + ['bun', ['bunx', 'eslint']], 87 + ['deno', ['deno', 'run', 'npm:eslint']], 88 + ['vlt', ['vlt', 'x', 'eslint']], 89 + ] as const)('%s (local) → %s', (pm, expected) => { 90 + expect( 91 + getRunCommandParts({ 92 + packageName: 'eslint', 93 + packageManager: pm, 94 + jsrInfo: jsrNotAvailable, 95 + }), 96 + ).toEqual(expected) 97 + }) 98 + 99 + // Binary-only packages use remote execute (download & run) 100 + it.each([ 101 + ['npm', ['npx', 'create-vite']], 102 + ['pnpm', ['pnpm', 'dlx', 'create-vite']], 103 + ['yarn', ['yarn', 'dlx', 'create-vite']], 104 + ['bun', ['bunx', 'create-vite']], 105 + ['deno', ['deno', 'run', 'npm:create-vite']], 106 + ['vlt', ['vlt', 'x', 'create-vite']], 107 + ] as const)('%s (remote) → %s', (pm, expected) => { 108 + expect( 109 + getRunCommandParts({ 110 + packageName: 'create-vite', 111 + packageManager: pm, 112 + jsrInfo: jsrNotAvailable, 113 + isBinaryOnly: true, 114 + }), 115 + ).toEqual(expected) 116 + }) 117 + 118 + it('uses command name directly for multi-bin packages', () => { 119 + const parts = getRunCommandParts({ 120 + packageName: 'typescript', 121 + packageManager: 'npm', 122 + command: 'tsserver', 123 + jsrInfo: jsrNotAvailable, 124 + }) 125 + // npx tsserver runs the tsserver command (not npx typescript/tsserver) 126 + expect(parts).toEqual(['npx', 'tsserver']) 127 + }) 128 + 129 + it('uses base name directly when command matches package base name', () => { 130 + const parts = getRunCommandParts({ 131 + packageName: '@scope/myapp', 132 + packageManager: 'npm', 133 + command: 'myapp', 134 + jsrInfo: jsrNotAvailable, 135 + }) 136 + expect(parts).toEqual(['npx', '@scope/myapp']) 137 + }) 138 + 139 + it('returns empty array for invalid package manager', () => { 140 + const parts = getRunCommandParts({ 141 + packageName: 'eslint', 142 + packageManager: 'invalid' as any, 143 + jsrInfo: jsrNotAvailable, 144 + }) 145 + expect(parts).toEqual([]) 146 + }) 147 + }) 148 + 149 + describe('getRunCommand', () => { 150 + it('generates full run command string', () => { 151 + expect( 152 + getRunCommand({ 153 + packageName: 'eslint', 154 + packageManager: 'npm', 155 + jsrInfo: jsrNotAvailable, 156 + }), 157 + ).toBe('npx eslint') 158 + }) 159 + 160 + it('generates correct bun run command with specific command', () => { 161 + expect( 162 + getRunCommand({ 163 + packageName: 'typescript', 164 + packageManager: 'bun', 165 + command: 'tsserver', 166 + jsrInfo: jsrNotAvailable, 167 + }), 168 + ).toBe('bunx tsserver') 169 + }) 170 + 171 + it('joined parts match getRunCommand output', () => { 172 + const options = { 173 + packageName: 'eslint', 174 + packageManager: 'pnpm' as const, 175 + jsrInfo: jsrNotAvailable, 176 + } 177 + const parts = getRunCommandParts(options) 178 + const command = getRunCommand(options) 179 + expect(parts.join(' ')).toBe(command) 180 + }) 181 + }) 182 + 183 + describe('isBinaryOnlyPackage', () => { 184 + it('returns true for create-* packages', () => { 185 + expect(isBinaryOnlyPackage({ name: 'create-vite' })).toBe(true) 186 + expect(isBinaryOnlyPackage({ name: 'create-next-app' })).toBe(true) 187 + }) 188 + 189 + it('returns true for scoped create packages', () => { 190 + expect(isBinaryOnlyPackage({ name: '@vue/create-app' })).toBe(true) 191 + expect(isBinaryOnlyPackage({ name: '@scope/create-something' })).toBe(true) 192 + }) 193 + 194 + it('returns true for packages with bin but no entry points', () => { 195 + expect( 196 + isBinaryOnlyPackage({ 197 + name: 'degit', 198 + bin: { degit: './bin.js' }, 199 + }), 200 + ).toBe(true) 201 + }) 202 + 203 + it('returns false for packages with bin AND entry points', () => { 204 + expect( 205 + isBinaryOnlyPackage({ 206 + name: 'eslint', 207 + bin: { eslint: './bin/eslint.js' }, 208 + main: './lib/api.js', 209 + }), 210 + ).toBe(false) 211 + }) 212 + 213 + it('returns false for packages with exports', () => { 214 + expect( 215 + isBinaryOnlyPackage({ 216 + name: 'some-package', 217 + bin: { cmd: './bin.js' }, 218 + exports: { '.': './index.js' }, 219 + }), 220 + ).toBe(false) 221 + }) 222 + 223 + it('returns false for packages without bin', () => { 224 + expect( 225 + isBinaryOnlyPackage({ 226 + name: 'lodash', 227 + main: './lodash.js', 228 + }), 229 + ).toBe(false) 230 + }) 231 + }) 232 + 233 + describe('isCreatePackage', () => { 234 + it('returns true for create-* packages', () => { 235 + expect(isCreatePackage('create-vite')).toBe(true) 236 + expect(isCreatePackage('create-next-app')).toBe(true) 237 + }) 238 + 239 + it('returns true for scoped create packages', () => { 240 + expect(isCreatePackage('@vue/create-app')).toBe(true) 241 + }) 242 + 243 + it('returns false for regular packages', () => { 244 + expect(isCreatePackage('eslint')).toBe(false) 245 + expect(isCreatePackage('lodash')).toBe(false) 246 + expect(isCreatePackage('@scope/utils')).toBe(false) 247 + }) 248 + }) 249 + })
+178
tests/create-command.spec.ts
··· 1 + import { expect, test } from '@nuxt/test-utils/playwright' 2 + 3 + test.describe('Create Command', () => { 4 + test.describe('Visibility', () => { 5 + test('/vite - should show create command (same maintainers)', async ({ page, goto }) => { 6 + await goto('/vite', { waitUntil: 'domcontentloaded' }) 7 + 8 + // Create command section should be visible (SSR) 9 + // Use specific container to avoid matching README code blocks 10 + const createCommandSection = page.locator('.group\\/createcmd') 11 + await expect(createCommandSection).toBeVisible() 12 + await expect(createCommandSection.locator('code')).toContainText(/create vite/i) 13 + 14 + // Link to create-vite should be present (uses sr-only text, so check attachment not visibility) 15 + await expect(page.locator('a[href="/create-vite"]')).toBeAttached() 16 + }) 17 + 18 + test('/next - should show create command (shared maintainer, same repo)', async ({ 19 + page, 20 + goto, 21 + }) => { 22 + await goto('/next', { waitUntil: 'domcontentloaded' }) 23 + 24 + // Create command section should be visible (SSR) 25 + // Use specific container to avoid matching README code blocks 26 + const createCommandSection = page.locator('.group\\/createcmd') 27 + await expect(createCommandSection).toBeVisible() 28 + await expect(createCommandSection.locator('code')).toContainText(/create next-app/i) 29 + 30 + // Link to create-next-app should be present (uses sr-only text, so check attachment not visibility) 31 + await expect(page.locator('a[href="/create-next-app"]')).toBeAttached() 32 + }) 33 + 34 + test('/nuxt - should show create command (same maintainer, same org)', async ({ 35 + page, 36 + goto, 37 + }) => { 38 + await goto('/nuxt', { waitUntil: 'domcontentloaded' }) 39 + 40 + // Create command section should be visible (SSR) 41 + // nuxt has create-nuxt package, so command is "npm create nuxt" 42 + // Use specific container to avoid matching README code blocks 43 + const createCommandSection = page.locator('.group\\/createcmd') 44 + await expect(createCommandSection).toBeVisible() 45 + await expect(createCommandSection.locator('code')).toContainText(/create nuxt/i) 46 + }) 47 + 48 + test('/color - should NOT show create command (different maintainers)', async ({ 49 + page, 50 + goto, 51 + }) => { 52 + await goto('/color', { waitUntil: 'domcontentloaded' }) 53 + 54 + // Wait for package to load 55 + await expect(page.locator('h1').filter({ hasText: 'color' })).toBeVisible() 56 + 57 + // Create command section should NOT be visible (different maintainers) 58 + const createCommandSection = page.locator('.group\\/createcmd') 59 + await expect(createCommandSection).not.toBeVisible() 60 + }) 61 + 62 + test('/lodash - should NOT show create command (no create-lodash exists)', async ({ 63 + page, 64 + goto, 65 + }) => { 66 + await goto('/lodash', { waitUntil: 'domcontentloaded' }) 67 + 68 + // Wait for package to load 69 + await expect(page.locator('h1').filter({ hasText: 'lodash' })).toBeVisible() 70 + 71 + // Create command section should NOT be visible (no create-lodash exists) 72 + const createCommandSection = page.locator('.group\\/createcmd') 73 + await expect(createCommandSection).not.toBeVisible() 74 + }) 75 + }) 76 + 77 + test.describe('Copy Functionality', () => { 78 + test('hovering create command shows copy button', async ({ page, goto }) => { 79 + await goto('/vite', { waitUntil: 'hydration' }) 80 + 81 + // Wait for package analysis API to load (create command requires this) 82 + // First ensure the package page has loaded 83 + await expect(page.locator('h1')).toContainText('vite') 84 + 85 + // Find the create command container (wait longer for API response) 86 + const createCommandContainer = page.locator('.group\\/createcmd') 87 + await expect(createCommandContainer).toBeVisible({ timeout: 15000 }) 88 + 89 + // Copy button should initially be hidden (opacity-0) 90 + const copyButton = createCommandContainer.locator('button') 91 + await expect(copyButton).toHaveCSS('opacity', '0') 92 + 93 + // Hover over the container 94 + await createCommandContainer.hover() 95 + 96 + // Copy button should become visible 97 + await expect(copyButton).toHaveCSS('opacity', '1') 98 + }) 99 + 100 + test('clicking copy button copies create command and shows confirmation', async ({ 101 + page, 102 + goto, 103 + context, 104 + }) => { 105 + // Grant clipboard permissions 106 + await context.grantPermissions(['clipboard-read', 'clipboard-write']) 107 + 108 + await goto('/vite', { waitUntil: 'hydration' }) 109 + 110 + // Find and hover over the create command container 111 + const createCommandContainer = page.locator('.group\\/createcmd') 112 + await createCommandContainer.hover() 113 + 114 + // Click the copy button 115 + const copyButton = createCommandContainer.locator('button') 116 + await copyButton.click() 117 + 118 + // Button text should change to "copied!" 119 + await expect(copyButton).toContainText(/copied/i) 120 + 121 + // Verify clipboard content contains the create command 122 + const clipboardContent = await page.evaluate(() => navigator.clipboard.readText()) 123 + expect(clipboardContent).toMatch(/create vite/i) 124 + 125 + await expect(copyButton).toContainText(/copy/i, { timeout: 5000 }) 126 + await expect(copyButton).not.toContainText(/copied/i) 127 + }) 128 + }) 129 + 130 + test.describe('Install Command Copy', () => { 131 + test('hovering install command shows copy button', async ({ page, goto }) => { 132 + await goto('/lodash', { waitUntil: 'hydration' }) 133 + 134 + // Find the install command container 135 + const installCommandContainer = page.locator('.group\\/installcmd') 136 + await expect(installCommandContainer).toBeVisible() 137 + 138 + // Copy button should initially be hidden 139 + const copyButton = installCommandContainer.locator('button') 140 + await expect(copyButton).toHaveCSS('opacity', '0') 141 + 142 + // Hover over the container 143 + await installCommandContainer.hover() 144 + 145 + // Copy button should become visible 146 + await expect(copyButton).toHaveCSS('opacity', '1') 147 + }) 148 + 149 + test('clicking copy button copies install command and shows confirmation', async ({ 150 + page, 151 + goto, 152 + context, 153 + }) => { 154 + // Grant clipboard permissions 155 + await context.grantPermissions(['clipboard-read', 'clipboard-write']) 156 + 157 + await goto('/lodash', { waitUntil: 'hydration' }) 158 + 159 + // Find and hover over the install command container 160 + const installCommandContainer = page.locator('.group\\/installcmd') 161 + await installCommandContainer.hover() 162 + 163 + // Click the copy button 164 + const copyButton = installCommandContainer.locator('button') 165 + await copyButton.click() 166 + 167 + // Button text should change to "copied!" 168 + await expect(copyButton).toContainText(/copied/i) 169 + 170 + // Verify clipboard content contains the install command 171 + const clipboardContent = await page.evaluate(() => navigator.clipboard.readText()) 172 + expect(clipboardContent).toMatch(/install lodash|add lodash/i) 173 + 174 + await expect(copyButton).toContainText(/copy/i, { timeout: 5000 }) 175 + await expect(copyButton).not.toContainText(/copied/i) 176 + }) 177 + }) 178 + })
+9 -22
tests/docs.spec.ts
··· 105 105 106 106 test.describe('Version Selector', () => { 107 107 test('version selector dropdown shows versions', async ({ page, goto }) => { 108 - await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 108 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'hydration' }) 109 109 110 - // Find and click the version selector button 110 + // Find and click the version selector button (wait for it to be visible) 111 111 const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) 112 - 113 - // Skip if version selector not present (data might not be loaded) 114 - if (!(await versionButton.isVisible())) { 115 - test.skip() 116 - return 117 - } 112 + await expect(versionButton).toBeVisible({ timeout: 10000 }) 118 113 119 114 await versionButton.click() 120 115 ··· 128 123 }) 129 124 130 125 test('selecting a version navigates to that version', async ({ page, goto }) => { 131 - await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 126 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'hydration' }) 132 127 133 - // Find and click the version selector button 128 + // Find and click the version selector button (wait for it to be visible) 134 129 const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) 135 - 136 - // Skip if version selector not present 137 - if (!(await versionButton.isVisible())) { 138 - test.skip() 139 - return 140 - } 130 + await expect(versionButton).toBeVisible({ timeout: 10000 }) 141 131 142 132 await versionButton.click() 143 133 ··· 167 157 }) 168 158 169 159 test('escape key closes version dropdown', async ({ page, goto }) => { 170 - await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 160 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'hydration' }) 171 161 162 + // Wait for version button to be visible 172 163 const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) 173 - 174 - if (!(await versionButton.isVisible())) { 175 - test.skip() 176 - return 177 - } 164 + await expect(versionButton).toBeVisible({ timeout: 10000 }) 178 165 179 166 await versionButton.click() 180 167