@lpm-registry/cli 0.58.0 → 0.59.1
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/README.md +25 -44
- package/bin/lpm.js +2 -354
- package/bin/lpx.js +4 -0
- package/bin/native.js +154 -0
- package/package.json +42 -82
- package/scripts/install-binary.js +219 -550
- package/CHANGELOG.md +0 -197
- package/LICENSE +0 -15
- package/index.d.ts +0 -131
- package/index.js +0 -31
- package/lib/api.js +0 -398
- package/lib/commands/add.js +0 -1217
- package/lib/commands/audit.js +0 -283
- package/lib/commands/cache.js +0 -209
- package/lib/commands/check-name.js +0 -112
- package/lib/commands/config.js +0 -186
- package/lib/commands/doctor.js +0 -140
- package/lib/commands/info.js +0 -215
- package/lib/commands/init.js +0 -146
- package/lib/commands/install.js +0 -264
- package/lib/commands/login.js +0 -595
- package/lib/commands/logout.js +0 -94
- package/lib/commands/marketplace-compare.js +0 -164
- package/lib/commands/marketplace-earnings.js +0 -89
- package/lib/commands/mcp-setup.js +0 -363
- package/lib/commands/npmrc.js +0 -178
- package/lib/commands/open.js +0 -82
- package/lib/commands/outdated.js +0 -291
- package/lib/commands/pool-stats.js +0 -100
- package/lib/commands/publish.js +0 -949
- package/lib/commands/quality.js +0 -211
- package/lib/commands/remove.js +0 -82
- package/lib/commands/run.js +0 -14
- package/lib/commands/search.js +0 -143
- package/lib/commands/setup.js +0 -221
- package/lib/commands/skills.js +0 -898
- package/lib/commands/token-rotate.js +0 -25
- package/lib/commands/uninstall.js +0 -107
- package/lib/commands/whoami.js +0 -129
- package/lib/config.js +0 -328
- package/lib/constants.js +0 -190
- package/lib/ecosystem.js +0 -501
- package/lib/editor-skills.js +0 -321
- package/lib/editors.js +0 -215
- package/lib/import-rewriter.js +0 -364
- package/lib/install-targets/mcp-server.js +0 -245
- package/lib/install-targets/vscode-extension.js +0 -178
- package/lib/install-targets.js +0 -82
- package/lib/integrity.js +0 -179
- package/lib/lpm-config-prompts.js +0 -102
- package/lib/lpm-config.js +0 -408
- package/lib/oidc.js +0 -166
- package/lib/project-utils.js +0 -152
- package/lib/quality/checks.js +0 -687
- package/lib/quality/display.js +0 -155
- package/lib/quality/score.js +0 -118
- package/lib/quality/swift-checks.js +0 -447
- package/lib/safe-path.js +0 -180
- package/lib/secure-store.js +0 -321
- package/lib/swift-project.js +0 -637
- package/lib/ui.js +0 -40
- package/scripts/__tests__/install-binary.test.js +0 -346
package/lib/lpm-config.js
DELETED
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LPM Package Config System
|
|
3
|
-
*
|
|
4
|
-
* Handles lpm.config.json parsing, validation, condition evaluation,
|
|
5
|
-
* file filtering, and dependency resolution
|
|
6
|
-
* for the `lpm add` command.
|
|
7
|
-
*
|
|
8
|
-
* Pure logic module — no I/O beyond file reads, no prompts.
|
|
9
|
-
*
|
|
10
|
-
* @module cli/lib/lpm-config
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import fs from "node:fs"
|
|
14
|
-
import path from "node:path"
|
|
15
|
-
|
|
16
|
-
/** Maximum lpm.config.json file size (1 MB) */
|
|
17
|
-
const MAX_CONFIG_FILE_SIZE = 1024 * 1024
|
|
18
|
-
|
|
19
|
-
/** Maximum number of file rules allowed */
|
|
20
|
-
const MAX_FILE_RULES = 1000
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Parse a package reference that may include version and URL query params.
|
|
24
|
-
*
|
|
25
|
-
* Supports:
|
|
26
|
-
* @lpm.dev/owner.pkg
|
|
27
|
-
* @lpm.dev/owner.pkg@1.0.0
|
|
28
|
-
* @lpm.dev/owner.pkg?component=dialog&styling=panda
|
|
29
|
-
* @lpm.dev/owner.pkg@1.0.0?component=dialog,button&styling=panda
|
|
30
|
-
*
|
|
31
|
-
* @param {string} ref - Package reference string
|
|
32
|
-
* @returns {{ name: string, version: string, inlineConfig: Record<string, string>, providedParams: Set<string> }}
|
|
33
|
-
*/
|
|
34
|
-
export function parseLpmPackageReference(ref) {
|
|
35
|
-
// Split query string first
|
|
36
|
-
const queryIndex = ref.indexOf("?")
|
|
37
|
-
let packagePart = ref
|
|
38
|
-
let queryString = ""
|
|
39
|
-
|
|
40
|
-
if (queryIndex !== -1) {
|
|
41
|
-
packagePart = ref.substring(0, queryIndex)
|
|
42
|
-
queryString = ref.substring(queryIndex + 1)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Extract version from package part
|
|
46
|
-
let name = packagePart
|
|
47
|
-
let version = "latest"
|
|
48
|
-
|
|
49
|
-
// Find version separator: last @ after position 0 (position 0 is the scope @)
|
|
50
|
-
const lastAt = packagePart.lastIndexOf("@")
|
|
51
|
-
if (lastAt > 0) {
|
|
52
|
-
name = packagePart.substring(0, lastAt)
|
|
53
|
-
version = packagePart.substring(lastAt + 1)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Parse query params
|
|
57
|
-
const inlineConfig = {}
|
|
58
|
-
const providedParams = new Set()
|
|
59
|
-
|
|
60
|
-
if (queryString) {
|
|
61
|
-
const params = new URLSearchParams(queryString)
|
|
62
|
-
for (const [key, value] of params.entries()) {
|
|
63
|
-
inlineConfig[key] = value
|
|
64
|
-
providedParams.add(key)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return { name, version, inlineConfig, providedParams }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Read and validate lpm.config.json from an extracted package directory.
|
|
73
|
-
*
|
|
74
|
-
* @param {string} extractDir - Path to extracted tarball directory
|
|
75
|
-
* @returns {import('./lpm-config.js').LpmConfig | null} Parsed config or null if not found
|
|
76
|
-
*/
|
|
77
|
-
export function readLpmConfig(extractDir) {
|
|
78
|
-
const configPath = path.join(extractDir, "lpm.config.json")
|
|
79
|
-
|
|
80
|
-
if (!fs.existsSync(configPath)) {
|
|
81
|
-
return null
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const stat = fs.statSync(configPath)
|
|
85
|
-
if (stat.size > MAX_CONFIG_FILE_SIZE) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
`lpm.config.json exceeds maximum size of ${MAX_CONFIG_FILE_SIZE / 1024 / 1024}MB`,
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const raw = fs.readFileSync(configPath, "utf-8")
|
|
92
|
-
const config = JSON.parse(raw)
|
|
93
|
-
|
|
94
|
-
const errors = validateLpmConfig(config)
|
|
95
|
-
if (errors.length > 0) {
|
|
96
|
-
throw new Error(`Invalid lpm.config.json:\n ${errors.join("\n ")}`)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return config
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Validate a parsed lpm.config.json object.
|
|
104
|
-
*
|
|
105
|
-
* @param {object} config - Parsed lpm.config.json
|
|
106
|
-
* @returns {string[]} Array of error messages (empty if valid)
|
|
107
|
-
*/
|
|
108
|
-
export function validateLpmConfig(config) {
|
|
109
|
-
const errors = []
|
|
110
|
-
|
|
111
|
-
// Validate file rules
|
|
112
|
-
if (config.files) {
|
|
113
|
-
if (!Array.isArray(config.files)) {
|
|
114
|
-
errors.push("'files' must be an array")
|
|
115
|
-
} else {
|
|
116
|
-
if (config.files.length > MAX_FILE_RULES) {
|
|
117
|
-
errors.push(
|
|
118
|
-
`Too many file rules (${config.files.length}). Maximum is ${MAX_FILE_RULES}.`,
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (let i = 0; i < config.files.length; i++) {
|
|
123
|
-
const rule = config.files[i]
|
|
124
|
-
|
|
125
|
-
if (rule.src) {
|
|
126
|
-
const normalizedSrc = rule.src.replace(/\\/g, "/")
|
|
127
|
-
if (normalizedSrc.includes("..")) {
|
|
128
|
-
errors.push(
|
|
129
|
-
`files[${i}].src contains path traversal: "${rule.src}"`,
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
if (normalizedSrc.startsWith("/")) {
|
|
133
|
-
errors.push(
|
|
134
|
-
`files[${i}].src must be a relative path: "${rule.src}"`,
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (rule.dest) {
|
|
140
|
-
const normalizedDest = rule.dest.replace(/\\/g, "/")
|
|
141
|
-
if (normalizedDest.includes("..")) {
|
|
142
|
-
errors.push(
|
|
143
|
-
`files[${i}].dest contains path traversal: "${rule.dest}"`,
|
|
144
|
-
)
|
|
145
|
-
}
|
|
146
|
-
if (normalizedDest.startsWith("/")) {
|
|
147
|
-
errors.push(
|
|
148
|
-
`files[${i}].dest must be a relative path: "${rule.dest}"`,
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
rule.include &&
|
|
155
|
-
!["always", "never", "when"].includes(rule.include)
|
|
156
|
-
) {
|
|
157
|
-
errors.push(
|
|
158
|
-
`files[${i}].include must be "always", "never", or "when". Got: "${rule.include}"`,
|
|
159
|
-
)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (rule.include === "when" && !rule.condition) {
|
|
163
|
-
errors.push(`files[${i}] has include "when" but no condition object`)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Validate configSchema
|
|
170
|
-
if (config.configSchema) {
|
|
171
|
-
if (
|
|
172
|
-
typeof config.configSchema !== "object" ||
|
|
173
|
-
Array.isArray(config.configSchema)
|
|
174
|
-
) {
|
|
175
|
-
errors.push("'configSchema' must be an object")
|
|
176
|
-
} else {
|
|
177
|
-
for (const [key, entry] of Object.entries(config.configSchema)) {
|
|
178
|
-
if (!entry.type) {
|
|
179
|
-
errors.push(`configSchema.${key} is missing 'type'`)
|
|
180
|
-
} else if (!["select", "boolean"].includes(entry.type)) {
|
|
181
|
-
errors.push(
|
|
182
|
-
`configSchema.${key}.type must be "select" or "boolean". Got: "${entry.type}"`,
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (entry.type === "select") {
|
|
187
|
-
if (
|
|
188
|
-
!entry.options ||
|
|
189
|
-
!Array.isArray(entry.options) ||
|
|
190
|
-
entry.options.length === 0
|
|
191
|
-
) {
|
|
192
|
-
errors.push(
|
|
193
|
-
`configSchema.${key} is type "select" but has no options`,
|
|
194
|
-
)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Validate importAlias
|
|
202
|
-
if (config.importAlias !== undefined) {
|
|
203
|
-
if (typeof config.importAlias !== "string") {
|
|
204
|
-
errors.push('\'importAlias\' must be a string (e.g., "@/", "~/")')
|
|
205
|
-
} else if (!config.importAlias.endsWith("/")) {
|
|
206
|
-
errors.push(
|
|
207
|
-
'\'importAlias\' must end with "/" (e.g., "@/", "~/", "@src/")',
|
|
208
|
-
)
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return errors
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Evaluate whether a file rule should be included based on config and provided params.
|
|
217
|
-
*
|
|
218
|
-
* Implements "include all by default": if a condition key was NOT explicitly
|
|
219
|
-
* provided by the user, the file is included regardless of its condition value.
|
|
220
|
-
*
|
|
221
|
-
* @param {object} fileRule - File rule from lpm.config.json
|
|
222
|
-
* @param {Record<string, string>} mergedConfig - Merged configuration values
|
|
223
|
-
* @param {Set<string>} providedParams - Set of parameter keys explicitly provided by the user
|
|
224
|
-
* @returns {boolean} Whether the file should be included
|
|
225
|
-
*/
|
|
226
|
-
export function evaluateCondition(fileRule, mergedConfig, providedParams) {
|
|
227
|
-
if (!fileRule.include || fileRule.include === "always") return true
|
|
228
|
-
if (fileRule.include === "never") return false
|
|
229
|
-
|
|
230
|
-
if (fileRule.include === "when" && fileRule.condition) {
|
|
231
|
-
// All condition entries must match (AND logic)
|
|
232
|
-
for (const [conditionKey, conditionValue] of Object.entries(
|
|
233
|
-
fileRule.condition,
|
|
234
|
-
)) {
|
|
235
|
-
// "Include all by default": if param was NOT provided, skip this check (include the file)
|
|
236
|
-
if (!providedParams.has(conditionKey)) {
|
|
237
|
-
continue
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const configValue = mergedConfig[conditionKey]
|
|
241
|
-
|
|
242
|
-
// Handle comma-separated multi-select values
|
|
243
|
-
if (typeof configValue === "string" && configValue.includes(",")) {
|
|
244
|
-
const selectedValues = configValue.split(",").map(v => v.trim())
|
|
245
|
-
if (!selectedValues.includes(String(conditionValue))) {
|
|
246
|
-
return false
|
|
247
|
-
}
|
|
248
|
-
} else {
|
|
249
|
-
// Single value or boolean comparison
|
|
250
|
-
if (String(configValue) !== String(conditionValue)) {
|
|
251
|
-
return false
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return true
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Default: include
|
|
260
|
-
return true
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Filter file rules based on merged config and provided params.
|
|
265
|
-
*
|
|
266
|
-
* @param {object[]} files - Array of file rules from lpm.config.json
|
|
267
|
-
* @param {Record<string, string>} mergedConfig - Merged configuration values
|
|
268
|
-
* @param {Set<string>} providedParams - Set of parameter keys explicitly provided
|
|
269
|
-
* @returns {object[]} Filtered file rules that should be included
|
|
270
|
-
*/
|
|
271
|
-
export function filterFiles(files, mergedConfig, providedParams) {
|
|
272
|
-
return files.filter(fileRule =>
|
|
273
|
-
evaluateCondition(fileRule, mergedConfig, providedParams),
|
|
274
|
-
)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Resolve conditional dependencies based on config choices.
|
|
279
|
-
*
|
|
280
|
-
* @param {Record<string, Record<string, string[]>>} depConfig - Dependencies config from lpm.config.json
|
|
281
|
-
* @param {Record<string, string>} mergedConfig - Merged configuration values
|
|
282
|
-
* @returns {{ npm: string[], lpm: string[] }} Separated npm and LPM dependencies
|
|
283
|
-
*/
|
|
284
|
-
export function resolveConditionalDependencies(depConfig, mergedConfig) {
|
|
285
|
-
const allDeps = new Set()
|
|
286
|
-
|
|
287
|
-
for (const [configKey, depMap] of Object.entries(depConfig)) {
|
|
288
|
-
const selectedValue = mergedConfig[configKey]
|
|
289
|
-
if (!selectedValue) continue
|
|
290
|
-
|
|
291
|
-
// Handle comma-separated values
|
|
292
|
-
const values =
|
|
293
|
-
typeof selectedValue === "string" && selectedValue.includes(",")
|
|
294
|
-
? selectedValue.split(",").map(v => v.trim())
|
|
295
|
-
: [selectedValue]
|
|
296
|
-
|
|
297
|
-
for (const value of values) {
|
|
298
|
-
const deps = depMap[value]
|
|
299
|
-
if (Array.isArray(deps)) {
|
|
300
|
-
for (const dep of deps) {
|
|
301
|
-
allDeps.add(dep)
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const npm = []
|
|
308
|
-
const lpm = []
|
|
309
|
-
|
|
310
|
-
for (const dep of allDeps) {
|
|
311
|
-
if (dep.startsWith("@lpm.dev/")) {
|
|
312
|
-
lpm.push(dep)
|
|
313
|
-
} else {
|
|
314
|
-
npm.push(dep)
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return { npm, lpm }
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Expand glob-like src patterns to actual file paths.
|
|
323
|
-
*
|
|
324
|
-
* Supports simple patterns:
|
|
325
|
-
* - Exact paths: "lib/utils.js"
|
|
326
|
-
* - Directory wildcards: "components/dialog/**"
|
|
327
|
-
*
|
|
328
|
-
* @param {string} srcPattern - Source pattern from file rule
|
|
329
|
-
* @param {string} extractDir - Extracted tarball directory
|
|
330
|
-
* @returns {string[]} Array of matching file paths (relative to extractDir)
|
|
331
|
-
*/
|
|
332
|
-
export function expandSrcGlob(srcPattern, extractDir) {
|
|
333
|
-
// If pattern doesn't contain *, it's an exact path
|
|
334
|
-
if (!srcPattern.includes("*")) {
|
|
335
|
-
const fullPath = path.join(extractDir, srcPattern)
|
|
336
|
-
if (fs.existsSync(fullPath)) {
|
|
337
|
-
return [srcPattern]
|
|
338
|
-
}
|
|
339
|
-
return []
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Handle ** (directory wildcard)
|
|
343
|
-
if (srcPattern.endsWith("/**")) {
|
|
344
|
-
const baseDir = srcPattern.slice(0, -3) // Remove /**
|
|
345
|
-
const fullBaseDir = path.join(extractDir, baseDir)
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
!fs.existsSync(fullBaseDir) ||
|
|
349
|
-
!fs.statSync(fullBaseDir).isDirectory()
|
|
350
|
-
) {
|
|
351
|
-
return []
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const results = []
|
|
355
|
-
const collectFiles = (dir, relativeTo) => {
|
|
356
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
357
|
-
for (const entry of entries) {
|
|
358
|
-
const fullEntryPath = path.join(dir, entry.name)
|
|
359
|
-
const relPath = path.relative(extractDir, fullEntryPath)
|
|
360
|
-
|
|
361
|
-
if (entry.isDirectory()) {
|
|
362
|
-
collectFiles(fullEntryPath, relativeTo)
|
|
363
|
-
} else {
|
|
364
|
-
results.push(relPath)
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
collectFiles(fullBaseDir, extractDir)
|
|
370
|
-
return results
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Handle dir/*.ext patterns (single-directory wildcard)
|
|
374
|
-
const lastSlash = srcPattern.lastIndexOf("/")
|
|
375
|
-
const dirPart = lastSlash >= 0 ? srcPattern.slice(0, lastSlash) : "."
|
|
376
|
-
const filePart = lastSlash >= 0 ? srcPattern.slice(lastSlash + 1) : srcPattern
|
|
377
|
-
|
|
378
|
-
if (filePart.includes("*")) {
|
|
379
|
-
const fullDir = path.join(extractDir, dirPart)
|
|
380
|
-
if (!fs.existsSync(fullDir) || !fs.statSync(fullDir).isDirectory()) {
|
|
381
|
-
return []
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Convert glob pattern to regex: *.mdc → /^.*\.mdc$/
|
|
385
|
-
const escaped = filePart
|
|
386
|
-
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
387
|
-
.replace(/\*/g, ".*")
|
|
388
|
-
const regex = new RegExp(`^${escaped}$`)
|
|
389
|
-
|
|
390
|
-
const entries = fs.readdirSync(fullDir, { withFileTypes: true })
|
|
391
|
-
const results = []
|
|
392
|
-
for (const entry of entries) {
|
|
393
|
-
if (entry.isFile() && regex.test(entry.name)) {
|
|
394
|
-
results.push(
|
|
395
|
-
dirPart === "." ? entry.name : path.join(dirPart, entry.name),
|
|
396
|
-
)
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
return results
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// For other patterns, treat as exact path
|
|
403
|
-
const fullPath = path.join(extractDir, srcPattern)
|
|
404
|
-
if (fs.existsSync(fullPath)) {
|
|
405
|
-
return [srcPattern]
|
|
406
|
-
}
|
|
407
|
-
return []
|
|
408
|
-
}
|
package/lib/oidc.js
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OIDC (OpenID Connect) token exchange for secret-free CI operations.
|
|
3
|
-
*
|
|
4
|
-
* When running in GitHub Actions with `id-token: write` permission,
|
|
5
|
-
* this module requests an OIDC JWT from GitHub and exchanges it for
|
|
6
|
-
* a short-lived LPM token — no LPM_TOKEN secret required.
|
|
7
|
-
*
|
|
8
|
-
* Two modes:
|
|
9
|
-
* - **Publish**: Requires trusted publisher config in LPM dashboard.
|
|
10
|
-
* - **Read (install)**: Requires GitHub/GitLab account linked to LPM user.
|
|
11
|
-
*
|
|
12
|
-
* @module cli/lib/oidc
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { getRegistryUrl } from "./config.js"
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Detect whether we are running in GitHub Actions with OIDC available.
|
|
19
|
-
* Both env vars must be set for token request to work.
|
|
20
|
-
*
|
|
21
|
-
* @returns {boolean}
|
|
22
|
-
*/
|
|
23
|
-
export function isGitHubActionsWithOidc() {
|
|
24
|
-
return !!(
|
|
25
|
-
process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
|
|
26
|
-
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
27
|
-
)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Detect whether we are running in GitLab CI with an OIDC token injected.
|
|
32
|
-
* GitLab injects the JWT directly as an env var via id_tokens — no HTTP fetch needed.
|
|
33
|
-
*
|
|
34
|
-
* @returns {boolean}
|
|
35
|
-
*/
|
|
36
|
-
export function isGitLabCiWithOidc() {
|
|
37
|
-
return !!(
|
|
38
|
-
process.env.GITLAB_CI === "true" && process.env.LPM_GITLAB_OIDC_TOKEN
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Request a GitHub Actions OIDC JWT.
|
|
44
|
-
* @returns {Promise<string>} The raw JWT string
|
|
45
|
-
*/
|
|
46
|
-
async function requestGitHubOidcJwt() {
|
|
47
|
-
const githubTokenUrl = `${process.env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://lpm.dev`
|
|
48
|
-
|
|
49
|
-
const githubRes = await fetch(githubTokenUrl, {
|
|
50
|
-
headers: {
|
|
51
|
-
Authorization: `bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
|
|
52
|
-
},
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
if (!githubRes.ok) {
|
|
56
|
-
const body = await githubRes.text().catch(() => "")
|
|
57
|
-
throw new Error(
|
|
58
|
-
`GitHub OIDC token request failed (${githubRes.status})${body ? `: ${body}` : ""}`,
|
|
59
|
-
)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const { value: oidcJwt } = await githubRes.json()
|
|
63
|
-
if (!oidcJwt) {
|
|
64
|
-
throw new Error("GitHub OIDC response did not contain a token value")
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return oidcJwt
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Exchange an OIDC JWT with the LPM server for a short-lived token.
|
|
72
|
-
*
|
|
73
|
-
* @param {string} oidcJwt - The raw OIDC JWT
|
|
74
|
-
* @param {{ scope?: "read"|"publish", packageName?: string }} [options]
|
|
75
|
-
* @returns {Promise<{ token: string, expiresAt: string }>}
|
|
76
|
-
*/
|
|
77
|
-
async function exchangeJwtWithServer(oidcJwt, options = {}) {
|
|
78
|
-
const { scope = "publish", packageName } = options
|
|
79
|
-
const registryUrl = getRegistryUrl()
|
|
80
|
-
const scopeParam = scope === "read" ? "?scope=read" : ""
|
|
81
|
-
|
|
82
|
-
const body = { token: oidcJwt }
|
|
83
|
-
if (packageName) body.package = packageName
|
|
84
|
-
|
|
85
|
-
const exchangeRes = await fetch(
|
|
86
|
-
`${registryUrl}/api/registry/-/token/oidc${scopeParam}`,
|
|
87
|
-
{
|
|
88
|
-
method: "POST",
|
|
89
|
-
headers: { "Content-Type": "application/json" },
|
|
90
|
-
body: JSON.stringify(body),
|
|
91
|
-
},
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
if (!exchangeRes.ok) {
|
|
95
|
-
const text = await exchangeRes.text().catch(() => "")
|
|
96
|
-
throw new Error(
|
|
97
|
-
`OIDC token exchange failed (${exchangeRes.status})${text ? `: ${text}` : ""}`,
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const data = await exchangeRes.json()
|
|
102
|
-
if (!data.token) {
|
|
103
|
-
throw new Error("OIDC exchange response did not contain a token")
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return data
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Exchange a GitHub Actions OIDC JWT for a short-lived LPM token.
|
|
111
|
-
*
|
|
112
|
-
* @param {string} [packageName] - Required for publish scope, optional for read
|
|
113
|
-
* @param {{ scope?: "read"|"publish" }} [options]
|
|
114
|
-
* @returns {Promise<string>} Short-lived LPM token
|
|
115
|
-
*/
|
|
116
|
-
export async function exchangeOidcToken(packageName, options = {}) {
|
|
117
|
-
const oidcJwt = await requestGitHubOidcJwt()
|
|
118
|
-
const data = await exchangeJwtWithServer(oidcJwt, {
|
|
119
|
-
scope: options.scope || "publish",
|
|
120
|
-
packageName,
|
|
121
|
-
})
|
|
122
|
-
return data.token
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Exchange a GitLab CI OIDC JWT for a short-lived LPM token.
|
|
127
|
-
*
|
|
128
|
-
* @param {string} [packageName] - Required for publish scope, optional for read
|
|
129
|
-
* @param {{ scope?: "read"|"publish" }} [options]
|
|
130
|
-
* @returns {Promise<string>} Short-lived LPM token
|
|
131
|
-
*/
|
|
132
|
-
export async function exchangeGitLabOidcToken(packageName, options = {}) {
|
|
133
|
-
const oidcJwt = process.env.LPM_GITLAB_OIDC_TOKEN
|
|
134
|
-
if (!oidcJwt) {
|
|
135
|
-
throw new Error(
|
|
136
|
-
"LPM_GITLAB_OIDC_TOKEN is not set. " +
|
|
137
|
-
"Add id_tokens: LPM_GITLAB_OIDC_TOKEN: { aud: https://lpm.dev } to your .gitlab-ci.yml",
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const data = await exchangeJwtWithServer(oidcJwt, {
|
|
142
|
-
scope: options.scope || "publish",
|
|
143
|
-
packageName,
|
|
144
|
-
})
|
|
145
|
-
return data.token
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Detect CI environment and exchange OIDC token for a read-only install token.
|
|
150
|
-
* Works with both GitHub Actions and GitLab CI.
|
|
151
|
-
*
|
|
152
|
-
* @returns {Promise<string>} Short-lived read-only LPM token
|
|
153
|
-
* @throws {Error} If not in a supported CI environment or exchange fails
|
|
154
|
-
*/
|
|
155
|
-
export async function exchangeOidcInstallToken() {
|
|
156
|
-
if (isGitHubActionsWithOidc()) {
|
|
157
|
-
return exchangeOidcToken(null, { scope: "read" })
|
|
158
|
-
}
|
|
159
|
-
if (isGitLabCiWithOidc()) {
|
|
160
|
-
return exchangeGitLabOidcToken(null, { scope: "read" })
|
|
161
|
-
}
|
|
162
|
-
throw new Error(
|
|
163
|
-
"OIDC is not available. This command requires a CI environment with OIDC support " +
|
|
164
|
-
"(GitHub Actions with `id-token: write` or GitLab CI with `id_tokens` configured).",
|
|
165
|
-
)
|
|
166
|
-
}
|