@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +15 -0
- package/README.md +406 -0
- package/bin/lpm.js +334 -0
- package/index.d.ts +131 -0
- package/index.js +31 -0
- package/lib/api.js +324 -0
- package/lib/commands/add.js +1217 -0
- package/lib/commands/audit.js +283 -0
- package/lib/commands/cache.js +209 -0
- package/lib/commands/check-name.js +112 -0
- package/lib/commands/config.js +174 -0
- package/lib/commands/doctor.js +142 -0
- package/lib/commands/info.js +215 -0
- package/lib/commands/init.js +146 -0
- package/lib/commands/install.js +217 -0
- package/lib/commands/login.js +547 -0
- package/lib/commands/logout.js +94 -0
- package/lib/commands/marketplace-compare.js +164 -0
- package/lib/commands/marketplace-earnings.js +89 -0
- package/lib/commands/mcp-setup.js +363 -0
- package/lib/commands/open.js +82 -0
- package/lib/commands/outdated.js +291 -0
- package/lib/commands/pool-stats.js +100 -0
- package/lib/commands/publish.js +707 -0
- package/lib/commands/quality.js +211 -0
- package/lib/commands/remove.js +82 -0
- package/lib/commands/run.js +14 -0
- package/lib/commands/search.js +143 -0
- package/lib/commands/setup.js +92 -0
- package/lib/commands/skills.js +863 -0
- package/lib/commands/token-rotate.js +25 -0
- package/lib/commands/whoami.js +129 -0
- package/lib/config.js +240 -0
- package/lib/constants.js +190 -0
- package/lib/ecosystem.js +501 -0
- package/lib/editors.js +215 -0
- package/lib/import-rewriter.js +364 -0
- package/lib/install-targets/mcp-server.js +245 -0
- package/lib/install-targets/vscode-extension.js +178 -0
- package/lib/install-targets.js +82 -0
- package/lib/integrity.js +179 -0
- package/lib/lpm-config-prompts.js +102 -0
- package/lib/lpm-config.js +408 -0
- package/lib/project-utils.js +152 -0
- package/lib/quality/checks.js +654 -0
- package/lib/quality/display.js +139 -0
- package/lib/quality/score.js +115 -0
- package/lib/quality/swift-checks.js +447 -0
- package/lib/safe-path.js +180 -0
- package/lib/secure-store.js +288 -0
- package/lib/swift-project.js +637 -0
- package/lib/ui.js +40 -0
- 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
|
+
}
|