@lpm-registry/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +15 -0
  3. package/README.md +406 -0
  4. package/bin/lpm.js +334 -0
  5. package/index.d.ts +131 -0
  6. package/index.js +31 -0
  7. package/lib/api.js +324 -0
  8. package/lib/commands/add.js +1217 -0
  9. package/lib/commands/audit.js +283 -0
  10. package/lib/commands/cache.js +209 -0
  11. package/lib/commands/check-name.js +112 -0
  12. package/lib/commands/config.js +174 -0
  13. package/lib/commands/doctor.js +142 -0
  14. package/lib/commands/info.js +215 -0
  15. package/lib/commands/init.js +146 -0
  16. package/lib/commands/install.js +217 -0
  17. package/lib/commands/login.js +547 -0
  18. package/lib/commands/logout.js +94 -0
  19. package/lib/commands/marketplace-compare.js +164 -0
  20. package/lib/commands/marketplace-earnings.js +89 -0
  21. package/lib/commands/mcp-setup.js +363 -0
  22. package/lib/commands/open.js +82 -0
  23. package/lib/commands/outdated.js +291 -0
  24. package/lib/commands/pool-stats.js +100 -0
  25. package/lib/commands/publish.js +707 -0
  26. package/lib/commands/quality.js +211 -0
  27. package/lib/commands/remove.js +82 -0
  28. package/lib/commands/run.js +14 -0
  29. package/lib/commands/search.js +143 -0
  30. package/lib/commands/setup.js +92 -0
  31. package/lib/commands/skills.js +863 -0
  32. package/lib/commands/token-rotate.js +25 -0
  33. package/lib/commands/whoami.js +129 -0
  34. package/lib/config.js +240 -0
  35. package/lib/constants.js +190 -0
  36. package/lib/ecosystem.js +501 -0
  37. package/lib/editors.js +215 -0
  38. package/lib/import-rewriter.js +364 -0
  39. package/lib/install-targets/mcp-server.js +245 -0
  40. package/lib/install-targets/vscode-extension.js +178 -0
  41. package/lib/install-targets.js +82 -0
  42. package/lib/integrity.js +179 -0
  43. package/lib/lpm-config-prompts.js +102 -0
  44. package/lib/lpm-config.js +408 -0
  45. package/lib/project-utils.js +152 -0
  46. package/lib/quality/checks.js +654 -0
  47. package/lib/quality/display.js +139 -0
  48. package/lib/quality/score.js +115 -0
  49. package/lib/quality/swift-checks.js +447 -0
  50. package/lib/safe-path.js +180 -0
  51. package/lib/secure-store.js +288 -0
  52. package/lib/swift-project.js +637 -0
  53. package/lib/ui.js +40 -0
  54. package/package.json +74 -0
package/lib/api.js ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * API Client with Retry, Timeout, and Rate Limit Handling
3
+ *
4
+ * Provides robust network communication with:
5
+ * - Configurable timeouts using AbortController
6
+ * - Exponential backoff retry for transient failures
7
+ * - Rate limit handling with Retry-After header support
8
+ *
9
+ * @module cli/lib/api
10
+ */
11
+
12
+ import { getRegistryUrl, getRetries, getTimeout, getToken } from "./config.js"
13
+ import {
14
+ ERROR_MESSAGES,
15
+ RATE_LIMIT_STATUS_CODES,
16
+ RETRY_BACKOFF_MULTIPLIER,
17
+ RETRY_BASE_DELAY_MS,
18
+ RETRY_MAX_DELAY_MS,
19
+ RETRYABLE_STATUS_CODES,
20
+ } from "./constants.js"
21
+
22
+ /**
23
+ * Sleep for a specified duration.
24
+ * @param {number} ms - Milliseconds to sleep
25
+ * @returns {Promise<void>}
26
+ */
27
+ function sleep(ms) {
28
+ return new Promise(resolve => setTimeout(resolve, ms))
29
+ }
30
+
31
+ /**
32
+ * Calculate exponential backoff delay.
33
+ * @param {number} attempt - Current attempt number (0-based)
34
+ * @returns {number} - Delay in milliseconds
35
+ */
36
+ function getBackoffDelay(attempt) {
37
+ const delay = RETRY_BASE_DELAY_MS * RETRY_BACKOFF_MULTIPLIER ** attempt
38
+ // Add jitter (±10%)
39
+ const jitter = delay * 0.1 * (Math.random() * 2 - 1)
40
+ return Math.min(delay + jitter, RETRY_MAX_DELAY_MS)
41
+ }
42
+
43
+ /**
44
+ * Parse Retry-After header value.
45
+ * @param {string | null} retryAfter - Header value (seconds or HTTP date)
46
+ * @returns {number} - Delay in milliseconds
47
+ */
48
+ function parseRetryAfter(retryAfter) {
49
+ if (!retryAfter) return RETRY_BASE_DELAY_MS
50
+
51
+ // Try parsing as seconds
52
+ const seconds = parseInt(retryAfter, 10)
53
+ if (!Number.isNaN(seconds)) {
54
+ return seconds * 1000
55
+ }
56
+
57
+ // Try parsing as HTTP date
58
+ const date = new Date(retryAfter)
59
+ if (!Number.isNaN(date.getTime())) {
60
+ return Math.max(0, date.getTime() - Date.now())
61
+ }
62
+
63
+ return RETRY_BASE_DELAY_MS
64
+ }
65
+
66
+ /**
67
+ * @typedef {Object} RequestOptions
68
+ * @property {Record<string, string>} [headers] - Request headers
69
+ * @property {string} [method] - HTTP method
70
+ * @property {string | Buffer} [body] - Request body
71
+ * @property {boolean} [skipRetry] - Skip retry logic for this request
72
+ * @property {number} [timeout] - Override default timeout
73
+ * @property {(attempt: number, maxRetries: number) => void} [onRetry] - Callback when retrying
74
+ * @property {(seconds: number) => void} [onRateLimited] - Callback when rate limited
75
+ */
76
+
77
+ /**
78
+ * Make an API request with timeout, retry, and rate limit handling.
79
+ *
80
+ * @param {string} path - API path (will be prefixed with options.prefix or /api/registry)
81
+ * @param {RequestOptions & { prefix?: string }} [options={}] - Request options
82
+ * @returns {Promise<import('node-fetch').Response>}
83
+ * @throws {Error} On network failure, timeout, or authentication error
84
+ */
85
+ export async function request(path, options = {}) {
86
+ const token = await getToken()
87
+ const registryUrl = getRegistryUrl()
88
+ const prefix = options.prefix ?? "/api/registry"
89
+ const url = `${registryUrl}${prefix}${path}`
90
+ const maxRetries = options.skipRetry ? 0 : getRetries()
91
+ const timeout = options.timeout ?? getTimeout()
92
+
93
+ const headers = {
94
+ ...options.headers,
95
+ }
96
+
97
+ if (token) {
98
+ headers.Authorization = `Bearer ${token}`
99
+ }
100
+
101
+ let lastError = null
102
+
103
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
104
+ const controller = new AbortController()
105
+ const timeoutId = setTimeout(() => controller.abort(), timeout)
106
+
107
+ try {
108
+ const response = await fetch(url, {
109
+ ...options,
110
+ headers,
111
+ signal: controller.signal,
112
+ })
113
+
114
+ clearTimeout(timeoutId)
115
+
116
+ // Handle authentication errors immediately (no retry)
117
+ if (response.status === 401) {
118
+ throw new Error(ERROR_MESSAGES.notAuthenticated)
119
+ }
120
+
121
+ if (response.status === 403) {
122
+ const data = await response.json().catch(() => ({}))
123
+ const errorMessage = data.error || "Access denied."
124
+
125
+ // Check for license/purchase required error
126
+ const purchaseMatch = errorMessage.match(
127
+ /Package (@[^/]+\/[^\s]+) requires purchase/,
128
+ )
129
+ if (purchaseMatch) {
130
+ const pkgName = purchaseMatch[1]
131
+ const registryUrl = getRegistryUrl()
132
+ throw new Error(
133
+ `${errorMessage}\n Purchase: ${registryUrl}/${pkgName.replace("@", "")}`,
134
+ )
135
+ }
136
+
137
+ throw new Error(errorMessage)
138
+ }
139
+
140
+ // Handle rate limiting
141
+ if (RATE_LIMIT_STATUS_CODES.includes(response.status)) {
142
+ const retryAfter = response.headers.get("Retry-After")
143
+ const delayMs = parseRetryAfter(retryAfter)
144
+ const delaySec = Math.ceil(delayMs / 1000)
145
+
146
+ if (options.onRateLimited) {
147
+ options.onRateLimited(delaySec)
148
+ }
149
+
150
+ // Only retry if we have attempts left
151
+ if (attempt < maxRetries) {
152
+ await sleep(delayMs)
153
+ continue
154
+ }
155
+
156
+ throw new Error(ERROR_MESSAGES.rateLimited)
157
+ }
158
+
159
+ // Handle retryable server errors
160
+ if (
161
+ RETRYABLE_STATUS_CODES.includes(response.status) &&
162
+ attempt < maxRetries
163
+ ) {
164
+ const delay = getBackoffDelay(attempt)
165
+
166
+ if (options.onRetry) {
167
+ options.onRetry(attempt + 1, maxRetries)
168
+ }
169
+
170
+ await sleep(delay)
171
+ continue
172
+ }
173
+
174
+ return response
175
+ } catch (error) {
176
+ clearTimeout(timeoutId)
177
+
178
+ // Handle abort (timeout)
179
+ if (error.name === "AbortError") {
180
+ lastError = new Error(ERROR_MESSAGES.timeout)
181
+
182
+ if (attempt < maxRetries) {
183
+ const delay = getBackoffDelay(attempt)
184
+ if (options.onRetry) {
185
+ options.onRetry(attempt + 1, maxRetries)
186
+ }
187
+ await sleep(delay)
188
+ continue
189
+ }
190
+
191
+ throw lastError
192
+ }
193
+
194
+ // Handle network errors
195
+ if (
196
+ error.code === "ECONNREFUSED" ||
197
+ error.code === "ENOTFOUND" ||
198
+ error.type === "system"
199
+ ) {
200
+ lastError = new Error(ERROR_MESSAGES.networkError)
201
+
202
+ if (attempt < maxRetries) {
203
+ const delay = getBackoffDelay(attempt)
204
+ if (options.onRetry) {
205
+ options.onRetry(attempt + 1, maxRetries)
206
+ }
207
+ await sleep(delay)
208
+ continue
209
+ }
210
+
211
+ throw lastError
212
+ }
213
+
214
+ // Re-throw other errors (like auth errors)
215
+ throw error
216
+ }
217
+ }
218
+
219
+ // Should not reach here, but just in case
220
+ throw lastError || new Error(ERROR_MESSAGES.networkError)
221
+ }
222
+
223
+ /**
224
+ * Make a GET request.
225
+ * @param {string} path - API path
226
+ * @param {RequestOptions} [options={}] - Request options
227
+ * @returns {Promise<import('node-fetch').Response>}
228
+ */
229
+ export function get(path, options = {}) {
230
+ return request(path, { ...options, method: "GET" })
231
+ }
232
+
233
+ /**
234
+ * Make a GET request to the search API (/api/search prefix).
235
+ * @param {string} path - Path after /api/search (e.g., '/packages?q=test')
236
+ * @param {RequestOptions} [options={}] - Request options
237
+ * @returns {Promise<import('node-fetch').Response>}
238
+ */
239
+ export function searchGet(path, options = {}) {
240
+ return request(path, { ...options, method: "GET", prefix: "/api/search" })
241
+ }
242
+
243
+ /**
244
+ * Make a POST request with JSON body.
245
+ * @param {string} path - API path
246
+ * @param {unknown} data - Request body (will be JSON serialized)
247
+ * @param {RequestOptions} [options={}] - Request options
248
+ * @returns {Promise<import('node-fetch').Response>}
249
+ */
250
+ export function post(path, data, options = {}) {
251
+ return request(path, {
252
+ ...options,
253
+ method: "POST",
254
+ headers: {
255
+ "Content-Type": "application/json",
256
+ ...options.headers,
257
+ },
258
+ body: JSON.stringify(data),
259
+ })
260
+ }
261
+
262
+ /**
263
+ * Make a PUT request with JSON body.
264
+ * @param {string} path - API path
265
+ * @param {unknown} data - Request body (will be JSON serialized)
266
+ * @param {RequestOptions} [options={}] - Request options
267
+ * @returns {Promise<import('node-fetch').Response>}
268
+ */
269
+ export function put(path, data, options = {}) {
270
+ return request(path, {
271
+ ...options,
272
+ method: "PUT",
273
+ headers: {
274
+ "Content-Type": "application/json",
275
+ ...options.headers,
276
+ },
277
+ body: JSON.stringify(data),
278
+ })
279
+ }
280
+
281
+ /**
282
+ * Check token validity and scopes.
283
+ * @returns {Promise<{valid: boolean, scopes?: string[], user?: string, error?: string}>}
284
+ */
285
+ export async function checkToken() {
286
+ try {
287
+ const response = await get("/cli/check", { skipRetry: true })
288
+
289
+ if (!response.ok) {
290
+ const data = await response.json().catch(() => ({}))
291
+ return { valid: false, error: data.error || "Token validation failed" }
292
+ }
293
+
294
+ const data = await response.json()
295
+ return { valid: true, ...data }
296
+ } catch (error) {
297
+ return { valid: false, error: error.message }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Verify token has required scope before an operation.
303
+ * @param {string} requiredScope - The scope to check for
304
+ * @returns {Promise<{valid: boolean, error?: string}>}
305
+ */
306
+ export async function verifyTokenScope(requiredScope) {
307
+ const result = await checkToken()
308
+
309
+ if (!result.valid) {
310
+ return { valid: false, error: result.error }
311
+ }
312
+
313
+ const scopes = result.scopes || []
314
+ const hasScope = scopes.includes(requiredScope) || scopes.includes("full")
315
+
316
+ if (!hasScope) {
317
+ return {
318
+ valid: false,
319
+ error: ERROR_MESSAGES.tokenMissingScope(requiredScope),
320
+ }
321
+ }
322
+
323
+ return { valid: true }
324
+ }