[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 user, orgs, and packages to nav (#98)

authored by

Philippe Serhal and committed by
GitHub
205556c6 f95f1bb3

+760 -37
+47 -29
app/components/AppHeader.vue
··· 9 9 showConnector: true, 10 10 }, 11 11 ) 12 + 13 + const { isConnected, npmUser } = useConnector() 12 14 </script> 13 15 14 16 <template> 15 17 <header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border"> 16 - <nav aria-label="Main navigation" class="container h-14 flex items-center justify-between"> 17 - <NuxtLink 18 - v-if="showLogo" 19 - to="/" 20 - aria-label="npmx home" 21 - class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded" 22 - > 23 - <span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx 24 - </NuxtLink> 25 - <!-- Spacer when logo is hidden --> 26 - <span v-else class="w-1" /> 18 + <nav aria-label="Main navigation" class="container h-14 flex items-center"> 19 + <!-- Left: Logo --> 20 + <div class="flex-shrink-0"> 21 + <NuxtLink 22 + v-if="showLogo" 23 + to="/" 24 + aria-label="npmx home" 25 + class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded" 26 + > 27 + <span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx 28 + </NuxtLink> 29 + <!-- Spacer when logo is hidden --> 30 + <span v-else class="w-1" /> 31 + </div> 27 32 28 - <ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0"> 33 + <!-- Center: Main nav items --> 34 + <ul class="flex-1 flex items-center justify-center gap-4 sm:gap-6 list-none m-0 p-0"> 29 35 <li class="flex items-center"> 30 36 <NuxtLink 31 37 to="/search" ··· 41 47 </kbd> 42 48 </NuxtLink> 43 49 </li> 44 - <li class="flex items-center"> 45 - <ClientOnly> 46 - <SettingsMenu /> 47 - </ClientOnly> 48 - </li> 49 - <li v-if="showConnector" class="flex items-center"> 50 - <ConnectorStatus /> 50 + 51 + <!-- Packages dropdown (when connected) --> 52 + <li v-if="isConnected && npmUser" class="flex items-center"> 53 + <HeaderPackagesDropdown :username="npmUser" /> 51 54 </li> 52 - <li v-else class="flex items-center"> 53 - <a 54 - href="https://github.com/npmx-dev/npmx.dev" 55 - rel="noopener noreferrer" 56 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 57 - aria-label="GitHub" 58 - > 59 - <span class="i-carbon-logo-github w-4 h-4" aria-hidden="true" /> 60 - <span class="hidden sm:inline" aria-hidden="true">github</span> 61 - </a> 55 + 56 + <!-- Orgs dropdown (when connected) --> 57 + <li v-if="isConnected && npmUser" class="flex items-center"> 58 + <HeaderOrgsDropdown :username="npmUser" /> 62 59 </li> 63 60 </ul> 61 + 62 + <!-- Right: User status + GitHub --> 63 + <div class="flex-shrink-0 flex items-center gap-6"> 64 + <ClientOnly> 65 + <SettingsMenu /> 66 + </ClientOnly> 67 + 68 + <div v-if="showConnector"> 69 + <ConnectorStatus /> 70 + </div> 71 + 72 + <a 73 + href="https://github.com/npmx-dev/npmx.dev" 74 + target="_blank" 75 + rel="noopener noreferrer" 76 + class="link-subtle" 77 + aria-label="GitHub repository" 78 + > 79 + <span class="i-carbon-logo-github w-5 h-5" aria-hidden="true" /> 80 + </a> 81 + </div> 64 82 </nav> 65 83 </header> 66 84 </template>
+14 -4
app/components/ConnectorStatus.client.vue
··· 12 12 const showModal = shallowRef(false) 13 13 const showTooltip = shallowRef(false) 14 14 15 - const statusText = computed(() => { 15 + const tooltipText = computed(() => { 16 16 if (isConnecting.value) return 'connecting…' 17 - if (isConnected.value && npmUser.value) return `connected as @${npmUser.value}` 18 17 if (isConnected.value) return 'connected' 19 18 return 'connect local CLI' 20 19 }) ··· 37 36 </script> 38 37 39 38 <template> 40 - <div class="relative"> 39 + <div class="relative flex items-center gap-2"> 40 + <!-- Username link (when connected) --> 41 + <NuxtLink 42 + v-if="isConnected && npmUser" 43 + :to="`/~${npmUser}`" 44 + class="link-subtle font-mono text-sm hidden sm:inline" 45 + > 46 + @{{ npmUser }} 47 + </NuxtLink> 48 + 41 49 <button 42 50 type="button" 43 51 class="relative flex items-center justify-center w-8 h-8 rounded-md transition-colors duration-200 hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" ··· 53 61 v-if="isConnected && avatar" 54 62 :src="avatar" 55 63 :alt="`${npmUser}'s avatar`" 64 + width="24" 65 + height="24" 56 66 class="w-6 h-6 rounded-full" 57 67 /> 58 68 <!-- Status dot (when not connected or no avatar) --> ··· 85 95 role="tooltip" 86 96 class="absolute right-0 top-full mt-2 px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-nowrap z-50" 87 97 > 88 - {{ statusText }} 98 + {{ tooltipText }} 89 99 </div> 90 100 </Transition> 91 101
+120
app/components/HeaderOrgsDropdown.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + username: string 4 + }>() 5 + 6 + const { listUserOrgs } = useConnector() 7 + 8 + const isOpen = ref(false) 9 + const isLoading = ref(false) 10 + const orgs = ref<string[]>([]) 11 + const hasLoaded = ref(false) 12 + const error = ref<string | null>(null) 13 + 14 + async function loadOrgs() { 15 + if (hasLoaded.value || isLoading.value) return 16 + 17 + isLoading.value = true 18 + error.value = null 19 + try { 20 + const orgList = await listUserOrgs() 21 + if (orgList) { 22 + // Already sorted alphabetically by server, take top 10 23 + orgs.value = orgList.slice(0, 10) 24 + } else { 25 + error.value = 'Failed to load organizations' 26 + } 27 + hasLoaded.value = true 28 + } catch { 29 + error.value = 'Failed to load organizations' 30 + } finally { 31 + isLoading.value = false 32 + } 33 + } 34 + 35 + function handleMouseEnter() { 36 + isOpen.value = true 37 + if (!hasLoaded.value) { 38 + loadOrgs() 39 + } 40 + } 41 + 42 + function handleMouseLeave() { 43 + isOpen.value = false 44 + } 45 + 46 + function handleKeydown(event: KeyboardEvent) { 47 + if (event.key === 'Escape' && isOpen.value) { 48 + isOpen.value = false 49 + } 50 + } 51 + </script> 52 + 53 + <template> 54 + <div 55 + class="relative" 56 + @mouseenter="handleMouseEnter" 57 + @mouseleave="handleMouseLeave" 58 + @keydown="handleKeydown" 59 + > 60 + <NuxtLink 61 + :to="`/~${username}/orgs`" 62 + class="link-subtle font-mono text-sm inline-flex items-center gap-1" 63 + > 64 + orgs 65 + <span 66 + class="i-carbon-chevron-down w-3 h-3 transition-transform duration-200" 67 + :class="{ 'rotate-180': isOpen }" 68 + aria-hidden="true" 69 + /> 70 + </NuxtLink> 71 + 72 + <Transition 73 + enter-active-class="transition-all duration-150" 74 + leave-active-class="transition-all duration-100" 75 + enter-from-class="opacity-0 translate-y-1" 76 + leave-to-class="opacity-0 translate-y-1" 77 + > 78 + <div v-if="isOpen" class="absolute right-0 top-full pt-2 w-56 z-50"> 79 + <div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden"> 80 + <div class="px-3 py-2 border-b border-border"> 81 + <span class="font-mono text-xs text-fg-subtle">Your Organizations</span> 82 + </div> 83 + 84 + <div v-if="isLoading" class="px-3 py-4 text-center"> 85 + <span class="text-fg-muted text-sm">Loading…</span> 86 + </div> 87 + 88 + <div v-else-if="error" class="px-3 py-4 text-center"> 89 + <span class="text-fg-muted text-sm">{{ error }}</span> 90 + </div> 91 + 92 + <ul v-else-if="orgs.length > 0" class="py-1 max-h-80 overflow-y-auto"> 93 + <li v-for="org in orgs" :key="org"> 94 + <NuxtLink 95 + :to="`/@${org}`" 96 + class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors" 97 + > 98 + @{{ org }} 99 + </NuxtLink> 100 + </li> 101 + </ul> 102 + 103 + <div v-else class="px-3 py-4 text-center"> 104 + <span class="text-fg-muted text-sm">No organizations found</span> 105 + </div> 106 + 107 + <div class="px-3 py-2 border-t border-border"> 108 + <NuxtLink 109 + :to="`/~${username}/orgs`" 110 + class="link-subtle font-mono text-xs inline-flex items-center gap-1" 111 + > 112 + View all 113 + <span class="i-carbon-arrow-right w-3 h-3" aria-hidden="true" /> 114 + </NuxtLink> 115 + </div> 116 + </div> 117 + </div> 118 + </Transition> 119 + </div> 120 + </template>
+120
app/components/HeaderPackagesDropdown.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + username: string 4 + }>() 5 + 6 + const { listUserPackages } = useConnector() 7 + 8 + const isOpen = ref(false) 9 + const isLoading = ref(false) 10 + const packages = ref<string[]>([]) 11 + const hasLoaded = ref(false) 12 + const error = ref<string | null>(null) 13 + 14 + async function loadPackages() { 15 + if (hasLoaded.value || isLoading.value) return 16 + 17 + isLoading.value = true 18 + error.value = null 19 + try { 20 + const pkgMap = await listUserPackages() 21 + if (pkgMap) { 22 + // Sort alphabetically and take top 10 23 + packages.value = Object.keys(pkgMap).sort().slice(0, 10) 24 + } else { 25 + error.value = 'Failed to load packages' 26 + } 27 + hasLoaded.value = true 28 + } catch { 29 + error.value = 'Failed to load packages' 30 + } finally { 31 + isLoading.value = false 32 + } 33 + } 34 + 35 + function handleMouseEnter() { 36 + isOpen.value = true 37 + if (!hasLoaded.value) { 38 + loadPackages() 39 + } 40 + } 41 + 42 + function handleMouseLeave() { 43 + isOpen.value = false 44 + } 45 + 46 + function handleKeydown(event: KeyboardEvent) { 47 + if (event.key === 'Escape' && isOpen.value) { 48 + isOpen.value = false 49 + } 50 + } 51 + </script> 52 + 53 + <template> 54 + <div 55 + class="relative" 56 + @mouseenter="handleMouseEnter" 57 + @mouseleave="handleMouseLeave" 58 + @keydown="handleKeydown" 59 + > 60 + <NuxtLink 61 + :to="`/~${username}`" 62 + class="link-subtle font-mono text-sm inline-flex items-center gap-1" 63 + > 64 + packages 65 + <span 66 + class="i-carbon-chevron-down w-3 h-3 transition-transform duration-200" 67 + :class="{ 'rotate-180': isOpen }" 68 + aria-hidden="true" 69 + /> 70 + </NuxtLink> 71 + 72 + <Transition 73 + enter-active-class="transition-all duration-150" 74 + leave-active-class="transition-all duration-100" 75 + enter-from-class="opacity-0 translate-y-1" 76 + leave-to-class="opacity-0 translate-y-1" 77 + > 78 + <div v-if="isOpen" class="absolute right-0 top-full pt-2 w-64 z-50"> 79 + <div class="bg-bg-elevated border border-border rounded-lg shadow-lg overflow-hidden"> 80 + <div class="px-3 py-2 border-b border-border"> 81 + <span class="font-mono text-xs text-fg-subtle">Your Packages</span> 82 + </div> 83 + 84 + <div v-if="isLoading" class="px-3 py-4 text-center"> 85 + <span class="text-fg-muted text-sm">Loading…</span> 86 + </div> 87 + 88 + <div v-else-if="error" class="px-3 py-4 text-center"> 89 + <span class="text-fg-muted text-sm">{{ error }}</span> 90 + </div> 91 + 92 + <ul v-else-if="packages.length > 0" class="py-1 max-h-80 overflow-y-auto"> 93 + <li v-for="pkg in packages" :key="pkg"> 94 + <NuxtLink 95 + :to="`/${pkg}`" 96 + class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors truncate" 97 + > 98 + {{ pkg }} 99 + </NuxtLink> 100 + </li> 101 + </ul> 102 + 103 + <div v-else class="px-3 py-4 text-center"> 104 + <span class="text-fg-muted text-sm">No packages found</span> 105 + </div> 106 + 107 + <div class="px-3 py-2 border-t border-border"> 108 + <NuxtLink 109 + :to="`/~${username}`" 110 + class="link-subtle font-mono text-xs inline-flex items-center gap-1" 111 + > 112 + View all 113 + <span class="i-carbon-arrow-right w-3 h-3" aria-hidden="true" /> 114 + </NuxtLink> 115 + </div> 116 + </div> 117 + </div> 118 + </Transition> 119 + </div> 120 + </template>
+15
app/composables/useConnector.ts
··· 350 350 return response?.success ? (response.data ?? null) : null 351 351 } 352 352 353 + async function listUserPackages(): Promise<Record<string, 'read-write' | 'read-only'> | null> { 354 + const response = 355 + await connectorFetch<ApiResponse<Record<string, 'read-write' | 'read-only'>>>( 356 + '/user/packages', 357 + ) 358 + return response?.success ? (response.data ?? null) : null 359 + } 360 + 361 + async function listUserOrgs(): Promise<string[] | null> { 362 + const response = await connectorFetch<ApiResponse<string[]>>('/user/orgs') 363 + return response?.success ? (response.data ?? null) : null 364 + } 365 + 353 366 // Computed helpers for operations 354 367 const pendingOperations = computed(() => 355 368 state.value.operations.filter(op => op.status === 'pending'), ··· 427 440 listOrgTeams, 428 441 listTeamUsers, 429 442 listPackageCollaborators, 443 + listUserPackages, 444 + listUserOrgs, 430 445 } 431 446 }) 432 447
app/pages/~[username].vue app/pages/~[username]/index.vue
+227
app/pages/~[username]/orgs.vue
··· 1 + <script setup lang="ts"> 2 + const route = useRoute('~username-orgs') 3 + 4 + const username = computed(() => route.params.username) 5 + 6 + const { isConnected, npmUser, listUserOrgs, listOrgUsers } = useConnector() 7 + 8 + // Only allow viewing your own orgs page 9 + const isOwnProfile = computed(() => { 10 + return isConnected.value && npmUser.value?.toLowerCase() === username.value.toLowerCase() 11 + }) 12 + 13 + interface OrgInfo { 14 + name: string 15 + role: 'developer' | 'admin' | 'owner' | null 16 + packageCount: number | null 17 + isLoadingDetails: boolean 18 + } 19 + 20 + const isLoading = ref(true) 21 + const orgs = ref<OrgInfo[]>([]) 22 + const error = ref<string | null>(null) 23 + 24 + async function loadOrgDetails(org: OrgInfo) { 25 + org.isLoadingDetails = true 26 + 27 + // Fetch package count using our server API (proxies to npm registry) 28 + try { 29 + const response = await $fetch<{ count: number }>( 30 + `/api/registry/org/${encodeURIComponent(org.name)}/packages`, 31 + { timeout: 5000 }, 32 + ) 33 + org.packageCount = response.count 34 + } catch { 35 + org.packageCount = null 36 + } 37 + 38 + // Fetch user's role in this org 39 + try { 40 + const users = await listOrgUsers(org.name) 41 + if (users && npmUser.value) { 42 + const lowerUser = npmUser.value.toLowerCase() 43 + const entry = Object.entries(users).find(([k]) => k.toLowerCase() === lowerUser) 44 + org.role = entry?.[1] ?? null 45 + } 46 + } catch { 47 + org.role = null 48 + } 49 + 50 + org.isLoadingDetails = false 51 + } 52 + 53 + async function loadOrgs() { 54 + if (!isOwnProfile.value) { 55 + isLoading.value = false 56 + return 57 + } 58 + 59 + isLoading.value = true 60 + error.value = null 61 + 62 + try { 63 + const orgList = await listUserOrgs() 64 + if (orgList) { 65 + orgs.value = orgList.map(name => ({ 66 + name, 67 + role: null, 68 + packageCount: null, 69 + isLoadingDetails: true, 70 + })) 71 + 72 + // Load details for each org in parallel 73 + await Promise.all(orgs.value.map(org => loadOrgDetails(org))) 74 + } else { 75 + error.value = 'Failed to load organizations' 76 + } 77 + } catch (e) { 78 + error.value = e instanceof Error ? e.message : 'Failed to load organizations' 79 + } finally { 80 + isLoading.value = false 81 + } 82 + } 83 + 84 + // Load on mount and when connection status changes 85 + watch(isOwnProfile, loadOrgs, { immediate: true }) 86 + 87 + function getRoleBadgeClass(role: string | null): string { 88 + switch (role) { 89 + case 'owner': 90 + return 'bg-purple-500/20 text-purple-300' 91 + case 'admin': 92 + return 'bg-blue-500/20 text-blue-300' 93 + case 'developer': 94 + return 'bg-green-500/20 text-green-300' 95 + default: 96 + return 'bg-fg-subtle/20 text-fg-muted' 97 + } 98 + } 99 + 100 + useSeoMeta({ 101 + title: () => `@${username.value} Organizations - npmx`, 102 + description: () => `npm organizations for ${username.value}`, 103 + }) 104 + </script> 105 + 106 + <template> 107 + <main class="container py-8 sm:py-12 w-full"> 108 + <!-- Header --> 109 + <header class="mb-8 pb-8 border-b border-border"> 110 + <div class="flex items-center gap-4 mb-4"> 111 + <!-- Avatar placeholder --> 112 + <div 113 + class="w-16 h-16 rounded-full bg-bg-muted border border-border flex items-center justify-center" 114 + aria-hidden="true" 115 + > 116 + <span class="text-2xl text-fg-subtle font-mono">{{ 117 + username.charAt(0).toUpperCase() 118 + }}</span> 119 + </div> 120 + <div> 121 + <h1 class="font-mono text-2xl sm:text-3xl font-medium">@{{ username }}</h1> 122 + <p class="text-fg-muted text-sm mt-1">Organizations</p> 123 + </div> 124 + </div> 125 + 126 + <!-- Back link --> 127 + <nav aria-label="Navigation"> 128 + <NuxtLink 129 + :to="`/~${username}`" 130 + class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 131 + > 132 + <span class="i-carbon-arrow-left w-4 h-4" aria-hidden="true" /> 133 + Back to profile 134 + </NuxtLink> 135 + </nav> 136 + </header> 137 + 138 + <!-- Not connected state --> 139 + <ClientOnly> 140 + <div v-if="!isConnected" class="py-12 text-center"> 141 + <p class="text-fg-muted mb-4">Connect the local CLI to view your organizations.</p> 142 + <p class="text-fg-subtle text-sm"> 143 + Run <code class="font-mono bg-bg-subtle px-1.5 py-0.5 rounded">npx @npmx.dev/cli</code> to 144 + get started. 145 + </p> 146 + </div> 147 + 148 + <!-- Not own profile state --> 149 + <div v-else-if="!isOwnProfile" class="py-12 text-center"> 150 + <p class="text-fg-muted">You can only view your own organizations.</p> 151 + <NuxtLink :to="`/~${npmUser}/orgs`" class="btn mt-4">View your organizations</NuxtLink> 152 + </div> 153 + 154 + <!-- Loading state --> 155 + <LoadingSpinner v-else-if="isLoading" text="Loading organizations..." /> 156 + 157 + <!-- Error state --> 158 + <div v-else-if="error" role="alert" class="py-12 text-center"> 159 + <p class="text-fg-muted mb-4">{{ error }}</p> 160 + <button type="button" class="btn" @click="loadOrgs">Try again</button> 161 + </div> 162 + 163 + <!-- Empty state --> 164 + <div v-else-if="orgs.length === 0" class="py-12 text-center"> 165 + <p class="text-fg-muted">No organizations found.</p> 166 + <p class="text-fg-subtle text-sm mt-2"> 167 + Organizations are detected from your scoped packages. 168 + </p> 169 + </div> 170 + 171 + <!-- Orgs list --> 172 + <section v-else aria-label="Organizations"> 173 + <h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-4"> 174 + {{ orgs.length }} Organization{{ orgs.length === 1 ? '' : 's' }} 175 + </h2> 176 + 177 + <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 178 + <li v-for="org in orgs" :key="org.name"> 179 + <NuxtLink 180 + :to="`/@${org.name}`" 181 + class="block p-5 bg-bg-subtle border border-border rounded-lg hover:border-fg-subtle transition-colors h-full" 182 + > 183 + <div class="flex items-start gap-4 mb-4"> 184 + <!-- Org avatar --> 185 + <div 186 + class="w-14 h-14 rounded-lg bg-bg-muted border border-border flex items-center justify-center flex-shrink-0" 187 + aria-hidden="true" 188 + > 189 + <span class="text-2xl text-fg-subtle font-mono">{{ 190 + org.name.charAt(0).toUpperCase() 191 + }}</span> 192 + </div> 193 + <div class="min-w-0 flex-1"> 194 + <h3 class="font-mono text-lg text-fg truncate">@{{ org.name }}</h3> 195 + <!-- Role badge --> 196 + <span 197 + v-if="org.role" 198 + class="inline-block mt-1 px-2 py-0.5 text-xs font-mono rounded" 199 + :class="getRoleBadgeClass(org.role)" 200 + > 201 + {{ org.role }} 202 + </span> 203 + <span 204 + v-else-if="org.isLoadingDetails" 205 + class="skeleton inline-block mt-1 h-5 w-16 rounded" 206 + /> 207 + </div> 208 + </div> 209 + 210 + <!-- Stats --> 211 + <div class="flex items-center gap-4 text-sm text-fg-muted"> 212 + <div class="flex items-center gap-1.5"> 213 + <span class="i-carbon-cube w-4 h-4" aria-hidden="true" /> 214 + <span v-if="org.packageCount !== null"> 215 + {{ org.packageCount }} package{{ org.packageCount === 1 ? '' : 's' }} 216 + </span> 217 + <span v-else-if="org.isLoadingDetails" class="skeleton inline-block h-4 w-20" /> 218 + <span v-else class="text-fg-subtle">—</span> 219 + </div> 220 + </div> 221 + </NuxtLink> 222 + </li> 223 + </ul> 224 + </section> 225 + </ClientOnly> 226 + </main> 227 + </template>
+10
cli/src/npm-client.ts
··· 314 314 } 315 315 316 316 /** 317 + * Lists all packages that a user has access to publish. 318 + * Uses `npm access list packages @{user} --json` 319 + * Returns a map of package name to permission level 320 + */ 321 + export async function listUserPackages(user: string): Promise<NpmExecResult> { 322 + validateUsername(user) 323 + return execNpm(['access', 'list', 'packages', `@${user}`, '--json'], { silent: true }) 324 + } 325 + 326 + /** 317 327 * Initialize and publish a new package to claim the name. 318 328 * Creates a minimal package.json in a temp directory and publishes it. 319 329 * @param name Package name to claim
+109 -4
cli/src/server.ts
··· 23 23 ownerAdd, 24 24 ownerRemove, 25 25 packageInit, 26 + listUserPackages, 26 27 type NpmExecResult, 27 28 } from './npm-client.ts' 28 29 import { ··· 234 235 } 235 236 236 237 if (operation.status !== 'pending') { 237 - throw new HTTPError({ statusCode: 400, message: 'Operation is not pending' }) 238 + throw new HTTPError({ 239 + statusCode: 400, 240 + message: 'Operation is not pending', 241 + }) 238 242 } 239 243 240 244 operation.status = 'approved' ··· 282 286 } 283 287 284 288 if (operation.status !== 'failed') { 285 - throw new HTTPError({ statusCode: 400, message: 'Only failed operations can be retried' }) 289 + throw new HTTPError({ 290 + statusCode: 400, 291 + message: 'Only failed operations can be retried', 292 + }) 286 293 } 287 294 288 295 // Reset the operation for retry ··· 337 344 // Dependency failed - skip this one too 338 345 if (failedIds.has(op.dependsOn)) { 339 346 op.status = 'failed' 340 - op.result = { stdout: '', stderr: 'Skipped: dependency failed', exitCode: 1 } 347 + op.result = { 348 + stdout: '', 349 + stderr: 'Skipped: dependency failed', 350 + exitCode: 1, 351 + } 341 352 failedIds.add(op.id) 342 353 results.push({ id: op.id, result: op.result }) 343 354 return false ··· 410 421 411 422 const operation = state.operations[index] 412 423 if (!operation || operation.status === 'running') { 413 - throw new HTTPError({ statusCode: 400, message: 'Cannot cancel running operation' }) 424 + throw new HTTPError({ 425 + statusCode: 400, 426 + message: 'Cannot cancel running operation', 427 + }) 414 428 } 415 429 416 430 state.operations.splice(index, 1) ··· 586 600 return { 587 601 success: false, 588 602 error: 'Failed to parse collaborators', 603 + } as ApiResponse 604 + } 605 + }) 606 + 607 + // User-specific endpoints 608 + 609 + app.get('/user/packages', async event => { 610 + const auth = event.req.headers.get('authorization') 611 + if (!validateToken(auth)) { 612 + throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) 613 + } 614 + 615 + const npmUser = state.session.npmUser 616 + if (!npmUser) { 617 + return { 618 + success: false, 619 + error: 'Not logged in to npm', 620 + } as ApiResponse 621 + } 622 + 623 + const result = await listUserPackages(npmUser) 624 + if (result.exitCode !== 0) { 625 + return { 626 + success: false, 627 + error: result.stderr || 'Failed to list user packages', 628 + } as ApiResponse 629 + } 630 + 631 + try { 632 + // npm access list packages returns { "packageName": "read-write" | "read-only" } 633 + const packages = JSON.parse(result.stdout) as Record<string, 'read-write' | 'read-only'> 634 + return { 635 + success: true, 636 + data: packages, 637 + } as ApiResponse 638 + } catch { 639 + return { 640 + success: false, 641 + error: 'Failed to parse user packages', 642 + } as ApiResponse 643 + } 644 + }) 645 + 646 + app.get('/user/orgs', async event => { 647 + const auth = event.req.headers.get('authorization') 648 + if (!validateToken(auth)) { 649 + throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) 650 + } 651 + 652 + const npmUser = state.session.npmUser 653 + if (!npmUser) { 654 + return { 655 + success: false, 656 + error: 'Not logged in to npm', 657 + } as ApiResponse 658 + } 659 + 660 + // Get user's packages and extract org names from scoped packages 661 + const result = await listUserPackages(npmUser) 662 + if (result.exitCode !== 0) { 663 + return { 664 + success: false, 665 + error: result.stderr || 'Failed to list user packages', 666 + } as ApiResponse 667 + } 668 + 669 + try { 670 + const packages = JSON.parse(result.stdout) as Record<string, string> 671 + const orgs = new Set<string>() 672 + 673 + // Extract org names from scoped packages (e.g., @myorg/mypackage -> myorg) 674 + for (const pkgName of Object.keys(packages)) { 675 + if (pkgName.startsWith('@')) { 676 + const match = pkgName.match(/^@([^/]+)\//) 677 + if (match && match[1]) { 678 + // Exclude the user's own scope (personal packages) 679 + if (match[1].toLowerCase() !== npmUser.toLowerCase()) { 680 + orgs.add(match[1]) 681 + } 682 + } 683 + } 684 + } 685 + 686 + return { 687 + success: true, 688 + data: Array.from(orgs).sort(), 689 + } as ApiResponse 690 + } catch { 691 + return { 692 + success: false, 693 + error: 'Failed to parse user orgs', 589 694 } as ApiResponse 590 695 } 591 696 })
+54
server/api/registry/org/[org]/packages.get.ts
··· 1 + import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' 2 + 3 + const NPM_REGISTRY = 'https://registry.npmjs.org' 4 + 5 + // Validation pattern for npm org names (alphanumeric with hyphens) 6 + const NPM_ORG_NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i 7 + 8 + function validateOrgName(name: string): void { 9 + if (!name || name.length > 50 || !NPM_ORG_NAME_RE.test(name)) { 10 + throw createError({ 11 + statusCode: 400, 12 + message: `Invalid org name: ${name}`, 13 + }) 14 + } 15 + } 16 + 17 + export default defineCachedEventHandler( 18 + async event => { 19 + const org = getRouterParam(event, 'org') 20 + 21 + if (!org) { 22 + throw createError({ 23 + statusCode: 400, 24 + message: 'Org name is required', 25 + }) 26 + } 27 + 28 + validateOrgName(org) 29 + 30 + try { 31 + const data = await $fetch<Record<string, string>>( 32 + `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, 33 + ) 34 + return { 35 + packages: Object.keys(data), 36 + count: Object.keys(data).length, 37 + } 38 + } catch { 39 + // Org doesn't exist or has no packages 40 + return { 41 + packages: [], 42 + count: 0, 43 + } 44 + } 45 + }, 46 + { 47 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 48 + swr: true, 49 + getKey: event => { 50 + const org = getRouterParam(event, 'org') ?? '' 51 + return `org-packages:v1:${org}` 52 + }, 53 + }, 54 + )
+44
test/unit/cli-server.spec.ts
··· 29 29 expect(response.status).toBe(401) 30 30 }) 31 31 }) 32 + 33 + describe('GET /user/packages', () => { 34 + it('returns 401 without auth token', async () => { 35 + const app = createConnectorApp(TEST_TOKEN) 36 + 37 + const response = await app.fetch(new Request('http://localhost/user/packages')) 38 + 39 + expect(response.status).toBe(401) 40 + }) 41 + 42 + it('returns 401 with invalid auth token', async () => { 43 + const app = createConnectorApp(TEST_TOKEN) 44 + 45 + const response = await app.fetch( 46 + new Request('http://localhost/user/packages', { 47 + headers: { Authorization: 'Bearer wrong-token' }, 48 + }), 49 + ) 50 + 51 + expect(response.status).toBe(401) 52 + }) 53 + }) 54 + 55 + describe('GET /user/orgs', () => { 56 + it('returns 401 without auth token', async () => { 57 + const app = createConnectorApp(TEST_TOKEN) 58 + 59 + const response = await app.fetch(new Request('http://localhost/user/orgs')) 60 + 61 + expect(response.status).toBe(401) 62 + }) 63 + 64 + it('returns 401 with invalid auth token', async () => { 65 + const app = createConnectorApp(TEST_TOKEN) 66 + 67 + const response = await app.fetch( 68 + new Request('http://localhost/user/orgs', { 69 + headers: { Authorization: 'Bearer wrong-token' }, 70 + }), 71 + ) 72 + 73 + expect(response.status).toBe(401) 74 + }) 75 + }) 32 76 })