@lpm-registry/cli 0.2.0 → 0.2.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/CHANGELOG.md +33 -2
- package/README.md +45 -40
- package/bin/lpm.js +8 -0
- package/lib/commands/publish.js +95 -1
- package/lib/commands/skills.js +10 -0
- package/lib/config.js +28 -1
- package/lib/oidc.js +138 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.1] - 2026-03-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Swift & XCFramework ecosystem support** — quality checks, packaging, framework detection, and local package management for Apple platforms
|
|
10
|
+
- **OIDC token exchange** for secret-free publishing from CI/CD (GitHub Actions, GitLab CI)
|
|
11
|
+
- **Agent Skills management** — `lpm skills` commands for creating and managing `.lpm/skills/` packages
|
|
12
|
+
- `lpm remove` command for uninstalling packages
|
|
13
|
+
- `lpm install --json` output mode for programmatic usage
|
|
14
|
+
- `lpm search` now supports semantic queries
|
|
15
|
+
- Intellisense coverage and XCFramework size checks in quality scoring
|
|
16
|
+
- Node.js type definitions check in quality scoring
|
|
17
|
+
- Graceful HTTP server shutdown after login callback
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Updated quality scoring metrics (28 checks for JS, 25 for Swift, 21 for XCFramework)
|
|
22
|
+
- Removed CI config references from quality checks
|
|
23
|
+
- Simplified `getDefaultPath` internals
|
|
24
|
+
- Removed `applyTemplateVariables` logic from config and commands
|
|
25
|
+
- Improved MCP setup command with server config resolution
|
|
26
|
+
- General code structure refactoring for readability
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Error handling for invalid JSON in `package.json`
|
|
31
|
+
- API request handling improvements
|
|
32
|
+
|
|
5
33
|
## [0.2.0] - 2025-02-14
|
|
6
34
|
|
|
7
35
|
### Added
|
|
36
|
+
|
|
8
37
|
- Quality scoring system with 27 checks across 4 categories
|
|
9
38
|
- `lpm publish --check` and `--min-score` flags for quality gating
|
|
10
|
-
- `lpm add` command for
|
|
39
|
+
- `lpm add` command for source code delivery
|
|
11
40
|
- `lpm.config.json` support for configurable source packages
|
|
12
41
|
- Secure credential storage (OS keychain with encrypted file fallback)
|
|
13
42
|
- SRI integrity verification for package tarballs
|
|
@@ -15,11 +44,12 @@ All notable changes to this project will be documented in this file.
|
|
|
15
44
|
- Exponential backoff retry with rate-limit handling
|
|
16
45
|
|
|
17
46
|
### Changed
|
|
47
|
+
|
|
18
48
|
- Migrated from `node-fetch` to native `fetch` (Node 18+)
|
|
19
49
|
- Removed unused `figlet` and `form-data` dependencies
|
|
20
|
-
- Updated default registry URL to `https://lpm.dev`
|
|
21
50
|
|
|
22
51
|
### Fixed
|
|
52
|
+
|
|
23
53
|
- Missing `await` on `setToken()` in token-rotate command
|
|
24
54
|
- URL construction for `@lpm.dev/owner.pkg` format in open command
|
|
25
55
|
- Inconsistent limit field names in doctor command
|
|
@@ -27,6 +57,7 @@ All notable changes to this project will be documented in this file.
|
|
|
27
57
|
## [0.1.0] - 2024-12-01
|
|
28
58
|
|
|
29
59
|
### Added
|
|
60
|
+
|
|
30
61
|
- Initial CLI release
|
|
31
62
|
- `lpm login`, `lpm logout`, `lpm whoami` authentication commands
|
|
32
63
|
- `lpm publish`, `lpm install` package management
|
package/README.md
CHANGED
|
@@ -12,13 +12,13 @@ npm install -g @lpm-registry/cli
|
|
|
12
12
|
|
|
13
13
|
LPM provides convenient aliases for common commands:
|
|
14
14
|
|
|
15
|
-
| Shortcut
|
|
16
|
-
|
|
17
|
-
| `lpm i`
|
|
18
|
-
| `lpm p`
|
|
19
|
-
| `lpm l`
|
|
20
|
-
| `lpm lo`
|
|
21
|
-
| `lpm set <key> <value>` | `lpm config set` | Set config
|
|
15
|
+
| Shortcut | Full Command | Description |
|
|
16
|
+
| ----------------------- | ---------------- | ---------------- |
|
|
17
|
+
| `lpm i` | `lpm install` | Install packages |
|
|
18
|
+
| `lpm p` | `lpm publish` | Publish package |
|
|
19
|
+
| `lpm l` | `lpm login` | Log in |
|
|
20
|
+
| `lpm lo` | `lpm logout` | Log out |
|
|
21
|
+
| `lpm set <key> <value>` | `lpm config set` | Set config |
|
|
22
22
|
|
|
23
23
|
## Package Name Format
|
|
24
24
|
|
|
@@ -29,6 +29,7 @@ LPM uses the `@lpm.dev` scope for all packages with dot notation:
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
Examples:
|
|
32
|
+
|
|
32
33
|
- `@lpm.dev/tolgaergin.my-utils` (personal package)
|
|
33
34
|
- `@lpm.dev/acme-corp.design-system` (org package)
|
|
34
35
|
|
|
@@ -103,10 +104,10 @@ lpm publish --min-score 80 # Block publish if quality score < 80
|
|
|
103
104
|
lpm publish --check --min-score 90 # Check only, fail if below 90 (useful in CI)
|
|
104
105
|
```
|
|
105
106
|
|
|
106
|
-
| Option
|
|
107
|
-
|
|
108
|
-
| `--check`
|
|
109
|
-
| `--min-score <n>` | Set minimum quality score (0-100) required to publish
|
|
107
|
+
| Option | Description |
|
|
108
|
+
| ----------------- | ------------------------------------------------------------------- |
|
|
109
|
+
| `--check` | Run quality checks and display report, then exit without publishing |
|
|
110
|
+
| `--min-score <n>` | Set minimum quality score (0-100) required to publish |
|
|
110
111
|
|
|
111
112
|
> **Note:** If `.lpm/skills/` exists in your package but skills aren't included in the tarball, the CLI warns you to add `.lpm` to the `files` field in package.json.
|
|
112
113
|
|
|
@@ -124,13 +125,13 @@ lpm i # Shortcut
|
|
|
124
125
|
|
|
125
126
|
Agent Skills are fetched by default for packages that include them.
|
|
126
127
|
|
|
127
|
-
| Option
|
|
128
|
-
|
|
128
|
+
| Option | Description |
|
|
129
|
+
| ------------- | ---------------------------------------- |
|
|
129
130
|
| `--no-skills` | Skip fetching Agent Skills after install |
|
|
130
131
|
|
|
131
132
|
#### Add (Source Code)
|
|
132
133
|
|
|
133
|
-
Download and extract package source code directly into your project
|
|
134
|
+
Download and extract package source code directly into your project.
|
|
134
135
|
|
|
135
136
|
```bash
|
|
136
137
|
# JavaScript
|
|
@@ -196,15 +197,19 @@ lpm quality @lpm.dev/owner.package --json
|
|
|
196
197
|
Manage Agent Skills for AI coding assistants.
|
|
197
198
|
|
|
198
199
|
#### `lpm skills validate`
|
|
200
|
+
|
|
199
201
|
Validate `.lpm/skills/*.md` files in the current directory. Checks file format, frontmatter, content, size limits, and blocked patterns. Shows quality score impact.
|
|
200
202
|
|
|
201
203
|
#### `lpm skills install [package]`
|
|
204
|
+
|
|
202
205
|
Fetch and install skills from the registry. Without a package argument, installs skills for all `@lpm.dev/*` dependencies in package.json. Saves to `.lpm/skills/{package-name}/` and adds `.lpm/skills/` to `.gitignore`.
|
|
203
206
|
|
|
204
207
|
#### `lpm skills list`
|
|
208
|
+
|
|
205
209
|
List available skills for all installed `@lpm.dev/*` packages. Shows which packages have skills, how many, and whether they're installed locally.
|
|
206
210
|
|
|
207
211
|
#### `lpm skills clean`
|
|
212
|
+
|
|
208
213
|
Remove the `.lpm/skills/` directory and all locally installed skills.
|
|
209
214
|
|
|
210
215
|
### Security & Maintenance
|
|
@@ -335,49 +340,49 @@ import {
|
|
|
335
340
|
runQualityChecks,
|
|
336
341
|
parseLpmPackageReference,
|
|
337
342
|
detectFramework,
|
|
338
|
-
} from "@lpm-registry/cli"
|
|
343
|
+
} from "@lpm-registry/cli";
|
|
339
344
|
```
|
|
340
345
|
|
|
341
346
|
### Integrity
|
|
342
347
|
|
|
343
|
-
| Function
|
|
344
|
-
|
|
345
|
-
| `generateIntegrity(buffer, algorithm?)`
|
|
346
|
-
| `verifyIntegrity(buffer, expected)`
|
|
347
|
-
| `verifyIntegrityMultiple(buffer, integrities)` | Verify against multiple integrity strings
|
|
348
|
-
| `parseIntegrity(integrity)`
|
|
348
|
+
| Function | Description |
|
|
349
|
+
| ---------------------------------------------- | -------------------------------------------------- |
|
|
350
|
+
| `generateIntegrity(buffer, algorithm?)` | Generate an SRI integrity hash (default: `sha512`) |
|
|
351
|
+
| `verifyIntegrity(buffer, expected)` | Verify a buffer against an SRI integrity string |
|
|
352
|
+
| `verifyIntegrityMultiple(buffer, integrities)` | Verify against multiple integrity strings |
|
|
353
|
+
| `parseIntegrity(integrity)` | Parse an SRI string into `{ algorithm, digest }` |
|
|
349
354
|
|
|
350
355
|
### Path Safety
|
|
351
356
|
|
|
352
|
-
| Function
|
|
353
|
-
|
|
354
|
-
| `validateComponentPath(root, path)`
|
|
355
|
-
| `validateTarballPaths(extractDir, paths)` | Check tarball entries for path traversal attacks
|
|
356
|
-
| `resolveSafePath(base, user)`
|
|
357
|
-
| `sanitizeFilename(name)`
|
|
357
|
+
| Function | Description |
|
|
358
|
+
| ----------------------------------------- | ------------------------------------------------------ |
|
|
359
|
+
| `validateComponentPath(root, path)` | Validate a component install path stays within project |
|
|
360
|
+
| `validateTarballPaths(extractDir, paths)` | Check tarball entries for path traversal attacks |
|
|
361
|
+
| `resolveSafePath(base, user)` | Safely resolve a user-provided path |
|
|
362
|
+
| `sanitizeFilename(name)` | Strip dangerous characters from a filename |
|
|
358
363
|
|
|
359
364
|
### Quality
|
|
360
365
|
|
|
361
|
-
| Function
|
|
362
|
-
|
|
366
|
+
| Function | Description |
|
|
367
|
+
| --------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
363
368
|
| `runQualityChecks({ packageJson, readme, lpmConfig, files, unpackedSize })` | Run all 28 quality checks and return score, checks, and tier |
|
|
364
369
|
|
|
365
370
|
### Package Config
|
|
366
371
|
|
|
367
|
-
| Function
|
|
368
|
-
|
|
369
|
-
| `parseLpmPackageReference(ref)`
|
|
370
|
-
| `readLpmConfig(dir)`
|
|
371
|
-
| `validateLpmConfig(config)`
|
|
372
|
-
| `filterFiles(files, config, params)` | Filter file rules based on config conditions
|
|
372
|
+
| Function | Description |
|
|
373
|
+
| ------------------------------------ | ---------------------------------------------------------- |
|
|
374
|
+
| `parseLpmPackageReference(ref)` | Parse `@lpm.dev/owner.pkg@version?key=val` into components |
|
|
375
|
+
| `readLpmConfig(dir)` | Read and validate `lpm.config.json` from a directory |
|
|
376
|
+
| `validateLpmConfig(config)` | Validate a parsed config object |
|
|
377
|
+
| `filterFiles(files, config, params)` | Filter file rules based on config conditions |
|
|
373
378
|
|
|
374
379
|
### Project Utils
|
|
375
380
|
|
|
376
|
-
| Function
|
|
377
|
-
|
|
378
|
-
| `detectFramework()`
|
|
379
|
-
| `getDefaultPath(framework, name)` | Get the default component install path for a framework
|
|
380
|
-
| `getUserImportPrefix()`
|
|
381
|
+
| Function | Description |
|
|
382
|
+
| --------------------------------- | --------------------------------------------------------- |
|
|
383
|
+
| `detectFramework()` | Detect the project framework (nextjs, vite, remix, astro) |
|
|
384
|
+
| `getDefaultPath(framework, name)` | Get the default component install path for a framework |
|
|
385
|
+
| `getUserImportPrefix()` | Get the user's import alias prefix (e.g. `@/`, `~/`) |
|
|
381
386
|
|
|
382
387
|
## Security
|
|
383
388
|
|
package/bin/lpm.js
CHANGED
|
@@ -103,6 +103,14 @@ program
|
|
|
103
103
|
"--min-score <score>",
|
|
104
104
|
"Minimum quality score required to publish (0-100)",
|
|
105
105
|
)
|
|
106
|
+
.option(
|
|
107
|
+
"--provenance",
|
|
108
|
+
"Force OIDC token exchange for secret-free publishing (auto-detected in GitHub Actions with id-token: write)",
|
|
109
|
+
)
|
|
110
|
+
.option(
|
|
111
|
+
"--dry-run",
|
|
112
|
+
"Validate and preview the publish (files, size, OIDC status) without uploading",
|
|
113
|
+
)
|
|
106
114
|
.action(publish)
|
|
107
115
|
|
|
108
116
|
program
|
package/lib/commands/publish.js
CHANGED
|
@@ -5,7 +5,11 @@ import path from "node:path"
|
|
|
5
5
|
import { promisify } from "node:util"
|
|
6
6
|
import * as p from "@clack/prompts"
|
|
7
7
|
import { request, verifyTokenScope } from "../api.js"
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
clearTransientToken,
|
|
10
|
+
getRegistryUrl,
|
|
11
|
+
setTransientToken,
|
|
12
|
+
} from "../config.js"
|
|
9
13
|
import { SUCCESS_MESSAGES, WARNING_MESSAGES } from "../constants.js"
|
|
10
14
|
import {
|
|
11
15
|
createEcosystemTarball,
|
|
@@ -15,6 +19,12 @@ import {
|
|
|
15
19
|
readSwiftManifest,
|
|
16
20
|
} from "../ecosystem.js"
|
|
17
21
|
import { generateIntegrity } from "../integrity.js"
|
|
22
|
+
import {
|
|
23
|
+
exchangeGitLabOidcToken,
|
|
24
|
+
exchangeOidcToken,
|
|
25
|
+
isGitHubActionsWithOidc,
|
|
26
|
+
isGitLabCiWithOidc,
|
|
27
|
+
} from "../oidc.js"
|
|
18
28
|
import { displayQualityReport } from "../quality/display.js"
|
|
19
29
|
import { runQualityChecks } from "../quality/score.js"
|
|
20
30
|
import { createSpinner, log, printHeader } from "../ui.js"
|
|
@@ -300,7 +310,9 @@ async function packWithTar(ecosystem, name, version) {
|
|
|
300
310
|
|
|
301
311
|
export async function publish(options = {}) {
|
|
302
312
|
const checkOnly = !!options.check
|
|
313
|
+
const dryRun = !!options.dryRun
|
|
303
314
|
const minScore = options.minScore ? parseInt(options.minScore, 10) : null
|
|
315
|
+
const forceProvenance = !!options.provenance
|
|
304
316
|
|
|
305
317
|
printHeader()
|
|
306
318
|
|
|
@@ -469,6 +481,41 @@ export async function publish(options = {}) {
|
|
|
469
481
|
spinner.stop()
|
|
470
482
|
displayQualityReport(qualityResult)
|
|
471
483
|
|
|
484
|
+
// --dry-run: print summary and exit without uploading
|
|
485
|
+
if (dryRun) {
|
|
486
|
+
const unpackedSize = npmPackMeta.unpackedSize || 0
|
|
487
|
+
const fileCount =
|
|
488
|
+
npmPackMeta.fileCount || (npmPackMeta.files?.length ?? 0)
|
|
489
|
+
const sizeStr =
|
|
490
|
+
unpackedSize >= 1024 * 1024
|
|
491
|
+
? `${(unpackedSize / 1024 / 1024).toFixed(1)} MB unpacked`
|
|
492
|
+
: `${(unpackedSize / 1024).toFixed(1)} kB unpacked`
|
|
493
|
+
|
|
494
|
+
const githubOidc = isGitHubActionsWithOidc()
|
|
495
|
+
const gitlabOidc = isGitLabCiWithOidc()
|
|
496
|
+
const oidcStr = githubOidc
|
|
497
|
+
? "GitHub Actions detected ✓"
|
|
498
|
+
: gitlabOidc
|
|
499
|
+
? "GitLab CI detected ✓"
|
|
500
|
+
: "Not running in CI"
|
|
501
|
+
const authStr =
|
|
502
|
+
githubOidc || gitlabOidc
|
|
503
|
+
? "Token will be exchanged at publish time"
|
|
504
|
+
: "Run lpm login or configure Trusted Publishers"
|
|
505
|
+
|
|
506
|
+
console.log("")
|
|
507
|
+
console.log(` Package ${name}`)
|
|
508
|
+
console.log(` Version ${version}`)
|
|
509
|
+
console.log(` Files ${fileCount} files`)
|
|
510
|
+
console.log(` Size ${sizeStr}`)
|
|
511
|
+
console.log("")
|
|
512
|
+
console.log(` OIDC ${oidcStr}`)
|
|
513
|
+
console.log(` Auth ${authStr}`)
|
|
514
|
+
console.log("")
|
|
515
|
+
log.info("Dry run complete — run without --dry-run to publish.")
|
|
516
|
+
process.exit(0)
|
|
517
|
+
}
|
|
518
|
+
|
|
472
519
|
// --check mode: display report and exit
|
|
473
520
|
if (checkOnly) {
|
|
474
521
|
if (minScore && qualityResult.score < minScore) {
|
|
@@ -500,6 +547,50 @@ export async function publish(options = {}) {
|
|
|
500
547
|
process.exit(0)
|
|
501
548
|
}
|
|
502
549
|
|
|
550
|
+
// 8.5. OIDC auto-detection: exchange a GitHub Actions or GitLab CI OIDC
|
|
551
|
+
// token for a short-lived LPM publish token. Runs before auth verification
|
|
552
|
+
// so that verifyTokenScope() picks up the OIDC token via getToken().
|
|
553
|
+
const githubOidcAvailable = isGitHubActionsWithOidc()
|
|
554
|
+
const gitlabOidcAvailable = isGitLabCiWithOidc()
|
|
555
|
+
const oidcAvailable = githubOidcAvailable || gitlabOidcAvailable
|
|
556
|
+
|
|
557
|
+
if (forceProvenance && !oidcAvailable) {
|
|
558
|
+
p.cancel(
|
|
559
|
+
"--provenance requires OIDC to be available. " +
|
|
560
|
+
"For GitHub Actions, ensure `id-token: write` permission. " +
|
|
561
|
+
"For GitLab CI, configure id_tokens in your job.",
|
|
562
|
+
)
|
|
563
|
+
process.exit(1)
|
|
564
|
+
}
|
|
565
|
+
if (oidcAvailable) {
|
|
566
|
+
spinner.start()
|
|
567
|
+
if (githubOidcAvailable) {
|
|
568
|
+
spinner.text = "Requesting OIDC token from GitHub Actions..."
|
|
569
|
+
} else {
|
|
570
|
+
spinner.text = "Exchanging GitLab CI OIDC token..."
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
const oidcToken = githubOidcAvailable
|
|
574
|
+
? await exchangeOidcToken(name)
|
|
575
|
+
: await exchangeGitLabOidcToken(name)
|
|
576
|
+
setTransientToken(oidcToken)
|
|
577
|
+
spinner.stop()
|
|
578
|
+
const provider = githubOidcAvailable ? "GitHub Actions" : "GitLab CI"
|
|
579
|
+
log.success(
|
|
580
|
+
`OIDC authentication successful (${provider} — no secrets needed)`,
|
|
581
|
+
)
|
|
582
|
+
} catch (err) {
|
|
583
|
+
spinner.stop()
|
|
584
|
+
if (forceProvenance) {
|
|
585
|
+
// When --provenance is explicit, a failure is fatal
|
|
586
|
+
throw new Error(`OIDC exchange failed: ${err.message}`)
|
|
587
|
+
}
|
|
588
|
+
// Auto-detection: non-fatal, fall back to configured credentials
|
|
589
|
+
log.warn(`OIDC exchange failed: ${err.message}`)
|
|
590
|
+
log.warn("Falling back to stored credentials...")
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
503
594
|
// 9. Verify authentication and owner permissions
|
|
504
595
|
spinner.start()
|
|
505
596
|
spinner.text = "Verifying authentication..."
|
|
@@ -695,6 +786,9 @@ export async function publish(options = {}) {
|
|
|
695
786
|
|
|
696
787
|
process.exit(1)
|
|
697
788
|
} finally {
|
|
789
|
+
// Clear OIDC transient token so it doesn't leak into subsequent operations
|
|
790
|
+
clearTransientToken()
|
|
791
|
+
|
|
698
792
|
// Cleanup tarball (even on error)
|
|
699
793
|
if (tarballPath && fs.existsSync(tarballPath)) {
|
|
700
794
|
try {
|
package/lib/commands/skills.js
CHANGED
|
@@ -161,6 +161,15 @@ function parseSkill(rawContent, filePath) {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// Optional version field (package version when skill was authored)
|
|
165
|
+
const version = frontmatter.version
|
|
166
|
+
if (version && typeof version !== "string") {
|
|
167
|
+
return {
|
|
168
|
+
skill: null,
|
|
169
|
+
error: `${filePath}: "version" field must be a string`,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
164
173
|
const content = markdownBody.trim()
|
|
165
174
|
|
|
166
175
|
return {
|
|
@@ -168,6 +177,7 @@ function parseSkill(rawContent, filePath) {
|
|
|
168
177
|
name: frontmatter.name,
|
|
169
178
|
description: frontmatter.description,
|
|
170
179
|
globs: globs && globs.length > 0 ? globs : null,
|
|
180
|
+
version: version || null,
|
|
171
181
|
content,
|
|
172
182
|
rawContent,
|
|
173
183
|
sizeBytes,
|
package/lib/config.js
CHANGED
|
@@ -37,12 +37,39 @@ const config = new Conf({
|
|
|
37
37
|
// Token Management (Secure Storage)
|
|
38
38
|
// ============================================================================
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Transient token — set for the duration of an OIDC-authenticated publish.
|
|
42
|
+
* Takes priority over all other token sources so the short-lived OIDC token
|
|
43
|
+
* is used without writing to keychain or mutating process.env.
|
|
44
|
+
* @type {string | null}
|
|
45
|
+
*/
|
|
46
|
+
let _transientToken = null
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Set a transient token that overrides all other token sources.
|
|
50
|
+
* Used by OIDC token exchange to inject a short-lived publish token.
|
|
51
|
+
* @param {string} token
|
|
52
|
+
*/
|
|
53
|
+
export function setTransientToken(token) {
|
|
54
|
+
_transientToken = token
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clear the transient token after use.
|
|
59
|
+
*/
|
|
60
|
+
export function clearTransientToken() {
|
|
61
|
+
_transientToken = null
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
/**
|
|
41
65
|
* Get the stored auth token from secure storage.
|
|
42
|
-
* Priority: LPM_TOKEN env var > secure storage > legacy storage
|
|
66
|
+
* Priority: transient (OIDC) > LPM_TOKEN env var > secure storage > legacy storage
|
|
43
67
|
* @returns {Promise<string | null>}
|
|
44
68
|
*/
|
|
45
69
|
export async function getToken() {
|
|
70
|
+
// OIDC-exchanged short-lived token takes highest priority
|
|
71
|
+
if (_transientToken) return _transientToken
|
|
72
|
+
|
|
46
73
|
// Check environment variable first (useful for CI/CD and testing)
|
|
47
74
|
const envToken = process.env.LPM_TOKEN
|
|
48
75
|
if (envToken) return envToken
|
package/lib/oidc.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC (OpenID Connect) token exchange for secret-free publishing.
|
|
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 publish token — no LPM_TOKEN secret required.
|
|
7
|
+
*
|
|
8
|
+
* The package must have a matching trusted publisher configured in
|
|
9
|
+
* the LPM dashboard (Workflow tab) before this will succeed.
|
|
10
|
+
*
|
|
11
|
+
* @module cli/lib/oidc
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getRegistryUrl } from "./config.js"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detect whether we are running in GitHub Actions with OIDC available.
|
|
18
|
+
* Both env vars must be set for token request to work.
|
|
19
|
+
*
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function isGitHubActionsWithOidc() {
|
|
23
|
+
return !!(
|
|
24
|
+
process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
|
|
25
|
+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect whether we are running in GitLab CI with an OIDC token injected.
|
|
31
|
+
* GitLab injects the JWT directly as an env var via id_tokens — no HTTP fetch needed.
|
|
32
|
+
*
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isGitLabCiWithOidc() {
|
|
36
|
+
return !!(
|
|
37
|
+
process.env.GITLAB_CI === "true" && process.env.LPM_GITLAB_OIDC_TOKEN
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Exchange a GitHub Actions OIDC JWT for a short-lived LPM publish token.
|
|
43
|
+
*
|
|
44
|
+
* Steps:
|
|
45
|
+
* 1. Request an OIDC JWT from GitHub (audience: https://lpm.dev)
|
|
46
|
+
* 2. POST the JWT to the LPM OIDC token exchange endpoint
|
|
47
|
+
* 3. Return the resulting `lpm_xxx` token (valid for 15 minutes)
|
|
48
|
+
*
|
|
49
|
+
* @param {string} packageName - Full package name, e.g. "@lpm.dev/owner.pkg-name"
|
|
50
|
+
* @returns {Promise<string>} Short-lived LPM publish token
|
|
51
|
+
* @throws {Error} If GitHub OIDC request fails or LPM exchange is rejected
|
|
52
|
+
*/
|
|
53
|
+
export async function exchangeOidcToken(packageName) {
|
|
54
|
+
// 1. Request OIDC JWT from GitHub Actions
|
|
55
|
+
// The audience must match EXPECTED_AUDIENCE in lib/registry/oidc.js on the server
|
|
56
|
+
const githubTokenUrl = `${process.env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://lpm.dev`
|
|
57
|
+
|
|
58
|
+
const githubRes = await fetch(githubTokenUrl, {
|
|
59
|
+
headers: {
|
|
60
|
+
Authorization: `bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (!githubRes.ok) {
|
|
65
|
+
const body = await githubRes.text().catch(() => "")
|
|
66
|
+
throw new Error(
|
|
67
|
+
`GitHub OIDC token request failed (${githubRes.status})${body ? `: ${body}` : ""}`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { value: oidcJwt } = await githubRes.json()
|
|
72
|
+
if (!oidcJwt) {
|
|
73
|
+
throw new Error("GitHub OIDC response did not contain a token value")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Exchange the OIDC JWT for a short-lived LPM publish token
|
|
77
|
+
const registryUrl = getRegistryUrl()
|
|
78
|
+
const exchangeRes = await fetch(`${registryUrl}/api/registry/-/token/oidc`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
body: JSON.stringify({ token: oidcJwt, package: packageName }),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (!exchangeRes.ok) {
|
|
85
|
+
const body = await exchangeRes.text().catch(() => "")
|
|
86
|
+
throw new Error(
|
|
87
|
+
`OIDC token exchange failed (${exchangeRes.status})${body ? `: ${body}` : ""}`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { token } = await exchangeRes.json()
|
|
92
|
+
if (!token) {
|
|
93
|
+
throw new Error("OIDC exchange response did not contain a token")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return token
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Exchange a GitLab CI OIDC JWT for a short-lived LPM publish token.
|
|
101
|
+
*
|
|
102
|
+
* GitLab injects the JWT directly into LPM_GITLAB_OIDC_TOKEN via id_tokens — no
|
|
103
|
+
* intermediate HTTP fetch is needed (unlike GitHub Actions).
|
|
104
|
+
*
|
|
105
|
+
* @param {string} packageName - Full package name, e.g. "@lpm.dev/owner.pkg-name"
|
|
106
|
+
* @returns {Promise<string>} Short-lived LPM publish token
|
|
107
|
+
* @throws {Error} If the OIDC token is missing or the LPM exchange is rejected
|
|
108
|
+
*/
|
|
109
|
+
export async function exchangeGitLabOidcToken(packageName) {
|
|
110
|
+
const oidcJwt = process.env.LPM_GITLAB_OIDC_TOKEN
|
|
111
|
+
if (!oidcJwt) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"LPM_GITLAB_OIDC_TOKEN is not set. " +
|
|
114
|
+
"Add id_tokens: LPM_GITLAB_OIDC_TOKEN: { aud: https://lpm.dev } to your .gitlab-ci.yml",
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const registryUrl = getRegistryUrl()
|
|
119
|
+
const exchangeRes = await fetch(`${registryUrl}/api/registry/-/token/oidc`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "Content-Type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ token: oidcJwt, package: packageName }),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if (!exchangeRes.ok) {
|
|
126
|
+
const body = await exchangeRes.text().catch(() => "")
|
|
127
|
+
throw new Error(
|
|
128
|
+
`OIDC token exchange failed (${exchangeRes.status})${body ? `: ${body}` : ""}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { token } = await exchangeRes.json()
|
|
133
|
+
if (!token) {
|
|
134
|
+
throw new Error("OIDC exchange response did not contain a token")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return token
|
|
138
|
+
}
|