[READ-ONLY] a fast, modern browser for the npm registry
at main 458 lines 14 kB view raw
1import type { PendingOperation, OperationStatus, OperationType } from '#cli/types' 2import { $fetch } from 'ofetch' 3 4export interface NewOperation { 5 type: OperationType 6 params: Record<string, string> 7 description: string 8 command: string 9 /** ID of operation this depends on (must complete successfully first) */ 10 dependsOn?: string 11} 12 13interface ApiResponse<T = unknown> { 14 success: boolean 15 data?: T 16 error?: string 17} 18 19export interface ConnectorState { 20 /** Whether we're currently connected to the local connector */ 21 connected: boolean 22 /** Whether we're attempting to connect */ 23 connecting: boolean 24 /** The npm username if connected and authenticated */ 25 npmUser: string | null 26 /** Base64 data URL of the user's avatar */ 27 avatar: string | null 28 /** Pending operations queue */ 29 operations: PendingOperation[] 30 /** Last connection error message */ 31 error: string | null 32 /** Timestamp of last completed execution (for triggering data refreshes) */ 33 lastExecutionTime: number | null 34} 35 36interface ConnectResponse { 37 success: boolean 38 data?: { 39 npmUser: string | null 40 avatar: string | null 41 connectedAt: number 42 } 43 error?: string 44} 45 46interface StateResponse { 47 success: boolean 48 data?: { 49 npmUser: string | null 50 avatar: string | null 51 operations: PendingOperation[] 52 } 53 error?: string 54} 55 56const STORAGE_KEY = 'npmx-connector' 57const DEFAULT_PORT = 31415 58 59export const useConnector = createSharedComposable(function useConnector() { 60 const { settings } = useSettings() 61 62 // Persisted connection config 63 const config = useState<{ token: string; port: number } | null>('connector-config', () => null) 64 65 // Connection state 66 const state = useState<ConnectorState>('connector-state', () => ({ 67 connected: false, 68 connecting: false, 69 npmUser: null, 70 avatar: null, 71 operations: [], 72 error: null, 73 lastExecutionTime: null, 74 })) 75 76 const baseUrl = computed(() => `http://127.0.0.1:${config.value?.port ?? DEFAULT_PORT}`) 77 78 const route = useRoute() 79 const router = useRouter() 80 81 onMounted(() => { 82 const urlToken = route.query.token as string | undefined 83 const urlPort = route.query.port as string | undefined 84 85 if (urlToken) { 86 const { token: _, port: __, ...cleanQuery } = route.query 87 router.replace({ query: cleanQuery }) 88 89 // Connect with URL params 90 const port = urlPort ? Number.parseInt(urlPort, 10) : DEFAULT_PORT 91 connect(urlToken, port) 92 return 93 } 94 95 const stored = localStorage.getItem(STORAGE_KEY) 96 if (stored) { 97 try { 98 config.value = JSON.parse(stored) 99 // Auto-reconnect if we have stored credentials 100 if (config.value) { 101 reconnect() 102 } 103 } catch { 104 localStorage.removeItem(STORAGE_KEY) 105 } 106 } 107 }) 108 109 async function connect(token: string, port: number = DEFAULT_PORT): Promise<boolean> { 110 state.value.connecting = true 111 state.value.error = null 112 113 try { 114 const response = await $fetch<ConnectResponse>(`http://127.0.0.1:${port}/connect`, { 115 method: 'POST', 116 body: { token }, 117 timeout: 5000, 118 }) 119 120 if (response.success && response.data) { 121 config.value = { token, port } 122 localStorage.setItem(STORAGE_KEY, JSON.stringify(config.value)) 123 124 state.value.connected = true 125 state.value.npmUser = response.data.npmUser 126 state.value.avatar = response.data.avatar 127 state.value.error = null 128 129 // Fetch full state after connecting 130 await refreshState() 131 return true 132 } else { 133 state.value.error = response.error ?? 'Connection failed' 134 return false 135 } 136 } catch (err) { 137 const message = err instanceof Error ? err.message : 'Connection failed' 138 if ( 139 message.includes('fetch') || 140 message.includes('network') || 141 message.includes('ECONNREFUSED') 142 ) { 143 state.value.error = 'Could not reach connector. Is it running?' 144 } else if (message.includes('401') || message.includes('Unauthorized')) { 145 state.value.error = 'Invalid token' 146 } else { 147 state.value.error = message 148 } 149 return false 150 } finally { 151 state.value.connecting = false 152 } 153 } 154 155 async function reconnect(): Promise<boolean> { 156 if (!config.value) return false 157 return connect(config.value.token, config.value.port) 158 } 159 160 function disconnect() { 161 config.value = null 162 localStorage.removeItem(STORAGE_KEY) 163 state.value = { 164 connected: false, 165 connecting: false, 166 npmUser: null, 167 avatar: null, 168 operations: [], 169 error: null, 170 lastExecutionTime: null, 171 } 172 } 173 174 async function refreshState(): Promise<void> { 175 if (!config.value) return 176 177 try { 178 const response = await $fetch<StateResponse>(`${baseUrl.value}/state`, { 179 headers: { 180 Authorization: `Bearer ${config.value.token}`, 181 }, 182 timeout: 5000, 183 }) 184 185 if (response.success && response.data) { 186 state.value.npmUser = response.data.npmUser 187 state.value.avatar = response.data.avatar 188 state.value.operations = response.data.operations 189 state.value.connected = true 190 } 191 } catch { 192 // Connection lost 193 state.value.connected = false 194 state.value.error = 'Connection lost' 195 } 196 } 197 198 async function connectorFetch<T>( 199 path: string, 200 options: { method?: 'GET' | 'POST' | 'DELETE'; body?: Record<string, unknown> } = {}, 201 ): Promise<T | null> { 202 if (!config.value) return null 203 204 try { 205 const response = await $fetch(`${baseUrl.value}${path}`, { 206 method: options.method ?? 'GET', 207 headers: { 208 Authorization: `Bearer ${config.value.token}`, 209 }, 210 body: options.body, 211 timeout: 30000, 212 }) 213 return response as T 214 } catch (err) { 215 state.value.error = err instanceof Error ? err.message : 'Request failed' 216 return null 217 } 218 } 219 220 // Operation management 221 222 async function addOperation(operation: NewOperation): Promise<PendingOperation | null> { 223 const response = await connectorFetch<ApiResponse<PendingOperation>>('/operations', { 224 method: 'POST', 225 body: operation as unknown as Record<string, unknown>, 226 }) 227 if (response?.success && response.data) { 228 await refreshState() 229 return response.data 230 } 231 return null 232 } 233 234 async function addOperations(operations: NewOperation[]): Promise<PendingOperation[]> { 235 const response = await connectorFetch<ApiResponse<PendingOperation[]>>('/operations/batch', { 236 method: 'POST', 237 body: operations as unknown as Record<string, unknown>, 238 }) 239 if (response?.success && response.data) { 240 await refreshState() 241 return response.data 242 } 243 return [] 244 } 245 246 async function removeOperation(id: string): Promise<boolean> { 247 const response = await connectorFetch<ApiResponse>(`/operations?id=${id}`, { 248 method: 'DELETE', 249 }) 250 if (response?.success) { 251 await refreshState() 252 return true 253 } 254 return false 255 } 256 257 async function clearOperations(): Promise<number> { 258 const response = await connectorFetch<ApiResponse<{ removed: number }>>('/operations/all', { 259 method: 'DELETE', 260 }) 261 if (response?.success && response.data) { 262 await refreshState() 263 return response.data.removed 264 } 265 return 0 266 } 267 268 async function approveOperation(id: string): Promise<boolean> { 269 const response = await connectorFetch<ApiResponse<PendingOperation>>(`/approve?id=${id}`, { 270 method: 'POST', 271 }) 272 if (response?.success) { 273 await refreshState() 274 return true 275 } 276 return false 277 } 278 279 async function retryOperation(id: string): Promise<boolean> { 280 const response = await connectorFetch<ApiResponse<PendingOperation>>(`/retry?id=${id}`, { 281 method: 'POST', 282 }) 283 if (response?.success) { 284 await refreshState() 285 return true 286 } 287 return false 288 } 289 290 async function approveAll(): Promise<number> { 291 const response = await connectorFetch<ApiResponse<{ approved: number }>>('/approve-all', { 292 method: 'POST', 293 }) 294 if (response?.success && response.data) { 295 await refreshState() 296 return response.data.approved 297 } 298 return 0 299 } 300 301 async function executeOperations( 302 otp?: string, 303 ): Promise<{ success: boolean; otpRequired?: boolean }> { 304 const response = await connectorFetch< 305 ApiResponse<{ results: unknown[]; otpRequired?: boolean }> 306 >('/execute', { 307 method: 'POST', 308 body: { 309 otp, 310 interactive: !otp, 311 openUrls: settings.value.connector.autoOpenURL, 312 }, 313 }) 314 if (response?.success) { 315 await refreshState() 316 // Update timestamp to trigger data refreshes in panels 317 state.value.lastExecutionTime = Date.now() 318 return { 319 success: true, 320 otpRequired: response.data?.otpRequired, 321 } 322 } 323 return { success: false } 324 } 325 326 // Data fetching functions 327 328 async function listOrgUsers( 329 org: string, 330 ): Promise<Record<string, 'developer' | 'admin' | 'owner'> | null> { 331 const response = await connectorFetch< 332 ApiResponse<Record<string, 'developer' | 'admin' | 'owner'>> 333 >(`/org/${encodeURIComponent(org)}/users`) 334 return response?.success ? (response.data ?? null) : null 335 } 336 337 async function listOrgTeams(org: string): Promise<string[] | null> { 338 const response = await connectorFetch<ApiResponse<string[]>>( 339 `/org/${encodeURIComponent(org)}/teams`, 340 ) 341 return response?.success ? (response.data ?? null) : null 342 } 343 344 async function listTeamUsers(scopeTeam: string): Promise<string[] | null> { 345 const response = await connectorFetch<ApiResponse<string[]>>( 346 `/team/${encodeURIComponent(scopeTeam)}/users`, 347 ) 348 return response?.success ? (response.data ?? null) : null 349 } 350 351 async function listPackageCollaborators( 352 pkg: string, 353 ): Promise<Record<string, 'read-only' | 'read-write'> | null> { 354 const response = await connectorFetch<ApiResponse<Record<string, 'read-only' | 'read-write'>>>( 355 `/package/${encodeURIComponent(pkg)}/collaborators`, 356 ) 357 return response?.success ? (response.data ?? null) : null 358 } 359 360 async function listUserPackages(): Promise<Record<string, 'read-write' | 'read-only'> | null> { 361 const response = 362 await connectorFetch<ApiResponse<Record<string, 'read-write' | 'read-only'>>>( 363 '/user/packages', 364 ) 365 return response?.success ? (response.data ?? null) : null 366 } 367 368 async function listUserOrgs(): Promise<string[] | null> { 369 const response = await connectorFetch<ApiResponse<string[]>>('/user/orgs') 370 return response?.success ? (response.data ?? null) : null 371 } 372 373 // Computed helpers for operations 374 const pendingOperations = computed(() => 375 state.value.operations.filter(op => op.status === 'pending'), 376 ) 377 const approvedOperations = computed(() => 378 state.value.operations.filter(op => op.status === 'approved'), 379 ) 380 /** Operations that are done (completed, or failed without needing OTP/auth retry) */ 381 const completedOperations = computed(() => 382 state.value.operations.filter( 383 op => 384 op.status === 'completed' || 385 (op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure), 386 ), 387 ) 388 /** Operations that are still active (pending, approved, running, or failed needing OTP/auth retry) */ 389 const activeOperations = computed(() => 390 state.value.operations.filter( 391 op => 392 op.status === 'pending' || 393 op.status === 'approved' || 394 op.status === 'running' || 395 (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)), 396 ), 397 ) 398 const hasOperations = computed(() => state.value.operations.length > 0) 399 const hasPendingOperations = computed(() => pendingOperations.value.length > 0) 400 const hasApprovedOperations = computed(() => approvedOperations.value.length > 0) 401 const hasActiveOperations = computed(() => activeOperations.value.length > 0) 402 const hasCompletedOperations = computed(() => completedOperations.value.length > 0) 403 404 return { 405 // State 406 state: readonly(state), 407 config: readonly(config), 408 409 // Computed - connection 410 isConnected: computed(() => state.value.connected), 411 isConnecting: computed(() => state.value.connecting), 412 npmUser: computed(() => state.value.npmUser), 413 avatar: computed(() => state.value.avatar), 414 error: computed(() => state.value.error), 415 /** Timestamp of last execution completion (watch this to refresh data) */ 416 lastExecutionTime: computed(() => state.value.lastExecutionTime), 417 418 // Computed - operations 419 operations: computed(() => state.value.operations), 420 pendingOperations, 421 approvedOperations, 422 completedOperations, 423 activeOperations, 424 hasOperations, 425 hasPendingOperations, 426 hasApprovedOperations, 427 hasActiveOperations, 428 hasCompletedOperations, 429 430 // Actions - connection 431 connect, 432 reconnect, 433 disconnect, 434 refreshState, 435 connectorFetch, 436 437 // Actions - operations 438 addOperation, 439 addOperations, 440 removeOperation, 441 clearOperations, 442 approveOperation, 443 retryOperation, 444 approveAll, 445 executeOperations, 446 447 // Actions - data fetching 448 listOrgUsers, 449 listOrgTeams, 450 listTeamUsers, 451 listPackageCollaborators, 452 listUserPackages, 453 listUserOrgs, 454 } 455}) 456 457// Re-export types for convenience 458export type { PendingOperation, OperationStatus, OperationType }