forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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 }