[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: publish new packages from ui (#67)

authored by

Daniel Roe and committed by
GitHub
b50ffd3b e7c5cfa9

+964 -27
+424
app/components/ClaimPackageModal.vue
··· 1 + <script setup lang="ts"> 2 + import type { CheckNameResult } from '~/utils/package-name' 3 + import { checkPackageName } from '~/utils/package-name' 4 + 5 + const props = defineProps<{ 6 + packageName: string 7 + }>() 8 + 9 + const open = defineModel<boolean>('open', { default: false }) 10 + 11 + const { 12 + isConnected, 13 + state, 14 + npmUser, 15 + addOperation, 16 + approveOperation, 17 + executeOperations, 18 + refreshState, 19 + } = useConnector() 20 + 21 + // Fetch name availability when modal opens 22 + const checkResult = ref<CheckNameResult | null>(null) 23 + 24 + const isChecking = ref(false) 25 + const isPublishing = ref(false) 26 + const publishError = ref<string | null>(null) 27 + const publishSuccess = ref(false) 28 + 29 + async function checkAvailability() { 30 + isChecking.value = true 31 + publishError.value = null 32 + try { 33 + checkResult.value = await checkPackageName(props.packageName) 34 + } catch (err) { 35 + publishError.value = err instanceof Error ? err.message : 'Failed to check name availability' 36 + } finally { 37 + isChecking.value = false 38 + } 39 + } 40 + 41 + async function handleClaim() { 42 + if (!checkResult.value?.available || !isConnected.value) return 43 + 44 + isPublishing.value = true 45 + publishError.value = null 46 + 47 + try { 48 + // Add the operation 49 + const operation = await addOperation({ 50 + type: 'package:init', 51 + params: { name: props.packageName, ...(npmUser.value && { author: npmUser.value }) }, 52 + description: `Initialize package ${props.packageName}`, 53 + command: `npm publish (${props.packageName}@0.0.0)`, 54 + }) 55 + 56 + if (!operation) { 57 + throw new Error('Failed to create operation') 58 + } 59 + 60 + // Auto-approve and execute 61 + await approveOperation(operation.id) 62 + const result = await executeOperations() 63 + 64 + // Refresh state and check if operation completed successfully 65 + await refreshState() 66 + 67 + // Find the operation and check its status 68 + const completedOp = state.value.operations.find(op => op.id === operation.id) 69 + if (completedOp?.status === 'completed') { 70 + publishSuccess.value = true 71 + } else if (completedOp?.status === 'failed') { 72 + if (completedOp.result?.requiresOtp) { 73 + // OTP is needed - open connector panel to handle it 74 + open.value = false 75 + connectorModalOpen.value = true 76 + } else { 77 + publishError.value = completedOp.result?.stderr || 'Failed to publish package' 78 + } 79 + } else { 80 + // Still pending/approved/running - open connector panel to show progress 81 + open.value = false 82 + connectorModalOpen.value = true 83 + } 84 + } catch (err) { 85 + publishError.value = err instanceof Error ? err.message : 'Failed to claim package' 86 + } finally { 87 + isPublishing.value = false 88 + } 89 + } 90 + 91 + // Check availability when modal opens 92 + watch(open, isOpen => { 93 + if (isOpen) { 94 + checkResult.value = null 95 + publishError.value = null 96 + publishSuccess.value = false 97 + checkAvailability() 98 + } 99 + }) 100 + 101 + // Computed for similar packages with warnings 102 + const hasDangerousSimilarPackages = computed(() => { 103 + if (!checkResult.value?.similarPackages) return false 104 + return checkResult.value.similarPackages.some( 105 + pkg => pkg.similarity === 'exact-match' || pkg.similarity === 'very-similar', 106 + ) 107 + }) 108 + 109 + const isScoped = computed(() => props.packageName.startsWith('@')) 110 + 111 + // Preview of the package.json that will be published 112 + const previewPackageJson = computed(() => { 113 + const access = isScoped.value ? 'public' : undefined 114 + return { 115 + name: props.packageName, 116 + version: '0.0.0', 117 + description: `Placeholder for ${props.packageName}`, 118 + main: 'index.js', 119 + scripts: {}, 120 + keywords: [], 121 + author: npmUser.value ? `${npmUser.value} (https://www.npmjs.com/~${npmUser.value})` : '', 122 + license: 'UNLICENSED', 123 + private: false, 124 + ...(access && { publishConfig: { access } }), 125 + } 126 + }) 127 + 128 + const connectorModalOpen = ref(false) 129 + </script> 130 + 131 + <template> 132 + <Teleport to="body"> 133 + <Transition 134 + enter-active-class="transition-opacity duration-200" 135 + leave-active-class="transition-opacity duration-200" 136 + enter-from-class="opacity-0" 137 + leave-to-class="opacity-0" 138 + > 139 + <div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center p-4"> 140 + <!-- Backdrop --> 141 + <button 142 + type="button" 143 + class="absolute inset-0 bg-black/60 cursor-default" 144 + aria-label="Close modal" 145 + @click="open = false" 146 + /> 147 + 148 + <!-- Modal --> 149 + <div 150 + class="relative w-full max-w-lg bg-bg border border-border rounded-lg shadow-xl max-h-[90vh] overflow-y-auto overscroll-contain" 151 + role="dialog" 152 + aria-modal="true" 153 + aria-labelledby="claim-modal-title" 154 + > 155 + <div class="p-6"> 156 + <div class="flex items-center justify-between mb-6"> 157 + <h2 id="claim-modal-title" class="font-mono text-lg font-medium"> 158 + Claim Package Name 159 + </h2> 160 + <button 161 + type="button" 162 + class="text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 163 + aria-label="Close" 164 + @click="open = false" 165 + > 166 + <span class="i-carbon-close block w-5 h-5" aria-hidden="true" /> 167 + </button> 168 + </div> 169 + 170 + <!-- Loading state --> 171 + <div v-if="isChecking" class="py-8 text-center"> 172 + <LoadingSpinner text="Checking availability…" /> 173 + </div> 174 + 175 + <!-- Success state --> 176 + <div v-else-if="publishSuccess" class="space-y-4"> 177 + <div 178 + class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 179 + > 180 + <span class="i-carbon-checkmark-filled text-green-500 w-6 h-6" aria-hidden="true" /> 181 + <div> 182 + <p class="font-mono text-sm text-fg">Package claimed!</p> 183 + <p class="text-xs text-fg-muted"> 184 + {{ packageName }}@0.0.0 has been published to npm. 185 + </p> 186 + </div> 187 + </div> 188 + 189 + <p class="text-sm text-fg-muted"> 190 + You can now publish new versions to this package using 191 + <code class="font-mono bg-bg-subtle px-1 rounded">npm publish</code>. 192 + </p> 193 + 194 + <div class="flex gap-3"> 195 + <NuxtLink 196 + :to="`/package/${packageName}`" 197 + class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 198 + @click="open = false" 199 + > 200 + View Package 201 + </NuxtLink> 202 + <button 203 + type="button" 204 + class="flex-1 px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 205 + @click="open = false" 206 + > 207 + Close 208 + </button> 209 + </div> 210 + </div> 211 + 212 + <!-- Check result --> 213 + <div v-else-if="checkResult" class="space-y-4"> 214 + <!-- Package name display --> 215 + <div class="p-4 bg-bg-subtle border border-border rounded-lg"> 216 + <p class="font-mono text-lg text-fg">{{ checkResult.name }}</p> 217 + </div> 218 + 219 + <!-- Validation errors --> 220 + <div 221 + v-if="checkResult.validationErrors?.length" 222 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 223 + role="alert" 224 + > 225 + <p class="font-medium mb-1">Invalid package name:</p> 226 + <ul class="list-disc list-inside space-y-1"> 227 + <li v-for="err in checkResult.validationErrors" :key="err">{{ err }}</li> 228 + </ul> 229 + </div> 230 + 231 + <!-- Validation warnings --> 232 + <div 233 + v-if="checkResult.validationWarnings?.length" 234 + class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 235 + role="alert" 236 + > 237 + <p class="font-medium mb-1">Warnings:</p> 238 + <ul class="list-disc list-inside space-y-1"> 239 + <li v-for="warn in checkResult.validationWarnings" :key="warn">{{ warn }}</li> 240 + </ul> 241 + </div> 242 + 243 + <!-- Availability status --> 244 + <div v-if="checkResult.valid"> 245 + <div 246 + v-if="checkResult.available" 247 + class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 248 + > 249 + <span 250 + class="i-carbon-checkmark-filled text-green-500 w-5 h-5" 251 + aria-hidden="true" 252 + /> 253 + <p class="font-mono text-sm text-fg">This name is available!</p> 254 + </div> 255 + 256 + <div 257 + v-else 258 + class="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/20 rounded-lg" 259 + > 260 + <span class="i-carbon-close-filled text-red-500 w-5 h-5" aria-hidden="true" /> 261 + <p class="font-mono text-sm text-fg">This name is already taken.</p> 262 + </div> 263 + </div> 264 + 265 + <!-- Similar packages warning --> 266 + <div v-if="checkResult.similarPackages?.length && checkResult.available"> 267 + <div 268 + :class=" 269 + hasDangerousSimilarPackages 270 + ? 'bg-yellow-500/10 border-yellow-500/20' 271 + : 'bg-bg-subtle border-border' 272 + " 273 + class="p-4 border rounded-lg" 274 + > 275 + <p 276 + :class="hasDangerousSimilarPackages ? 'text-yellow-400' : 'text-fg-muted'" 277 + class="text-sm font-medium mb-3" 278 + > 279 + <span v-if="hasDangerousSimilarPackages"> 280 + Similar packages exist - npm may reject this name: 281 + </span> 282 + <span v-else> Related packages: </span> 283 + </p> 284 + <ul class="space-y-2"> 285 + <li 286 + v-for="pkg in checkResult.similarPackages.slice(0, 5)" 287 + :key="pkg.name" 288 + class="flex items-start gap-2" 289 + > 290 + <span 291 + v-if="pkg.similarity === 'exact-match'" 292 + class="i-carbon-warning-filled text-red-500 w-4 h-4 mt-0.5 shrink-0" 293 + aria-hidden="true" 294 + /> 295 + <span 296 + v-else-if="pkg.similarity === 'very-similar'" 297 + class="i-carbon-warning text-yellow-500 w-4 h-4 mt-0.5 shrink-0" 298 + aria-hidden="true" 299 + /> 300 + <span v-else class="w-4 h-4 shrink-0" /> 301 + <div class="min-w-0"> 302 + <NuxtLink 303 + :to="`/package/${pkg.name}`" 304 + class="font-mono text-sm text-fg hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 305 + target="_blank" 306 + > 307 + {{ pkg.name }} 308 + </NuxtLink> 309 + <p v-if="pkg.description" class="text-xs text-fg-subtle truncate"> 310 + {{ pkg.description }} 311 + </p> 312 + </div> 313 + </li> 314 + </ul> 315 + </div> 316 + </div> 317 + 318 + <!-- Error message --> 319 + <div 320 + v-if="publishError" 321 + role="alert" 322 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 323 + > 324 + {{ publishError }} 325 + </div> 326 + 327 + <!-- Actions --> 328 + <div v-if="checkResult.available && checkResult.valid" class="space-y-3"> 329 + <!-- Warning for unscoped packages --> 330 + <div 331 + v-if="!isScoped" 332 + class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 333 + > 334 + <p class="font-medium mb-1">Consider using a scoped package instead</p> 335 + <p class="text-xs text-yellow-400/80"> 336 + Unscoped package names are a shared resource. Only claim a name if you intend to 337 + publish and maintain a package. For personal or organizational projects, use a 338 + scoped name like 339 + <code class="font-mono">@{{ npmUser || 'username' }}/{{ packageName }}</code 340 + >. 341 + </p> 342 + </div> 343 + 344 + <!-- Not connected warning --> 345 + <div v-if="!isConnected" class="space-y-3"> 346 + <div 347 + class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 348 + > 349 + <p>Connect to the local connector to claim this package name.</p> 350 + </div> 351 + <button 352 + type="button" 353 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 354 + @click="connectorModalOpen = true" 355 + > 356 + Connect to Connector 357 + </button> 358 + </div> 359 + 360 + <!-- Claim button --> 361 + <div v-else class="space-y-3"> 362 + <p class="text-sm text-fg-muted"> 363 + This will publish a minimal placeholder package. 364 + </p> 365 + 366 + <!-- Expandable package.json preview --> 367 + <details class="border border-border rounded-md overflow-hidden"> 368 + <summary 369 + class="px-3 py-2 text-sm text-fg-muted bg-bg-subtle cursor-pointer hover:text-fg transition-colors select-none" 370 + > 371 + Preview package.json 372 + </summary> 373 + <pre class="p-3 text-xs font-mono text-fg-muted bg-[#0d0d0d] overflow-x-auto">{{ 374 + JSON.stringify(previewPackageJson, null, 2) 375 + }}</pre> 376 + </details> 377 + 378 + <button 379 + type="button" 380 + :disabled="isPublishing" 381 + class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 382 + @click="handleClaim" 383 + > 384 + {{ isPublishing ? 'Publishing…' : 'Claim Package Name' }} 385 + </button> 386 + </div> 387 + </div> 388 + 389 + <!-- Close button for unavailable/invalid --> 390 + <button 391 + v-if="!checkResult.available || !checkResult.valid" 392 + type="button" 393 + class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 394 + @click="open = false" 395 + > 396 + Close 397 + </button> 398 + </div> 399 + 400 + <!-- Error state --> 401 + <div v-else-if="publishError" class="space-y-4"> 402 + <div 403 + role="alert" 404 + class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 405 + > 406 + {{ publishError }} 407 + </div> 408 + <button 409 + type="button" 410 + class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 411 + @click="checkAvailability" 412 + > 413 + Retry 414 + </button> 415 + </div> 416 + </div> 417 + </div> 418 + </div> 419 + </Transition> 420 + </Teleport> 421 + 422 + <!-- Connector modal --> 423 + <ConnectorModal v-model:open="connectorModalOpen" /> 424 + </template>
+152 -13
app/pages/search.vue
··· 1 1 <script setup lang="ts"> 2 2 import { formatNumber } from '#imports' 3 3 import { debounce } from 'perfect-debounce' 4 + import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' 4 5 5 6 const route = useRoute() 6 7 const router = useRouter() ··· 153 154 hasInteracted.value = true 154 155 }) 155 156 157 + // Check if current query could be a valid package name 158 + const isValidPackageName = computed(() => isValidNewPackageName(query.value.trim())) 159 + 160 + // Check if package name is available (doesn't exist on npm) 161 + const packageAvailability = ref<{ name: string; available: boolean } | null>(null) 162 + 163 + // Debounced check for package availability 164 + const checkAvailability = debounce(async (name: string) => { 165 + if (!isValidNewPackageName(name)) { 166 + packageAvailability.value = null 167 + return 168 + } 169 + 170 + try { 171 + const exists = await checkPackageExists(name) 172 + // Only update if this is still the current query 173 + if (name === query.value.trim()) { 174 + packageAvailability.value = { name, available: !exists } 175 + } 176 + } catch { 177 + packageAvailability.value = null 178 + } 179 + }, 300) 180 + 181 + // Trigger availability check when query changes 182 + watch( 183 + query, 184 + q => { 185 + const trimmed = q.trim() 186 + if (isValidNewPackageName(trimmed)) { 187 + checkAvailability(trimmed) 188 + } else { 189 + packageAvailability.value = null 190 + } 191 + }, 192 + { immediate: true }, 193 + ) 194 + 195 + // Get connector state 196 + const { isConnected, npmUser, listOrgUsers } = useConnector() 197 + 198 + // Check if this is a scoped package and extract scope 199 + const packageScope = computed(() => { 200 + const q = query.value.trim() 201 + if (!q.startsWith('@')) return null 202 + const match = q.match(/^@([^/]+)\//) 203 + return match ? match[1] : null 204 + }) 205 + 206 + // Track org membership for scoped packages 207 + const orgMembership = ref<Record<string, boolean>>({}) 208 + 209 + // Check org membership when scope changes 210 + watch( 211 + [packageScope, isConnected, npmUser], 212 + async ([scope, connected, user]) => { 213 + if (!scope || !connected || !user) return 214 + // Skip if already checked 215 + if (scope in orgMembership.value) return 216 + 217 + try { 218 + const users = await listOrgUsers(scope) 219 + // Check if current user is in the org's user list 220 + if (users && user in users) { 221 + orgMembership.value[scope] = true 222 + } else { 223 + orgMembership.value[scope] = false 224 + } 225 + } catch { 226 + orgMembership.value[scope] = false 227 + } 228 + }, 229 + { immediate: true }, 230 + ) 231 + 232 + // Check if user can publish to scope (either their username or an org they're a member of) 233 + const canPublishToScope = computed(() => { 234 + const scope = packageScope.value 235 + if (!scope) return true // Unscoped package 236 + if (!npmUser.value) return false 237 + // Can publish if scope matches username 238 + if (scope.toLowerCase() === npmUser.value.toLowerCase()) return true 239 + // Can publish if user is a member of the org 240 + return orgMembership.value[scope] === true 241 + }) 242 + 243 + // Show claim prompt when valid name, available, connected, and has permission 244 + const showClaimPrompt = computed(() => { 245 + return ( 246 + isConnected.value && 247 + isValidPackageName.value && 248 + packageAvailability.value?.available === true && 249 + packageAvailability.value.name === query.value.trim() && 250 + canPublishToScope.value && 251 + status.value !== 'pending' 252 + ) 253 + }) 254 + 255 + // Modal state for claiming a package 256 + const claimModalOpen = ref(false) 257 + 156 258 useSeoMeta({ 157 259 title: () => (query.value ? `Search: ${query.value} - npmx` : 'Search Packages - npmx'), 158 260 }) ··· 192 294 v-model="inputValue" 193 295 type="search" 194 296 name="q" 195 - placeholder="search packages..." 297 + placeholder="search packages…" 196 298 autocomplete="off" 197 - class="w-full max-w-full bg-bg-subtle border border-border rounded-lg pl-8 pr-4 py-3 font-mono text-base text-fg placeholder:text-fg-subtle transition-all duration-300 focus:(border-border-hover outline-none) appearance-none" 299 + class="w-full max-w-full bg-bg-subtle border border-border rounded-lg pl-8 pr-4 py-3 font-mono text-base text-fg placeholder:text-fg-subtle transition-colors duration-300 focus:(border-border-hover outline-none) appearance-none" 198 300 @focus="isSearchFocused = true" 199 301 @blur="isSearchFocused = false" 200 302 /> ··· 208 310 </header> 209 311 210 312 <!-- Results area with container padding --> 211 - <div class="container py-6"> 313 + <div class="container pt-20 pb-6"> 212 314 <section v-if="query" aria-label="Search results"> 213 315 <!-- Initial loading (only after user interaction, not during view transition) --> 214 - <LoadingSpinner v-if="showSearching" text="Searching..." /> 316 + <LoadingSpinner v-if="showSearching" text="Searching…" /> 215 317 216 318 <div v-else-if="visibleResults"> 319 + <!-- Claim prompt - shown at top when valid name but no exact match --> 320 + <div 321 + v-if="showClaimPrompt && visibleResults.total > 0" 322 + class="mb-6 p-4 bg-bg-subtle border border-border rounded-lg flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4" 323 + > 324 + <div class="flex-1 min-w-0"> 325 + <p class="font-mono text-sm text-fg"> 326 + "<span class="text-fg font-medium">{{ query }}</span 327 + >" is not taken 328 + </p> 329 + <p class="text-xs text-fg-muted mt-0.5">Claim this package name on npm</p> 330 + </div> 331 + <button 332 + type="button" 333 + class="shrink-0 px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 334 + @click="claimModalOpen = true" 335 + > 336 + Claim "{{ query }}" 337 + </button> 338 + </div> 339 + 217 340 <p 218 341 v-if="visibleResults.total > 0" 219 342 role="status" 220 343 class="text-fg-muted text-sm mb-6 font-mono" 221 344 > 222 345 Found <span class="text-fg">{{ formatNumber(visibleResults.total) }}</span> packages 223 - <span v-if="status === 'pending'" class="text-fg-subtle">(updating...)</span> 346 + <span v-if="status === 'pending'" class="text-fg-subtle">(updating…)</span> 224 347 </p> 225 348 226 - <p 227 - v-else-if="status !== 'pending'" 228 - role="status" 229 - class="text-fg-muted py-12 text-center font-mono" 230 - > 231 - No packages found for "<span class="text-fg">{{ query }}</span 232 - >" 233 - </p> 349 + <!-- No results found --> 350 + <div v-else-if="status !== 'pending'" role="status" class="py-12 text-center"> 351 + <p class="text-fg-muted font-mono mb-6"> 352 + No packages found for "<span class="text-fg">{{ query }}</span 353 + >" 354 + </p> 355 + 356 + <!-- Offer to claim the package name if it's valid --> 357 + <div v-if="showClaimPrompt" class="max-w-md mx-auto"> 358 + <div class="p-4 bg-bg-subtle border border-border rounded-lg"> 359 + <p class="text-sm text-fg-muted mb-3">Want to claim this package name?</p> 360 + <button 361 + type="button" 362 + class="px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 363 + @click="claimModalOpen = true" 364 + > 365 + Claim "{{ query }}" 366 + </button> 367 + </div> 368 + </div> 369 + </div> 234 370 235 371 <PackageList 236 372 v-if="visibleResults.objects.length > 0" ··· 251 387 <p class="text-fg-subtle font-mono text-sm">Start typing to search packages</p> 252 388 </section> 253 389 </div> 390 + 391 + <!-- Claim package modal --> 392 + <ClaimPackageModal v-model:open="claimModalOpen" :package-name="query" /> 254 393 </main> 255 394 </template>
+188
app/utils/package-name.ts
··· 1 + import validatePackageName from 'validate-npm-package-name' 2 + 3 + /** 4 + * Normalize a package name for comparison by removing common variations. 5 + * This aims to mirror npm's typosquatting detection algorithm. 6 + */ 7 + export function normalizePackageName(name: string): string { 8 + // Remove scope if present 9 + const unscoped = name.startsWith('@') ? name.split('/')[1] || name : name 10 + 11 + // Normalize: lowercase, remove punctuation (.-_), remove 'js' and 'node' suffixes/prefixes 12 + return ( 13 + unscoped 14 + .toLowerCase() 15 + // Remove all punctuation 16 + .replace(/[.\-_]/g, '') 17 + // Remove common suffixes/prefixes 18 + .replace(/^(node|js)|(-?js|-?node)$/g, '') 19 + ) 20 + } 21 + 22 + /** 23 + * Calculate similarity between two strings using Levenshtein distance. 24 + */ 25 + export function levenshteinDistance(a: string, b: string): number { 26 + const matrix: number[][] = [] 27 + 28 + for (let i = 0; i <= b.length; i++) { 29 + matrix[i] = [i] 30 + } 31 + for (let j = 0; j <= a.length; j++) { 32 + matrix[0]![j] = j 33 + } 34 + 35 + for (let i = 1; i <= b.length; i++) { 36 + for (let j = 1; j <= a.length; j++) { 37 + if (b.charAt(i - 1) === a.charAt(j - 1)) { 38 + matrix[i]![j] = matrix[i - 1]![j - 1]! 39 + } else { 40 + matrix[i]![j] = Math.min( 41 + matrix[i - 1]![j - 1]! + 1, // substitution 42 + matrix[i]![j - 1]! + 1, // insertion 43 + matrix[i - 1]![j]! + 1, // deletion 44 + ) 45 + } 46 + } 47 + } 48 + 49 + return matrix[b.length]![a.length]! 50 + } 51 + 52 + export function isValidNewPackageName(name: string): boolean { 53 + if (!name) return false 54 + const result = validatePackageName(name) 55 + return result.validForNewPackages === true 56 + } 57 + 58 + export interface SimilarPackage { 59 + name: string 60 + description?: string 61 + similarity: 'exact-match' | 'very-similar' | 'similar' 62 + } 63 + 64 + export interface CheckNameResult { 65 + name: string 66 + available: boolean 67 + valid: boolean 68 + validationErrors?: string[] 69 + validationWarnings?: string[] 70 + similarPackages?: SimilarPackage[] 71 + } 72 + 73 + const NPM_REGISTRY = 'https://registry.npmjs.org' 74 + 75 + export async function checkPackageExists(name: string): Promise<boolean> { 76 + try { 77 + const encodedName = name.startsWith('@') 78 + ? `@${encodeURIComponent(name.slice(1))}` 79 + : encodeURIComponent(name) 80 + 81 + await $fetch(`${NPM_REGISTRY}/${encodedName}`, { 82 + method: 'HEAD', 83 + }) 84 + return true 85 + } catch { 86 + return false 87 + } 88 + } 89 + 90 + export async function findSimilarPackages(name: string): Promise<SimilarPackage[]> { 91 + const normalized = normalizePackageName(name) 92 + const similar: SimilarPackage[] = [] 93 + 94 + try { 95 + const searchResponse = await $fetch<{ 96 + objects: Array<{ 97 + package: { 98 + name: string 99 + description?: string 100 + } 101 + }> 102 + }>(`${NPM_REGISTRY}/-/v1/search?text=${encodeURIComponent(name)}&size=20`) 103 + 104 + for (const obj of searchResponse.objects) { 105 + const pkgName = obj.package.name 106 + const pkgNormalized = normalizePackageName(pkgName) 107 + 108 + // Skip if it's the exact same name 109 + if (pkgName === name) { 110 + similar.push({ 111 + name: pkgName, 112 + description: obj.package.description, 113 + similarity: 'exact-match', 114 + }) 115 + continue 116 + } 117 + 118 + // Check if normalized names match (high similarity) 119 + if (normalized === pkgNormalized) { 120 + similar.push({ 121 + name: pkgName, 122 + description: obj.package.description, 123 + similarity: 'very-similar', 124 + }) 125 + continue 126 + } 127 + 128 + // Check Levenshtein distance for similar names 129 + const distance = levenshteinDistance(normalized, pkgNormalized) 130 + const maxLen = Math.max(normalized.length, pkgNormalized.length) 131 + 132 + // Guard against division by zero 133 + if (maxLen === 0) continue 134 + 135 + const similarityScore = 1 - distance / maxLen 136 + 137 + if (similarityScore >= 0.8 || distance <= 2) { 138 + similar.push({ 139 + name: pkgName, 140 + description: obj.package.description, 141 + similarity: 'similar', 142 + }) 143 + } 144 + } 145 + 146 + // Sort by similarity (exact > very-similar > similar) 147 + const order = { 'exact-match': 0, 'very-similar': 1, 'similar': 2 } 148 + similar.sort((a, b) => order[a.similarity] - order[b.similarity]) 149 + 150 + return similar.slice(0, 10) // Limit to 10 results 151 + } catch { 152 + return [] 153 + } 154 + } 155 + 156 + export async function checkPackageName(name: string): Promise<CheckNameResult> { 157 + const validation = validatePackageName(name) 158 + const valid = validation.validForNewPackages === true 159 + 160 + const result: CheckNameResult = { 161 + name, 162 + available: false, 163 + valid, 164 + } 165 + 166 + if (validation.errors?.length) { 167 + result.validationErrors = validation.errors 168 + } 169 + if (validation.warnings?.length) { 170 + result.validationWarnings = validation.warnings 171 + } 172 + 173 + // If name is not valid for new packages, return early 174 + if (!valid) { 175 + return result 176 + } 177 + 178 + // Check if package exists and find similar packages in parallel 179 + const [exists, similarPackages] = await Promise.all([ 180 + checkPackageExists(name), 181 + findSimilarPackages(name), 182 + ]) 183 + 184 + result.available = !exists 185 + result.similarPackages = similarPackages 186 + 187 + return result 188 + }
+105
cli/src/npm-client.ts
··· 1 1 import process from 'node:process' 2 2 import { execFile } from 'node:child_process' 3 3 import { promisify } from 'node:util' 4 + import { mkdtemp, writeFile, rm } from 'node:fs/promises' 5 + import { tmpdir } from 'node:os' 6 + import { join } from 'node:path' 4 7 import validateNpmPackageName from 'validate-npm-package-name' 5 8 import { logCommand, logSuccess, logError } from './logger.ts' 6 9 ··· 286 289 validatePackageName(pkg) 287 290 return execNpm(['access', 'list', 'collaborators', pkg, '--json'], { silent: true }) 288 291 } 292 + 293 + /** 294 + * Initialize and publish a new package to claim the name. 295 + * Creates a minimal package.json in a temp directory and publishes it. 296 + * @param name Package name to claim 297 + * @param author npm username of the publisher (for author field) 298 + * @param otp Optional OTP for 2FA 299 + */ 300 + export async function packageInit( 301 + name: string, 302 + author?: string, 303 + otp?: string, 304 + ): Promise<NpmExecResult> { 305 + validatePackageName(name) 306 + 307 + // Create a temporary directory 308 + const tempDir = await mkdtemp(join(tmpdir(), 'npmx-init-')) 309 + 310 + try { 311 + // Determine access type based on whether it's a scoped package 312 + const isScoped = name.startsWith('@') 313 + const access = isScoped ? 'public' : undefined 314 + 315 + // Create minimal package.json 316 + const packageJson = { 317 + name, 318 + version: '0.0.0', 319 + description: `Placeholder for ${name}`, 320 + main: 'index.js', 321 + scripts: {}, 322 + keywords: [], 323 + author: author ? `${author} (https://www.npmjs.com/~${author})` : '', 324 + license: 'UNLICENSED', 325 + private: false, 326 + ...(access && { publishConfig: { access } }), 327 + } 328 + 329 + await writeFile(join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2)) 330 + 331 + // Create empty index.js 332 + await writeFile(join(tempDir, 'index.js'), '// Placeholder\n') 333 + 334 + // Build npm publish args 335 + const args = ['publish'] 336 + if (access) { 337 + args.push('--access', access) 338 + } 339 + 340 + // Run npm publish from the temp directory 341 + const npmArgs = otp ? [...args, '--otp', otp] : args 342 + 343 + // Log the command being run (hide OTP value for security) 344 + const displayCmd = otp ? `npm ${args.join(' ')} --otp ******` : `npm ${args.join(' ')}` 345 + logCommand(`${displayCmd} (in temp dir for ${name})`) 346 + 347 + try { 348 + const { stdout, stderr } = await execFileAsync('npm', npmArgs, { 349 + timeout: 60000, 350 + cwd: tempDir, 351 + env: { ...process.env, FORCE_COLOR: '0' }, 352 + }) 353 + 354 + logSuccess(`Published ${name}@0.0.0`) 355 + 356 + return { 357 + stdout: stdout.trim(), 358 + stderr: filterNpmWarnings(stderr), 359 + exitCode: 0, 360 + } 361 + } catch (error) { 362 + const err = error as { stdout?: string; stderr?: string; code?: number } 363 + const stderr = err.stderr?.trim() ?? String(error) 364 + const requiresOtp = detectOtpRequired(stderr) 365 + const authFailure = detectAuthFailure(stderr) 366 + 367 + if (requiresOtp) { 368 + logError('OTP required') 369 + } else if (authFailure) { 370 + logError('Authentication required - please run "npm login" and restart the connector') 371 + } else { 372 + logError(filterNpmWarnings(stderr).split('\n')[0] || 'Command failed') 373 + } 374 + 375 + return { 376 + stdout: err.stdout?.trim() ?? '', 377 + stderr: requiresOtp 378 + ? 'This operation requires a one-time password (OTP).' 379 + : authFailure 380 + ? 'Authentication failed. Please run "npm login" and restart the connector.' 381 + : filterNpmWarnings(stderr), 382 + exitCode: err.code ?? 1, 383 + requiresOtp, 384 + authFailure, 385 + } 386 + } 387 + } finally { 388 + // Clean up temp directory 389 + await rm(tempDir, { recursive: true, force: true }).catch(() => { 390 + // Ignore cleanup errors 391 + }) 392 + } 393 + }
+22 -14
cli/src/server.ts
··· 19 19 accessListCollaborators, 20 20 ownerAdd, 21 21 ownerRemove, 22 + packageInit, 22 23 type NpmExecResult, 23 24 } from './npm-client.ts' 24 25 ··· 245 246 } 246 247 247 248 // OTP can be passed directly in the request body for this execution 248 - const body = (await event.req.json()) as { otp?: string } | null 249 - const otp = body?.otp 249 + let otp: string | undefined 250 + try { 251 + const body = (await event.req.json()) as { otp?: string } | null 252 + otp = body?.otp 253 + } catch { 254 + // Empty body is fine - no OTP provided 255 + } 250 256 251 257 const approvedOps = state.operations.filter(op => op.status === 'approved') 252 258 const results: Array<{ id: string; result: NpmExecResult }> = [] ··· 508 514 switch (type) { 509 515 case 'org:add-user': 510 516 return orgAddUser( 511 - params.org!, 512 - params.user!, 517 + params.org, 518 + params.user, 513 519 params.role as 'developer' | 'admin' | 'owner', 514 520 otp, 515 521 ) 516 522 case 'org:rm-user': 517 - return orgRemoveUser(params.org!, params.user!, otp) 523 + return orgRemoveUser(params.org, params.user, otp) 518 524 case 'team:create': 519 - return teamCreate(params.scopeTeam!, otp) 525 + return teamCreate(params.scopeTeam, otp) 520 526 case 'team:destroy': 521 - return teamDestroy(params.scopeTeam!, otp) 527 + return teamDestroy(params.scopeTeam, otp) 522 528 case 'team:add-user': 523 - return teamAddUser(params.scopeTeam!, params.user!, otp) 529 + return teamAddUser(params.scopeTeam, params.user, otp) 524 530 case 'team:rm-user': 525 - return teamRemoveUser(params.scopeTeam!, params.user!, otp) 531 + return teamRemoveUser(params.scopeTeam, params.user, otp) 526 532 case 'access:grant': 527 533 return accessGrant( 528 534 params.permission as 'read-only' | 'read-write', 529 - params.scopeTeam!, 530 - params.pkg!, 535 + params.scopeTeam, 536 + params.pkg, 531 537 otp, 532 538 ) 533 539 case 'access:revoke': 534 - return accessRevoke(params.scopeTeam!, params.pkg!, otp) 540 + return accessRevoke(params.scopeTeam, params.pkg, otp) 535 541 case 'owner:add': 536 - return ownerAdd(params.user!, params.pkg!, otp) 542 + return ownerAdd(params.user, params.pkg, otp) 537 543 case 'owner:rm': 538 - return ownerRemove(params.user!, params.pkg!, otp) 544 + return ownerRemove(params.user, params.pkg, otp) 545 + case 'package:init': 546 + return packageInit(params.name, params.author, otp) 539 547 default: 540 548 return { 541 549 stdout: '',
+1
cli/src/types.ts
··· 21 21 | 'access:revoke' 22 22 | 'owner:add' 23 23 | 'owner:rm' 24 + | 'package:init' 24 25 25 26 export type OperationStatus = 26 27 | 'pending'
+72
test/unit/check-name.spec.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { 3 + normalizePackageName, 4 + levenshteinDistance, 5 + isValidNewPackageName, 6 + } from '../../app/utils/package-name' 7 + 8 + describe('package-name utilities', () => { 9 + describe('normalizePackageName', () => { 10 + it('normalizes simple names', () => { 11 + expect(normalizePackageName('lodash')).toBe('lodash') 12 + expect(normalizePackageName('my-package')).toBe('mypackage') 13 + expect(normalizePackageName('my_package')).toBe('mypackage') 14 + expect(normalizePackageName('my.package')).toBe('mypackage') 15 + }) 16 + 17 + it('removes js/node suffixes', () => { 18 + expect(normalizePackageName('lodash-js')).toBe('lodash') 19 + expect(normalizePackageName('lodashjs')).toBe('lodash') 20 + expect(normalizePackageName('lodash-node')).toBe('lodash') 21 + }) 22 + 23 + it('removes js/node prefixes', () => { 24 + expect(normalizePackageName('js-yaml')).toBe('yaml') 25 + expect(normalizePackageName('node-fetch')).toBe('fetch') 26 + }) 27 + 28 + it('handles scoped packages', () => { 29 + expect(normalizePackageName('@scope/my-package')).toBe('mypackage') 30 + expect(normalizePackageName('@vue/core')).toBe('core') 31 + }) 32 + 33 + it('handles edge cases', () => { 34 + expect(normalizePackageName('js')).toBe('') 35 + expect(normalizePackageName('node')).toBe('') 36 + }) 37 + }) 38 + 39 + describe('levenshteinDistance', () => { 40 + it('returns 0 for identical strings', () => { 41 + expect(levenshteinDistance('hello', 'hello')).toBe(0) 42 + }) 43 + 44 + it('returns correct distance for single character changes', () => { 45 + expect(levenshteinDistance('hello', 'hallo')).toBe(1) 46 + expect(levenshteinDistance('hello', 'hell')).toBe(1) 47 + expect(levenshteinDistance('hello', 'helloo')).toBe(1) 48 + }) 49 + 50 + it('handles empty strings', () => { 51 + expect(levenshteinDistance('', '')).toBe(0) 52 + expect(levenshteinDistance('hello', '')).toBe(5) 53 + expect(levenshteinDistance('', 'hello')).toBe(5) 54 + }) 55 + }) 56 + 57 + describe('isValidNewPackageName', () => { 58 + it('validates correct package names', () => { 59 + expect(isValidNewPackageName('my-package')).toBe(true) 60 + expect(isValidNewPackageName('my_package')).toBe(true) 61 + expect(isValidNewPackageName('@scope/package')).toBe(true) 62 + }) 63 + 64 + it('rejects invalid package names', () => { 65 + expect(isValidNewPackageName('.hidden')).toBe(false) 66 + expect(isValidNewPackageName('-invalid')).toBe(false) 67 + expect(isValidNewPackageName('UPPERCASE')).toBe(false) 68 + expect(isValidNewPackageName('has spaces')).toBe(false) 69 + expect(isValidNewPackageName('')).toBe(false) 70 + }) 71 + }) 72 + })