[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 jsr badge when available on jsr (+ add to pm) (#22)

authored by

Daniel Roe and committed by
GitHub
00866065 53a75813

+573 -42
+25
app/components/JsrBadge.vue
··· 1 + <script setup lang="ts"> 2 + defineProps<{ 3 + /** JSR package URL (e.g., "https://jsr.io/@std/fs") */ 4 + url: string 5 + /** Whether to show as compact (icon only) or full (with text) */ 6 + compact?: boolean 7 + }>() 8 + </script> 9 + 10 + <template> 11 + <a 12 + :href="url" 13 + target="_blank" 14 + rel="noopener noreferrer" 15 + class="inline-flex items-center gap-1 text-xs font-mono text-fg-muted hover:text-fg transition-colors duration-200" 16 + title="also available on JSR" 17 + > 18 + <span 19 + class="i-simple-icons-jsr shrink-0" 20 + :class="compact ? 'w-3.5 h-3.5' : 'w-4 h-4'" 21 + aria-hidden="true" 22 + /> 23 + <span v-if="!compact" class="sr-only sm:not-sr-only">jsr</span> 24 + </a> 25 + </template>
+43 -36
app/pages/[...package].vue
··· 1 1 <script setup lang="ts"> 2 2 import { joinURL } from 'ufo' 3 3 import type { PackumentVersion, NpmVersionDist } from '#shared/types' 4 + import type { JsrPackageInfo } from '#shared/types/jsr' 4 5 5 6 definePageMeta({ 6 7 name: 'package', ··· 58 59 }, 59 60 { default: () => ({ html: '' }) }, 60 61 ) 62 + 63 + // Check if package exists on JSR (only for scoped packages) 64 + const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${packageName.value}`, { 65 + default: () => ({ exists: false }), 66 + // Only fetch for scoped packages (JSR requirement) 67 + immediate: computed(() => packageName.value.startsWith('@')).value, 68 + }) 61 69 62 70 // Get the version to display (requested or latest) 63 71 const displayVersion = computed(() => { ··· 143 151 return !!dist.attestations 144 152 } 145 153 146 - // Package manager install commands 147 - const packageManagers = [ 148 - { id: 'npm', label: 'npm', action: 'install' }, 149 - { id: 'pnpm', label: 'pnpm', action: 'add' }, 150 - { id: 'yarn', label: 'yarn', action: 'add' }, 151 - { id: 'bun', label: 'bun', action: 'add' }, 152 - { id: 'deno', label: 'deno', action: 'add npm:' }, 153 - ] as const 154 - 155 - type PackageManagerId = (typeof packageManagers)[number]['id'] 156 - 157 - // Persist preference in localStorage 154 + // Persist package manager preference in localStorage 158 155 const selectedPM = ref<PackageManagerId>('npm') 159 156 160 157 onMounted(() => { ··· 168 165 localStorage.setItem('npmx-pm', value) 169 166 }) 170 167 171 - const currentPM = computed( 172 - () => packageManagers.find(p => p.id === selectedPM.value) || packageManagers[0], 173 - ) 174 - const selectedPMLabel = computed(() => currentPM.value.label) 175 - const selectedPMAction = computed(() => currentPM.value.action) 168 + const installCommandParts = computed(() => { 169 + if (!pkg.value) return [] 170 + return getInstallCommandParts({ 171 + packageName: pkg.value.name, 172 + packageManager: selectedPM.value, 173 + version: requestedVersion.value, 174 + jsrInfo: jsrInfo.value, 175 + }) 176 + }) 176 177 177 178 const installCommand = computed(() => { 178 179 if (!pkg.value) return '' 179 - const pm = currentPM.value 180 - let command = `${pm.label} ${pm.action} ${pkg.value.name}` 181 - // deno uses "add npm:package" format 182 - if (pm.id === 'deno') { 183 - command = `${pm.label} ${pm.action}${pkg.value.name}` 184 - } 185 - if (requestedVersion.value) { 186 - command += `@${requestedVersion.value}` 187 - } 188 - return command 180 + return getInstallCommand({ 181 + packageName: pkg.value.name, 182 + packageManager: selectedPM.value, 183 + version: requestedVersion.value, 184 + jsrInfo: jsrInfo.value, 185 + }) 189 186 }) 190 187 191 188 // Copy install command ··· 415 412 npm 416 413 </a> 417 414 </li> 415 + <li v-if="jsrInfo?.exists && jsrInfo.url"> 416 + <a 417 + :href="jsrInfo.url" 418 + target="_blank" 419 + rel="noopener noreferrer" 420 + class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 421 + title="Also available on JSR" 422 + > 423 + <span class="i-simple-icons-jsr w-4 h-4" /> 424 + jsr 425 + </a> 426 + </li> 418 427 <li> 419 428 <a 420 429 :href="`https://socket.dev/npm/package/${pkg.name}/overview/${displayVersion?.version ?? 'latest'}`" ··· 517 526 <span class="text-fg-subtle font-mono text-sm select-none">$</span> 518 527 <code class="font-mono text-sm" 519 528 ><ClientOnly 520 - ><span class="text-fg">{{ selectedPMLabel }}</span 521 - >&nbsp;<span class="text-fg-muted">{{ selectedPMAction }}</span 522 - ><span v-if="selectedPM !== 'deno'" class="text-fg-muted" 523 - >&nbsp;{{ pkg.name }}</span 524 - ><span v-else class="text-fg-muted">{{ pkg.name }}</span 525 - ><span v-if="requestedVersion" class="text-fg-muted">@{{ requestedVersion }}</span 529 + ><span 530 + v-for="(part, i) in installCommandParts" 531 + :key="i" 532 + :class="i === 0 ? 'text-fg' : 'text-fg-muted'" 533 + >{{ i > 0 ? ' ' : '' }}{{ part }}</span 526 534 ><template #fallback 527 - ><span class="text-fg">npm</span>&nbsp;<span class="text-fg-muted" 528 - >install&nbsp;{{ pkg.name }}</span 529 - ></template 535 + ><span class="text-fg">npm</span 536 + ><span class="text-fg-muted"> install {{ pkg.name }}</span></template 530 537 ></ClientOnly 531 538 ></code 532 539 >
+66
app/utils/install-command.ts
··· 1 + import type { JsrPackageInfo } from '#shared/types/jsr' 2 + 3 + export const packageManagers = [ 4 + { id: 'npm', label: 'npm', action: 'install' }, 5 + { id: 'pnpm', label: 'pnpm', action: 'add' }, 6 + { id: 'yarn', label: 'yarn', action: 'add' }, 7 + { id: 'bun', label: 'bun', action: 'add' }, 8 + { id: 'deno', label: 'deno', action: 'add' }, 9 + { id: 'jsr', label: 'jsr', action: 'add' }, 10 + ] as const 11 + 12 + export type PackageManagerId = (typeof packageManagers)[number]['id'] 13 + 14 + export interface InstallCommandOptions { 15 + packageName: string 16 + packageManager: PackageManagerId 17 + version?: string | null 18 + jsrInfo?: JsrPackageInfo | null 19 + } 20 + 21 + /** 22 + * Get the package specifier for a given package manager. 23 + * Handles npm: prefix for deno and jsr (when not native). 24 + */ 25 + export function getPackageSpecifier(options: InstallCommandOptions): string { 26 + const { packageName, packageManager, jsrInfo } = options 27 + 28 + if (packageManager === 'deno') { 29 + // deno add npm:package 30 + return `npm:${packageName}` 31 + } 32 + 33 + if (packageManager === 'jsr') { 34 + if (jsrInfo?.exists && jsrInfo.scope && jsrInfo.name) { 35 + // Native JSR package: @scope/name 36 + return `@${jsrInfo.scope}/${jsrInfo.name}` 37 + } 38 + // npm compatibility: npm:package 39 + return `npm:${packageName}` 40 + } 41 + 42 + // Standard package managers (npm, pnpm, yarn, bun) 43 + return packageName 44 + } 45 + 46 + /** 47 + * Generate the full install command for a package. 48 + */ 49 + export function getInstallCommand(options: InstallCommandOptions): string { 50 + return getInstallCommandParts(options).join(' ') 51 + } 52 + 53 + /** 54 + * Generate install command as an array of parts. 55 + * First element is the command (e.g., "npm"), rest are arguments. 56 + * Useful for rendering with different styling for command vs args. 57 + */ 58 + export function getInstallCommandParts(options: InstallCommandOptions): string[] { 59 + const pm = packageManagers.find(p => p.id === options.packageManager) 60 + if (!pm) return [] 61 + 62 + const spec = getPackageSpecifier(options) 63 + const version = options.version ? `@${options.version}` : '' 64 + 65 + return [pm.label, pm.action, `${spec}${version}`] 66 + }
+1 -1
cli/package.json
··· 26 26 "citty": "^0.2.0", 27 27 "consola": "^3.4.2", 28 28 "defu": "^6.1.4", 29 - "h3": "^2.0.1-rc.11", 29 + "h3-next": "npm:h3@^2.0.1-rc.11", 30 30 "ofetch": "^1.5.1", 31 31 "picocolors": "^1.1.1", 32 32 "srvx": "^0.10.1",
+2 -2
cli/src/server.ts
··· 1 1 import crypto from 'node:crypto' 2 - import { H3, HTTPError, handleCors, type H3Event } from 'h3' 3 - import type { CorsOptions } from 'h3' 2 + import { H3, HTTPError, handleCors, type H3Event } from 'h3-next' 3 + import type { CorsOptions } from 'h3-next' 4 4 5 5 import type { ConnectorState, PendingOperation, OperationType, ApiResponse } from './types.ts' 6 6 import {
+1
package.json
··· 26 26 "test:unit": "vitest --project unit" 27 27 }, 28 28 "dependencies": { 29 + "@iconify-json/simple-icons": "^1.2.67", 29 30 "@iconify-json/vscode-icons": "^1.2.40", 30 31 "@nuxt/fonts": "^0.13.0", 31 32 "@nuxt/scripts": "^0.13.2",
+13 -3
pnpm-lock.yaml
··· 13 13 14 14 .: 15 15 dependencies: 16 + '@iconify-json/simple-icons': 17 + specifier: ^1.2.67 18 + version: 1.2.67 16 19 '@iconify-json/vscode-icons': 17 20 specifier: ^1.2.40 18 21 version: 1.2.40 ··· 161 164 defu: 162 165 specifier: ^6.1.4 163 166 version: 6.1.4 164 - h3: 165 - specifier: ^2.0.1-rc.11 166 - version: 2.0.1-rc.11 167 + h3-next: 168 + specifier: npm:h3@^2.0.1-rc.11 169 + version: h3@2.0.1-rc.11 167 170 ofetch: 168 171 specifier: ^1.5.1 169 172 version: 1.5.1 ··· 1224 1227 1225 1228 '@iconify-json/carbon@1.2.18': 1226 1229 resolution: {integrity: sha512-Grb13E6r/RqTEV4Sqd/BQR2FUt57U2WLuticJ5H8JbTdHLop1LmdePu3EJJA3Xi8DcWRbD6OnC133hKfOwlgtg==} 1230 + 1231 + '@iconify-json/simple-icons@1.2.67': 1232 + resolution: {integrity: sha512-RGJRwlxyup54L1UDAjCshy3ckX5zcvYIU74YLSnUgHGvqh6B4mvksbGNHAIEp7dZQ6cM13RZVT5KC07CmnFNew==} 1227 1233 1228 1234 '@iconify-json/solar@1.2.5': 1229 1235 resolution: {integrity: sha512-WMAiNwchU8zhfrySww6KQBRIBbsQ6SvgIu2yA+CHGyMima/0KQwT5MXogrZPJGoQF+1Ye3Qj6K+1CiyNn3YkoA==} ··· 8100 8106 optional: true 8101 8107 8102 8108 '@iconify-json/carbon@1.2.18': 8109 + dependencies: 8110 + '@iconify/types': 2.0.0 8111 + 8112 + '@iconify-json/simple-icons@1.2.67': 8103 8113 dependencies: 8104 8114 '@iconify/types': 2.0.0 8105 8115
+25
server/api/jsr/[...pkg].get.ts
··· 1 + import type { JsrPackageInfo } from '#shared/types/jsr' 2 + 3 + /** 4 + * Check if an npm package exists on JSR. 5 + * 6 + * GET /api/jsr/:pkg 7 + * 8 + * @example GET /api/jsr/@std/fs → { exists: true, scope: "std", name: "fs", ... } 9 + * @example GET /api/jsr/lodash → { exists: false } 10 + */ 11 + export default defineCachedEventHandler<Promise<JsrPackageInfo>>( 12 + async event => { 13 + const pkgPath = getRouterParam(event, 'pkg') 14 + if (!pkgPath) { 15 + throw createError({ statusCode: 400, message: 'Package name is required' }) 16 + } 17 + 18 + return await fetchJsrPackageInfo(pkgPath) 19 + }, 20 + { 21 + maxAge: 60 * 60, // 1 hour 22 + name: 'api-jsr-package', 23 + getKey: event => getRouterParam(event, 'pkg') ?? '', 24 + }, 25 + )
+66
server/utils/jsr.ts
··· 1 + import type { JsrPackageMeta, JsrPackageInfo } from '#shared/types/jsr' 2 + 3 + const JSR_REGISTRY = 'https://jsr.io' 4 + 5 + /** 6 + * Check if a scoped npm package exists on JSR with the same name. 7 + * 8 + * This only works for scoped packages (@scope/name) since: 9 + * 1. JSR only has scoped packages 10 + * 2. We can only authoritatively match when names are identical 11 + * 12 + * Unscoped npm packages (e.g., "hono") may exist on JSR under a different 13 + * name (e.g., "@hono/hono"), but we don't attempt to guess these mappings. 14 + * 15 + * @param npmPackageName - The npm package name (e.g., "@hono/hono") 16 + * @returns JsrPackageInfo with existence status and metadata 17 + */ 18 + export const fetchJsrPackageInfo = defineCachedFunction( 19 + async (npmPackageName: string): Promise<JsrPackageInfo> => { 20 + // Only check scoped packages - we can't authoritatively map unscoped names 21 + if (!npmPackageName.startsWith('@')) { 22 + return { exists: false } 23 + } 24 + 25 + // Parse scope and name from @scope/name format 26 + const match = npmPackageName.match(/^@([^/]+)\/(.+)$/) 27 + if (!match) { 28 + return { exists: false } 29 + } 30 + 31 + const [, scope, name] = match 32 + 33 + try { 34 + // Fetch JSR package metadata 35 + const meta = await $fetch<JsrPackageMeta>(`${JSR_REGISTRY}/@${scope}/${name}/meta.json`, { 36 + // Short timeout since this is a nice-to-have feature 37 + timeout: 3000, 38 + }) 39 + 40 + // Find latest non-yanked version 41 + const versions = Object.entries(meta.versions) 42 + .filter(([, v]) => !v.yanked) 43 + .map(([version]) => version) 44 + 45 + versions.sort() 46 + const latestVersion = versions[versions.length - 1] 47 + 48 + return { 49 + exists: true, 50 + scope: meta.scope, 51 + name: meta.name, 52 + url: `${JSR_REGISTRY}/@${meta.scope}/${meta.name}`, 53 + latestVersion, 54 + } 55 + } catch { 56 + // Package doesn't exist on JSR or API error 57 + return { exists: false } 58 + } 59 + }, 60 + { 61 + // Cache for 1 hour - JSR info doesn't change often 62 + maxAge: 60 * 60, 63 + name: 'jsr-package-info', 64 + getKey: (name: string) => name, 65 + }, 66 + )
+1
shared/types/index.ts
··· 1 1 export * from './npm-registry' 2 + export * from './jsr'
+43
shared/types/jsr.ts
··· 1 + /** 2 + * JSR (jsr.io) Registry API Types 3 + * 4 + * @see https://jsr.io/docs/api 5 + */ 6 + 7 + /** 8 + * JSR package metadata from meta.json 9 + * GET https://jsr.io/@<scope>/<package-name>/meta.json 10 + */ 11 + export interface JsrPackageMeta { 12 + /** Package scope (without @) */ 13 + scope: string 14 + /** Package name */ 15 + name: string 16 + /** Map of versions to version metadata */ 17 + versions: Record<string, JsrVersionMeta> 18 + } 19 + 20 + /** 21 + * JSR version metadata (minimal, from meta.json) 22 + */ 23 + export interface JsrVersionMeta { 24 + /** If true, the version has been yanked */ 25 + yanked?: boolean 26 + } 27 + 28 + /** 29 + * JSR package info response for our API 30 + * Indicates whether a package exists on JSR 31 + */ 32 + export interface JsrPackageInfo { 33 + /** Whether the package exists on JSR */ 34 + exists: boolean 35 + /** JSR scope (without @) */ 36 + scope?: string 37 + /** JSR package name */ 38 + name?: string 39 + /** Full JSR URL */ 40 + url?: string 41 + /** Latest version on JSR (non-yanked) */ 42 + latestVersion?: string 43 + }
+287
test/unit/install-command.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { 3 + getInstallCommand, 4 + getInstallCommandParts, 5 + getPackageSpecifier, 6 + } from '../../app/utils/install-command' 7 + import type { JsrPackageInfo } from '../../shared/types/jsr' 8 + 9 + describe('install command generation', () => { 10 + // Test fixtures 11 + const unscopedPackage = 'lodash' 12 + const scopedPackage = '@trpc/server' 13 + 14 + const jsrAvailable: JsrPackageInfo = { 15 + exists: true, 16 + scope: 'trpc', 17 + name: 'server', 18 + url: 'https://jsr.io/@trpc/server', 19 + latestVersion: '10.0.0', 20 + } 21 + 22 + const jsrNotAvailable: JsrPackageInfo = { 23 + exists: false, 24 + } 25 + 26 + describe('getPackageSpecifier', () => { 27 + describe('unscoped package (lodash) - not on JSR', () => { 28 + it.each([ 29 + ['npm', 'lodash'], 30 + ['pnpm', 'lodash'], 31 + ['yarn', 'lodash'], 32 + ['bun', 'lodash'], 33 + ['deno', 'npm:lodash'], 34 + ['jsr', 'npm:lodash'], 35 + ] as const)('%s → %s', (pm, expected) => { 36 + expect( 37 + getPackageSpecifier({ 38 + packageName: unscopedPackage, 39 + packageManager: pm, 40 + jsrInfo: jsrNotAvailable, 41 + }), 42 + ).toBe(expected) 43 + }) 44 + }) 45 + 46 + describe('scoped package (@trpc/server) - available on JSR', () => { 47 + it.each([ 48 + ['npm', '@trpc/server'], 49 + ['pnpm', '@trpc/server'], 50 + ['yarn', '@trpc/server'], 51 + ['bun', '@trpc/server'], 52 + ['deno', 'npm:@trpc/server'], 53 + ['jsr', '@trpc/server'], // Native JSR specifier 54 + ] as const)('%s → %s', (pm, expected) => { 55 + expect( 56 + getPackageSpecifier({ 57 + packageName: scopedPackage, 58 + packageManager: pm, 59 + jsrInfo: jsrAvailable, 60 + }), 61 + ).toBe(expected) 62 + }) 63 + }) 64 + 65 + describe('scoped package (@vue/shared) - NOT on JSR', () => { 66 + it.each([ 67 + ['npm', '@vue/shared'], 68 + ['pnpm', '@vue/shared'], 69 + ['yarn', '@vue/shared'], 70 + ['bun', '@vue/shared'], 71 + ['deno', 'npm:@vue/shared'], 72 + ['jsr', 'npm:@vue/shared'], // Falls back to npm: compat 73 + ] as const)('%s → %s', (pm, expected) => { 74 + expect( 75 + getPackageSpecifier({ 76 + packageName: '@vue/shared', 77 + packageManager: pm, 78 + jsrInfo: jsrNotAvailable, 79 + }), 80 + ).toBe(expected) 81 + }) 82 + }) 83 + }) 84 + 85 + describe('getInstallCommand', () => { 86 + describe('unscoped package without version', () => { 87 + it.each([ 88 + ['npm', 'npm install lodash'], 89 + ['pnpm', 'pnpm add lodash'], 90 + ['yarn', 'yarn add lodash'], 91 + ['bun', 'bun add lodash'], 92 + ['deno', 'deno add npm:lodash'], 93 + ['jsr', 'jsr add npm:lodash'], 94 + ] as const)('%s → %s', (pm, expected) => { 95 + expect( 96 + getInstallCommand({ 97 + packageName: unscopedPackage, 98 + packageManager: pm, 99 + jsrInfo: jsrNotAvailable, 100 + }), 101 + ).toBe(expected) 102 + }) 103 + }) 104 + 105 + describe('unscoped package with version', () => { 106 + it.each([ 107 + ['npm', 'npm install lodash@4.17.21'], 108 + ['pnpm', 'pnpm add lodash@4.17.21'], 109 + ['yarn', 'yarn add lodash@4.17.21'], 110 + ['bun', 'bun add lodash@4.17.21'], 111 + ['deno', 'deno add npm:lodash@4.17.21'], 112 + ['jsr', 'jsr add npm:lodash@4.17.21'], 113 + ] as const)('%s → %s', (pm, expected) => { 114 + expect( 115 + getInstallCommand({ 116 + packageName: unscopedPackage, 117 + packageManager: pm, 118 + version: '4.17.21', 119 + jsrInfo: jsrNotAvailable, 120 + }), 121 + ).toBe(expected) 122 + }) 123 + }) 124 + 125 + describe('scoped package on JSR without version', () => { 126 + it.each([ 127 + ['npm', 'npm install @trpc/server'], 128 + ['pnpm', 'pnpm add @trpc/server'], 129 + ['yarn', 'yarn add @trpc/server'], 130 + ['bun', 'bun add @trpc/server'], 131 + ['deno', 'deno add npm:@trpc/server'], 132 + ['jsr', 'jsr add @trpc/server'], // Native JSR 133 + ] as const)('%s → %s', (pm, expected) => { 134 + expect( 135 + getInstallCommand({ 136 + packageName: scopedPackage, 137 + packageManager: pm, 138 + jsrInfo: jsrAvailable, 139 + }), 140 + ).toBe(expected) 141 + }) 142 + }) 143 + 144 + describe('scoped package on JSR with version', () => { 145 + it.each([ 146 + ['npm', 'npm install @trpc/server@10.0.0'], 147 + ['pnpm', 'pnpm add @trpc/server@10.0.0'], 148 + ['yarn', 'yarn add @trpc/server@10.0.0'], 149 + ['bun', 'bun add @trpc/server@10.0.0'], 150 + ['deno', 'deno add npm:@trpc/server@10.0.0'], 151 + ['jsr', 'jsr add @trpc/server@10.0.0'], // Native JSR with version 152 + ] as const)('%s → %s', (pm, expected) => { 153 + expect( 154 + getInstallCommand({ 155 + packageName: scopedPackage, 156 + packageManager: pm, 157 + version: '10.0.0', 158 + jsrInfo: jsrAvailable, 159 + }), 160 + ).toBe(expected) 161 + }) 162 + }) 163 + 164 + describe('scoped package NOT on JSR', () => { 165 + it.each([ 166 + ['npm', 'npm install @vue/shared'], 167 + ['pnpm', 'pnpm add @vue/shared'], 168 + ['yarn', 'yarn add @vue/shared'], 169 + ['bun', 'bun add @vue/shared'], 170 + ['deno', 'deno add npm:@vue/shared'], 171 + ['jsr', 'jsr add npm:@vue/shared'], // Falls back to npm: compat 172 + ] as const)('%s → %s', (pm, expected) => { 173 + expect( 174 + getInstallCommand({ 175 + packageName: '@vue/shared', 176 + packageManager: pm, 177 + jsrInfo: jsrNotAvailable, 178 + }), 179 + ).toBe(expected) 180 + }) 181 + }) 182 + }) 183 + 184 + describe('getInstallCommandParts', () => { 185 + it('returns correct parts for npm without version', () => { 186 + const parts = getInstallCommandParts({ 187 + packageName: 'lodash', 188 + packageManager: 'npm', 189 + jsrInfo: jsrNotAvailable, 190 + }) 191 + expect(parts).toEqual(['npm', 'install', 'lodash']) 192 + }) 193 + 194 + it('returns correct parts for npm with version', () => { 195 + const parts = getInstallCommandParts({ 196 + packageName: 'lodash', 197 + packageManager: 'npm', 198 + version: '4.17.21', 199 + jsrInfo: jsrNotAvailable, 200 + }) 201 + expect(parts).toEqual(['npm', 'install', 'lodash@4.17.21']) 202 + }) 203 + 204 + it('returns correct parts for deno with npm: prefix', () => { 205 + const parts = getInstallCommandParts({ 206 + packageName: '@trpc/server', 207 + packageManager: 'deno', 208 + jsrInfo: jsrAvailable, 209 + }) 210 + expect(parts).toEqual(['deno', 'add', 'npm:@trpc/server']) 211 + }) 212 + 213 + it('returns correct parts for jsr with native package', () => { 214 + const parts = getInstallCommandParts({ 215 + packageName: '@trpc/server', 216 + packageManager: 'jsr', 217 + jsrInfo: jsrAvailable, 218 + }) 219 + expect(parts).toEqual(['jsr', 'add', '@trpc/server']) 220 + }) 221 + 222 + it('returns correct parts for jsr with npm compat', () => { 223 + const parts = getInstallCommandParts({ 224 + packageName: 'lodash', 225 + packageManager: 'jsr', 226 + jsrInfo: jsrNotAvailable, 227 + }) 228 + expect(parts).toEqual(['jsr', 'add', 'npm:lodash']) 229 + }) 230 + 231 + it('joined parts match getInstallCommand output', () => { 232 + const options = { 233 + packageName: '@trpc/server', 234 + packageManager: 'pnpm' as const, 235 + version: '10.0.0', 236 + jsrInfo: jsrAvailable, 237 + } 238 + const parts = getInstallCommandParts(options) 239 + const command = getInstallCommand(options) 240 + expect(parts.join(' ')).toBe(command) 241 + }) 242 + }) 243 + 244 + describe('edge cases', () => { 245 + it('handles null jsrInfo same as not available', () => { 246 + expect( 247 + getPackageSpecifier({ 248 + packageName: 'lodash', 249 + packageManager: 'jsr', 250 + jsrInfo: null, 251 + }), 252 + ).toBe('npm:lodash') 253 + }) 254 + 255 + it('handles undefined jsrInfo same as not available', () => { 256 + expect( 257 + getPackageSpecifier({ 258 + packageName: 'lodash', 259 + packageManager: 'jsr', 260 + jsrInfo: undefined, 261 + }), 262 + ).toBe('npm:lodash') 263 + }) 264 + 265 + it('handles jsrInfo with exists:true but missing scope/name', () => { 266 + const partialJsr: JsrPackageInfo = { 267 + exists: true, 268 + // Missing scope and name 269 + } 270 + expect( 271 + getPackageSpecifier({ 272 + packageName: '@foo/bar', 273 + packageManager: 'jsr', 274 + jsrInfo: partialJsr, 275 + }), 276 + ).toBe('npm:@foo/bar') 277 + }) 278 + 279 + it('getInstallCommandParts returns empty array for invalid package manager', () => { 280 + const parts = getInstallCommandParts({ 281 + packageName: 'lodash', 282 + packageManager: 'invalid' as any, 283 + }) 284 + expect(parts).toEqual([]) 285 + }) 286 + }) 287 + })