@lpm-registry/cli 0.2.4 → 0.2.6
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 -0
- package/README.md +28 -9
- package/bin/lpm.js +20 -0
- package/lib/commands/doctor.js +7 -9
- package/lib/commands/install.js +66 -22
- package/lib/commands/npmrc.js +140 -0
- package/lib/commands/setup.js +80 -26
- package/lib/commands/skills.js +12 -1
- package/lib/commands/uninstall.js +107 -0
- package/lib/config.js +37 -0
- package/lib/oidc.js +79 -51
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.6] - 2026-03-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`lpm uninstall` command** — Mirrors `npm uninstall` with automatic registry auth. Aliases: `lpm un`, `lpm unlink`.
|
|
10
|
+
- **`--pm` flag for install/uninstall** — Choose package manager: `lpm install --pm pnpm`. Supports npm (default), pnpm, yarn, bun.
|
|
11
|
+
- **`lpm config set packageManager`** — Set default package manager globally. Also available via `LPM_PACKAGE_MANAGER` env var.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `lpm install` now shows package list before install: "Found 15 LPM packages in package.json"
|
|
16
|
+
- Skills install now scoped to explicit packages when specified (`lpm install @lpm.dev/pkg` only fetches skills for that package)
|
|
17
|
+
- Skills install skips `file:`, `link:`, `workspace:` dependencies (local packages)
|
|
18
|
+
- `lpm doctor` registry check fixed — uses `/-/whoami` endpoint instead of non-existent `/health`
|
|
19
|
+
- Publish script updated with version bumping, git commit/tag/push, and `--dry-run` support
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- pnpm/yarn/bun compatibility — no longer passes npm-specific `--userconfig` flag. Uses project `.npmrc` with backup/restore for non-npm package managers.
|
|
24
|
+
|
|
25
|
+
## [0.2.5] - 2026-03-19
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`lpm npmrc` command** — Generates a 30-day read-only token and writes it to `.npmrc` for local development. Auto-adds `.npmrc` to `.gitignore`. Makes `npm install` work without env vars or manual token setup.
|
|
30
|
+
- **`lpm setup --oidc` flag** — Exchanges CI OIDC token (GitHub Actions, GitLab CI) for a short-lived read-only install token. Eliminates static `LPM_TOKEN` secrets in CI. Falls back to `${LPM_TOKEN}` placeholder if OIDC is unavailable.
|
|
31
|
+
- **OIDC read scope support** — Server-side OIDC token exchange now accepts `?scope=read` for install-only tokens (30 min TTL). Uses Supabase OAuth identity linking instead of trusted publisher config.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- OIDC module refactored for reuse — `exchangeOidcInstallToken()` auto-detects CI environment and exchanges for read-only tokens
|
|
36
|
+
- `lpm setup` default mode now mentions `lpm npmrc` and `--oidc` in help output
|
|
37
|
+
|
|
5
38
|
## [0.2.4] - 2026-03-18
|
|
6
39
|
|
|
7
40
|
### Added
|
package/README.md
CHANGED
|
@@ -65,23 +65,28 @@ lpm whoami
|
|
|
65
65
|
|
|
66
66
|
### Project Setup
|
|
67
67
|
|
|
68
|
-
####
|
|
68
|
+
#### Generate .npmrc Token (Local Dev)
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
Generate a 30-day read-only token for your project. Makes `npm install` work with LPM packages.
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
-
lpm
|
|
73
|
+
lpm npmrc # 30-day token (default)
|
|
74
|
+
lpm npmrc --days 7 # 7-day token
|
|
75
|
+
lpm npmrc --days 90 # 90-day token
|
|
74
76
|
```
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
Automatically adds `.npmrc` to `.gitignore` to prevent token leaks.
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
#### Setup .npmrc (CI/CD)
|
|
81
|
+
|
|
82
|
+
Configure `.npmrc` for CI/CD environments.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
lpm setup # Writes ${LPM_TOKEN} placeholder
|
|
86
|
+
lpm setup --oidc # OIDC — no secrets needed (GitHub Actions, GitLab CI)
|
|
82
87
|
```
|
|
83
88
|
|
|
84
|
-
For deployment, set the `LPM_TOKEN` environment variable
|
|
89
|
+
For deployment platforms (Vercel, Netlify), set the `LPM_TOKEN` environment variable.
|
|
85
90
|
|
|
86
91
|
### Package Management
|
|
87
92
|
|
|
@@ -120,6 +125,7 @@ lpm install @lpm.dev/owner.package-name
|
|
|
120
125
|
lpm install @lpm.dev/tolgaergin.utils @lpm.dev/acme.helpers
|
|
121
126
|
lpm install # Install all @lpm.dev packages from package.json
|
|
122
127
|
lpm install --no-skills # Skip fetching Agent Skills
|
|
128
|
+
lpm install --pm pnpm # Use pnpm instead of npm
|
|
123
129
|
lpm i # Shortcut
|
|
124
130
|
```
|
|
125
131
|
|
|
@@ -128,6 +134,19 @@ Agent Skills are fetched by default for packages that include them.
|
|
|
128
134
|
| Option | Description |
|
|
129
135
|
| ------------- | ---------------------------------------- |
|
|
130
136
|
| `--no-skills` | Skip fetching Agent Skills after install |
|
|
137
|
+
| `--pm <name>` | Package manager: npm (default), pnpm, yarn, bun |
|
|
138
|
+
|
|
139
|
+
Set a default package manager: `lpm config set packageManager pnpm`
|
|
140
|
+
|
|
141
|
+
#### Uninstall
|
|
142
|
+
|
|
143
|
+
Uninstall packages with automatic registry authentication.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
lpm uninstall @lpm.dev/owner.package-name
|
|
147
|
+
lpm uninstall --pm pnpm # Use pnpm
|
|
148
|
+
lpm un # Shortcut
|
|
149
|
+
```
|
|
131
150
|
|
|
132
151
|
#### Add (Source Code)
|
|
133
152
|
|
package/bin/lpm.js
CHANGED
|
@@ -19,6 +19,7 @@ import { logout } from "../lib/commands/logout.js"
|
|
|
19
19
|
import { marketplaceCompare } from "../lib/commands/marketplace-compare.js"
|
|
20
20
|
import { marketplaceEarnings } from "../lib/commands/marketplace-earnings.js"
|
|
21
21
|
import { mcpRemove, mcpSetup, mcpStatus } from "../lib/commands/mcp-setup.js"
|
|
22
|
+
import { npmrc } from "../lib/commands/npmrc.js"
|
|
22
23
|
import { openDashboard } from "../lib/commands/open.js"
|
|
23
24
|
import { outdated } from "../lib/commands/outdated.js"
|
|
24
25
|
import { poolStats } from "../lib/commands/pool-stats.js"
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
skillsValidate,
|
|
36
37
|
} from "../lib/commands/skills.js"
|
|
37
38
|
import { rotateToken } from "../lib/commands/token-rotate.js"
|
|
39
|
+
import { uninstall } from "../lib/commands/uninstall.js"
|
|
38
40
|
import { whoami } from "../lib/commands/whoami.js"
|
|
39
41
|
|
|
40
42
|
// Load package.json
|
|
@@ -92,8 +94,16 @@ program
|
|
|
92
94
|
.description("Install packages with automatic registry authentication")
|
|
93
95
|
.option("--json", "Machine-readable JSON output")
|
|
94
96
|
.option("--no-skills", "Skip fetching Agent Skills after install")
|
|
97
|
+
.option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)")
|
|
95
98
|
.action(install)
|
|
96
99
|
|
|
100
|
+
program
|
|
101
|
+
.command("uninstall <packages...>")
|
|
102
|
+
.aliases(["un", "unlink"])
|
|
103
|
+
.description("Uninstall packages via package manager")
|
|
104
|
+
.option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)")
|
|
105
|
+
.action(uninstall)
|
|
106
|
+
|
|
97
107
|
program
|
|
98
108
|
.command("publish")
|
|
99
109
|
.alias("p")
|
|
@@ -202,8 +212,18 @@ program
|
|
|
202
212
|
.command("setup")
|
|
203
213
|
.description("Configure .npmrc for LPM packages (@lpm.dev scope)")
|
|
204
214
|
.option("-r, --registry <url>", "Custom registry URL")
|
|
215
|
+
.option(
|
|
216
|
+
"--oidc",
|
|
217
|
+
"Exchange CI OIDC token for install access (no secrets needed)",
|
|
218
|
+
)
|
|
205
219
|
.action(setup)
|
|
206
220
|
|
|
221
|
+
program
|
|
222
|
+
.command("npmrc")
|
|
223
|
+
.description("Generate a read-only .npmrc token for local development")
|
|
224
|
+
.option("-d, --days <number>", "Token expiry in days (default: 30)")
|
|
225
|
+
.action(npmrc)
|
|
226
|
+
|
|
207
227
|
program
|
|
208
228
|
.command("config [action] [key] [value]")
|
|
209
229
|
.description("Manage CLI configuration (list, get, set, delete)")
|
package/lib/commands/doctor.js
CHANGED
|
@@ -24,16 +24,14 @@ export async function doctor() {
|
|
|
24
24
|
// 2. Check Registry Reachability
|
|
25
25
|
const spinnerApi = createSpinner("Checking registry connection...").start()
|
|
26
26
|
try {
|
|
27
|
-
// Assuming we have a health endpoint or similar. Using the registry URL from config or default.
|
|
28
27
|
const registryUrl = getRegistryUrl()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
28
|
+
// Use /-/whoami without a token — a 401 "Missing token" proves the registry is reachable.
|
|
29
|
+
// Only network errors (ECONNREFUSED, timeout) indicate the registry is down.
|
|
30
|
+
await fetch(`${registryUrl}/api/registry/-/whoami`, {
|
|
31
|
+
signal: AbortSignal.timeout(10000),
|
|
32
|
+
})
|
|
33
|
+
spinnerApi.succeed(`Registry reachable at ${registryUrl}`)
|
|
34
|
+
checks.push({ name: "Registry API", status: "ok" })
|
|
37
35
|
} catch (err) {
|
|
38
36
|
spinnerApi.fail(`Could not connect to registry: ${err.message}`)
|
|
39
37
|
checks.push({ name: "Registry API", status: "fail" })
|
package/lib/commands/install.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
|
-
import { getRegistryUrl, getToken } from "../config.js"
|
|
4
|
+
import { getPackageManager, getRegistryUrl, getToken } from "../config.js"
|
|
5
5
|
import { createSpinner, log, printHeader } from "../ui.js"
|
|
6
6
|
import { skillsInstall } from "./skills.js"
|
|
7
7
|
|
|
@@ -44,6 +44,9 @@ export async function install(packages, options) {
|
|
|
44
44
|
process.exit(1)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Track if user specified packages explicitly (for scoped skills fetch)
|
|
48
|
+
const explicitPackages = packages && packages.length > 0
|
|
49
|
+
|
|
47
50
|
if (!packages || packages.length === 0) {
|
|
48
51
|
// No packages specified - read from package.json
|
|
49
52
|
const packageJsonPath = path.resolve(process.cwd(), "package.json")
|
|
@@ -99,17 +102,16 @@ export async function install(packages, options) {
|
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
if (!isJson) {
|
|
102
|
-
log.info(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
log.info(`Found ${packages.length} LPM packages in package.json:`)
|
|
106
|
+
for (const pkg of packages) {
|
|
107
|
+
log.info(` ${pkg}`)
|
|
108
|
+
}
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
let spinner
|
|
109
113
|
if (!isJson) {
|
|
110
|
-
spinner = createSpinner(
|
|
111
|
-
`Preparing to install ${packages.join(", ")}...`,
|
|
112
|
-
).start()
|
|
114
|
+
spinner = createSpinner("Configuring registry authentication...").start()
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
const baseRegistryUrl = getRegistryUrl()
|
|
@@ -126,26 +128,54 @@ export async function install(packages, options) {
|
|
|
126
128
|
@lpm.dev:registry=${registryUrl}
|
|
127
129
|
`
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const pm = getPackageManager(options?.pm)
|
|
132
|
+
|
|
133
|
+
// For npm we can use --userconfig with a temp file.
|
|
134
|
+
// For pnpm/yarn/bun we write a temporary project .npmrc (all PMs read it).
|
|
135
|
+
const projectNpmrcPath = path.resolve(process.cwd(), ".npmrc")
|
|
136
|
+
let existingNpmrc = null
|
|
137
|
+
let tempNpmrcPath = null
|
|
138
|
+
|
|
139
|
+
if (pm === "npm") {
|
|
140
|
+
// npm supports --userconfig — use a separate temp file (doesn't touch project .npmrc)
|
|
141
|
+
tempNpmrcPath = path.resolve(process.cwd(), `.npmrc.lpm-${Date.now()}`)
|
|
142
|
+
fs.writeFileSync(tempNpmrcPath, npmrcContent)
|
|
143
|
+
} else {
|
|
144
|
+
// pnpm/yarn/bun read project .npmrc — back up existing, write temp, restore after
|
|
145
|
+
if (fs.existsSync(projectNpmrcPath)) {
|
|
146
|
+
existingNpmrc = fs.readFileSync(projectNpmrcPath, "utf8")
|
|
147
|
+
}
|
|
148
|
+
fs.writeFileSync(projectNpmrcPath, npmrcContent)
|
|
149
|
+
}
|
|
132
150
|
|
|
133
151
|
if (!isJson) {
|
|
134
|
-
spinner.succeed(
|
|
152
|
+
spinner.succeed(`Configuration generated. Running ${pm} install...`)
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
const pmArgs =
|
|
156
|
+
pm === "npm"
|
|
157
|
+
? ["install", ...packages, "--userconfig", tempNpmrcPath]
|
|
158
|
+
: ["install", ...packages]
|
|
139
159
|
|
|
140
160
|
// In JSON mode, capture stdout/stderr instead of inheriting
|
|
141
|
-
const child = spawn(
|
|
161
|
+
const child = spawn(pm, pmArgs, {
|
|
142
162
|
stdio: isJson ? "pipe" : "inherit",
|
|
143
163
|
env: { ...process.env, LPM_TOKEN: token },
|
|
144
164
|
})
|
|
145
165
|
|
|
146
166
|
const cleanup = () => {
|
|
147
|
-
if (
|
|
148
|
-
|
|
167
|
+
if (pm === "npm") {
|
|
168
|
+
// Remove temp file
|
|
169
|
+
if (tempNpmrcPath && fs.existsSync(tempNpmrcPath)) {
|
|
170
|
+
fs.unlinkSync(tempNpmrcPath)
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Restore original .npmrc (or remove if there wasn't one)
|
|
174
|
+
if (existingNpmrc !== null) {
|
|
175
|
+
fs.writeFileSync(projectNpmrcPath, existingNpmrc)
|
|
176
|
+
} else if (fs.existsSync(projectNpmrcPath)) {
|
|
177
|
+
fs.unlinkSync(projectNpmrcPath)
|
|
178
|
+
}
|
|
149
179
|
}
|
|
150
180
|
}
|
|
151
181
|
|
|
@@ -168,11 +198,19 @@ export async function install(packages, options) {
|
|
|
168
198
|
packages: packages.map(p => ({ name: p })),
|
|
169
199
|
npmOutput: (stdout + stderr).trim(),
|
|
170
200
|
warnings: [],
|
|
171
|
-
errors: code !== 0 ? [
|
|
201
|
+
errors: code !== 0 ? [`${pm} install failed with code ${code}`] : [],
|
|
172
202
|
}
|
|
173
203
|
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
|
|
174
204
|
if (code !== 0) process.exit(code)
|
|
175
|
-
if (fetchSkills)
|
|
205
|
+
if (fetchSkills) {
|
|
206
|
+
if (explicitPackages) {
|
|
207
|
+
for (const pkg of packages.filter(isLpmPackage)) {
|
|
208
|
+
await skillsInstall(pkg, { json: true })
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
await skillsInstall(null, { json: true })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
176
214
|
})
|
|
177
215
|
|
|
178
216
|
child.on("error", err => {
|
|
@@ -182,7 +220,7 @@ export async function install(packages, options) {
|
|
|
182
220
|
packages: packages.map(p => ({ name: p })),
|
|
183
221
|
npmOutput: "",
|
|
184
222
|
warnings: [],
|
|
185
|
-
errors: [`Failed to start
|
|
223
|
+
errors: [`Failed to start ${pm}: ${err.message}`],
|
|
186
224
|
}
|
|
187
225
|
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
|
|
188
226
|
process.exit(1)
|
|
@@ -191,19 +229,25 @@ export async function install(packages, options) {
|
|
|
191
229
|
child.on("close", async code => {
|
|
192
230
|
cleanup()
|
|
193
231
|
if (code !== 0) {
|
|
194
|
-
log.error(
|
|
232
|
+
log.error(`${pm} install failed with code ${code}`)
|
|
195
233
|
process.exit(code)
|
|
196
234
|
} else {
|
|
197
235
|
log.success("Packages installed successfully.")
|
|
198
236
|
if (fetchSkills) {
|
|
199
237
|
console.log("")
|
|
200
|
-
|
|
238
|
+
if (explicitPackages) {
|
|
239
|
+
for (const pkg of packages.filter(isLpmPackage)) {
|
|
240
|
+
await skillsInstall(pkg)
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
await skillsInstall(null)
|
|
244
|
+
}
|
|
201
245
|
}
|
|
202
246
|
}
|
|
203
247
|
})
|
|
204
248
|
|
|
205
249
|
child.on("error", err => {
|
|
206
|
-
log.error(`Failed to start
|
|
250
|
+
log.error(`Failed to start ${pm}: ${err.message}`)
|
|
207
251
|
cleanup()
|
|
208
252
|
process.exit(1)
|
|
209
253
|
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { request } from "../api.js"
|
|
4
|
+
import { getRegistryUrl, getToken } from "../config.js"
|
|
5
|
+
import { createSpinner, log, printHeader } from "../ui.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a read-only .npmrc token for local development.
|
|
9
|
+
* Creates a 30-day scoped token and writes it to the project's .npmrc.
|
|
10
|
+
* Also ensures .npmrc is in .gitignore to prevent accidental commits.
|
|
11
|
+
*/
|
|
12
|
+
export async function npmrc(options) {
|
|
13
|
+
printHeader()
|
|
14
|
+
|
|
15
|
+
const token = await getToken()
|
|
16
|
+
if (!token) {
|
|
17
|
+
log.error(
|
|
18
|
+
'You must be logged in to generate an .npmrc token. Run "lpm login" first.',
|
|
19
|
+
)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const projectRoot = process.cwd()
|
|
24
|
+
const npmrcPath = path.join(projectRoot, ".npmrc")
|
|
25
|
+
const gitignorePath = path.join(projectRoot, ".gitignore")
|
|
26
|
+
|
|
27
|
+
// Check for package.json
|
|
28
|
+
const pkgJsonPath = path.join(projectRoot, "package.json")
|
|
29
|
+
if (!fs.existsSync(pkgJsonPath)) {
|
|
30
|
+
log.warn("No package.json found. Run this command in your project root.")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Derive project name for the token label
|
|
34
|
+
let projectName = "project"
|
|
35
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"))
|
|
38
|
+
if (pkg.name) {
|
|
39
|
+
projectName = pkg.name.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Invalid package.json — use default name
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const expiryDays = parseInt(options?.days, 10) || 30
|
|
47
|
+
const spinner = createSpinner("Creating read-only token...").start()
|
|
48
|
+
|
|
49
|
+
// Call server to create a scoped read-only token
|
|
50
|
+
let readToken
|
|
51
|
+
let expiresAt
|
|
52
|
+
try {
|
|
53
|
+
const tokenName = `npmrc-${projectName}-${new Date().toISOString().slice(0, 10)}`
|
|
54
|
+
const response = await request("/-/token/create", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
scope: "read",
|
|
58
|
+
name: tokenName,
|
|
59
|
+
expiryDays,
|
|
60
|
+
}),
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const text = await response.text()
|
|
66
|
+
throw new Error(`Token creation failed: ${response.status} ${text}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = await response.json()
|
|
70
|
+
readToken = data.token
|
|
71
|
+
expiresAt = data.expiresAt
|
|
72
|
+
spinner.succeed("Read-only token created.")
|
|
73
|
+
} catch (error) {
|
|
74
|
+
spinner.fail(`Failed to create token: ${error.message}`)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Build .npmrc content
|
|
79
|
+
const registryUrl = getRegistryUrl()
|
|
80
|
+
const fullRegistryUrl = registryUrl.endsWith("/api/registry")
|
|
81
|
+
? registryUrl
|
|
82
|
+
: `${registryUrl}/api/registry`
|
|
83
|
+
const registryHost = fullRegistryUrl.replace(/^https?:/, "")
|
|
84
|
+
|
|
85
|
+
// Read existing .npmrc and remove old LPM config lines
|
|
86
|
+
let npmrcContent = ""
|
|
87
|
+
if (fs.existsSync(npmrcPath)) {
|
|
88
|
+
npmrcContent = fs
|
|
89
|
+
.readFileSync(npmrcPath, "utf8")
|
|
90
|
+
.split("\n")
|
|
91
|
+
.filter(line => {
|
|
92
|
+
return (
|
|
93
|
+
!line.includes("@lpm.dev:registry") &&
|
|
94
|
+
!line.includes("lpm.dev/api/registry/:_authToken") &&
|
|
95
|
+
!line.includes("_authToken=lpm_") &&
|
|
96
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: matching literal npm token placeholder
|
|
97
|
+
!line.includes("_authToken=${LPM_TOKEN}") &&
|
|
98
|
+
!line.includes("# LPM Registry")
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
.join("\n")
|
|
102
|
+
.trim()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lpmConfig = `# LPM Registry (generated by lpm npmrc — do not commit)
|
|
106
|
+
@lpm.dev:registry=${fullRegistryUrl}
|
|
107
|
+
${registryHost}/:_authToken=${readToken}`
|
|
108
|
+
|
|
109
|
+
npmrcContent = npmrcContent ? `${npmrcContent}\n\n${lpmConfig}` : lpmConfig
|
|
110
|
+
fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
|
|
111
|
+
|
|
112
|
+
// Ensure .npmrc is in .gitignore
|
|
113
|
+
let gitignoreUpdated = false
|
|
114
|
+
if (fs.existsSync(gitignorePath)) {
|
|
115
|
+
const gitignore = fs.readFileSync(gitignorePath, "utf8")
|
|
116
|
+
if (!gitignore.split("\n").some(line => line.trim() === ".npmrc")) {
|
|
117
|
+
fs.writeFileSync(gitignorePath, `${gitignore.trimEnd()}\n.npmrc\n`)
|
|
118
|
+
gitignoreUpdated = true
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
fs.writeFileSync(gitignorePath, ".npmrc\n")
|
|
122
|
+
gitignoreUpdated = true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Output
|
|
126
|
+
console.log("")
|
|
127
|
+
log.success(".npmrc configured with read-only LPM token.")
|
|
128
|
+
if (gitignoreUpdated) {
|
|
129
|
+
log.info(".npmrc added to .gitignore to prevent token leaks.")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const expiryDate = new Date(expiresAt).toLocaleDateString("en-US", {
|
|
133
|
+
year: "numeric",
|
|
134
|
+
month: "long",
|
|
135
|
+
day: "numeric",
|
|
136
|
+
})
|
|
137
|
+
log.info(`Token expires: ${expiryDate}`)
|
|
138
|
+
log.info("Run `lpm npmrc` again to refresh when expired.")
|
|
139
|
+
console.log("")
|
|
140
|
+
}
|
package/lib/commands/setup.js
CHANGED
|
@@ -3,11 +3,36 @@ import path from "node:path"
|
|
|
3
3
|
import * as p from "@clack/prompts"
|
|
4
4
|
import chalk from "chalk"
|
|
5
5
|
import { getRegistryUrl } from "../config.js"
|
|
6
|
+
import { exchangeOidcInstallToken } from "../oidc.js"
|
|
6
7
|
import { log, printHeader } from "../ui.js"
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Remove existing LPM config lines from .npmrc content.
|
|
11
|
+
* @param {string} content
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function removeLpmLines(content) {
|
|
15
|
+
return content
|
|
16
|
+
.split("\n")
|
|
17
|
+
.filter(line => {
|
|
18
|
+
return (
|
|
19
|
+
!line.includes("@lpm.dev:registry") &&
|
|
20
|
+
!line.includes("lpm.dev/api/registry/:_authToken") &&
|
|
21
|
+
!line.includes("_authToken=lpm_") &&
|
|
22
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: matching literal npm token placeholder
|
|
23
|
+
!line.includes("_authToken=${LPM_TOKEN}") &&
|
|
24
|
+
!line.includes("# LPM Registry")
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
.join("\n")
|
|
28
|
+
.trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
/**
|
|
9
32
|
* Configure .npmrc for LPM packages.
|
|
10
|
-
*
|
|
33
|
+
*
|
|
34
|
+
* Default mode: writes ${LPM_TOKEN} placeholder (for CI with env var).
|
|
35
|
+
* --oidc mode: exchanges CI OIDC token for a real short-lived token (no secrets needed).
|
|
11
36
|
*/
|
|
12
37
|
export async function setup(options) {
|
|
13
38
|
printHeader()
|
|
@@ -24,6 +49,56 @@ export async function setup(options) {
|
|
|
24
49
|
log.warn("No package.json found. Run this command in your project root.")
|
|
25
50
|
}
|
|
26
51
|
|
|
52
|
+
// Build registry URL
|
|
53
|
+
const fullRegistryUrl = registryUrl.endsWith("/api/registry")
|
|
54
|
+
? registryUrl
|
|
55
|
+
: `${registryUrl}/api/registry`
|
|
56
|
+
const registryHost = fullRegistryUrl.replace(/^https?:/, "")
|
|
57
|
+
|
|
58
|
+
// ── OIDC mode: exchange CI token for a real short-lived install token ──
|
|
59
|
+
if (options?.oidc) {
|
|
60
|
+
let oidcToken
|
|
61
|
+
try {
|
|
62
|
+
oidcToken = await exchangeOidcInstallToken()
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log.warn(`OIDC exchange failed: ${err.message}`)
|
|
65
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal placeholder name in user-facing message
|
|
66
|
+
log.warn("Falling back to ${LPM_TOKEN} environment variable placeholder.")
|
|
67
|
+
// Fall through to default mode
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (oidcToken) {
|
|
71
|
+
let npmrcContent = ""
|
|
72
|
+
if (fs.existsSync(npmrcPath)) {
|
|
73
|
+
npmrcContent = removeLpmLines(fs.readFileSync(npmrcPath, "utf8"))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lpmConfig = `# LPM Registry (OIDC — expires in 30 minutes)
|
|
77
|
+
@lpm.dev:registry=${fullRegistryUrl}
|
|
78
|
+
${registryHost}/:_authToken=${oidcToken}`
|
|
79
|
+
|
|
80
|
+
npmrcContent = npmrcContent
|
|
81
|
+
? `${npmrcContent}\n\n${lpmConfig}`
|
|
82
|
+
: lpmConfig
|
|
83
|
+
fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
|
|
84
|
+
|
|
85
|
+
p.note(
|
|
86
|
+
`@lpm.dev:registry=${fullRegistryUrl}\nToken: OIDC read-only (30 min)`,
|
|
87
|
+
".npmrc configuration",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
console.log("")
|
|
91
|
+
log.success(".npmrc configured with OIDC read-only token.")
|
|
92
|
+
log.info("Token expires in 30 minutes. No LPM_TOKEN secret needed.")
|
|
93
|
+
console.log("")
|
|
94
|
+
|
|
95
|
+
p.outro("Setup complete!")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Default mode: ${LPM_TOKEN} placeholder ──
|
|
101
|
+
|
|
27
102
|
// Check for existing .npmrc
|
|
28
103
|
let npmrcContent = ""
|
|
29
104
|
if (fs.existsSync(npmrcPath)) {
|
|
@@ -41,40 +116,18 @@ export async function setup(options) {
|
|
|
41
116
|
return
|
|
42
117
|
}
|
|
43
118
|
|
|
44
|
-
|
|
45
|
-
npmrcContent = npmrcContent
|
|
46
|
-
.split("\n")
|
|
47
|
-
.filter(line => {
|
|
48
|
-
return (
|
|
49
|
-
!line.includes("@lpm.dev:registry") &&
|
|
50
|
-
!line.includes("lpm.dev/api/registry/:_authToken") &&
|
|
51
|
-
!line.includes("# LPM Registry")
|
|
52
|
-
)
|
|
53
|
-
})
|
|
54
|
-
.join("\n")
|
|
55
|
-
.trim()
|
|
119
|
+
npmrcContent = removeLpmLines(npmrcContent)
|
|
56
120
|
}
|
|
57
121
|
}
|
|
58
122
|
|
|
59
|
-
//
|
|
60
|
-
const fullRegistryUrl = registryUrl.endsWith("/api/registry")
|
|
61
|
-
? registryUrl
|
|
62
|
-
: `${registryUrl}/api/registry`
|
|
63
|
-
|
|
64
|
-
// Remove protocol for auth token config
|
|
65
|
-
const registryHost = fullRegistryUrl.replace(/^https?:/, "")
|
|
66
|
-
|
|
67
|
-
// Add LPM registry config
|
|
123
|
+
// Add LPM registry config with placeholder
|
|
68
124
|
const lpmConfig = `
|
|
69
125
|
# LPM Registry
|
|
70
126
|
@lpm.dev:registry=${fullRegistryUrl}
|
|
71
127
|
${registryHost}/:_authToken=\${LPM_TOKEN}
|
|
72
128
|
`.trim()
|
|
73
129
|
|
|
74
|
-
// Combine with existing content
|
|
75
130
|
npmrcContent = npmrcContent ? `${npmrcContent}\n\n${lpmConfig}` : lpmConfig
|
|
76
|
-
|
|
77
|
-
// Write .npmrc
|
|
78
131
|
fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
|
|
79
132
|
|
|
80
133
|
p.note(
|
|
@@ -84,8 +137,9 @@ ${registryHost}/:_authToken=\${LPM_TOKEN}
|
|
|
84
137
|
|
|
85
138
|
console.log("")
|
|
86
139
|
log.success(".npmrc configured for LPM packages.")
|
|
87
|
-
log.info("For local development: Run `lpm
|
|
140
|
+
log.info("For local development: Run `lpm npmrc` to generate a token.")
|
|
88
141
|
log.info("For CI/CD: Set the LPM_TOKEN environment variable.")
|
|
142
|
+
log.info("For CI/CD with OIDC: Run `lpm setup --oidc` (no secrets needed).")
|
|
89
143
|
console.log("")
|
|
90
144
|
|
|
91
145
|
p.outro("Setup complete!")
|
package/lib/commands/skills.js
CHANGED
|
@@ -322,7 +322,18 @@ function getLpmDependencies() {
|
|
|
322
322
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
323
323
|
|
|
324
324
|
return Object.entries(allDeps)
|
|
325
|
-
.filter(([name]) =>
|
|
325
|
+
.filter(([name, version]) => {
|
|
326
|
+
if (!name.startsWith("@lpm.dev/")) return false
|
|
327
|
+
// Skip local file/link references — they have their own .lpm/skills/
|
|
328
|
+
if (
|
|
329
|
+
version.startsWith("file:") ||
|
|
330
|
+
version.startsWith("link:") ||
|
|
331
|
+
version.startsWith("workspace:")
|
|
332
|
+
) {
|
|
333
|
+
return false
|
|
334
|
+
}
|
|
335
|
+
return true
|
|
336
|
+
})
|
|
326
337
|
.map(([name, version]) => ({
|
|
327
338
|
fullName: name,
|
|
328
339
|
shortName: name.replace("@lpm.dev/", ""),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { getPackageManager, getRegistryUrl, getToken } from "../config.js"
|
|
5
|
+
import { createSpinner, log, printHeader } from "../ui.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Uninstall LPM packages.
|
|
9
|
+
* Creates a temporary .npmrc with auth, runs the package manager's uninstall, and cleans up.
|
|
10
|
+
*
|
|
11
|
+
* @param {string[]} packages - Package names to uninstall
|
|
12
|
+
* @param {object} [options]
|
|
13
|
+
* @param {string} [options.pm] - Package manager override (npm, pnpm, yarn, bun)
|
|
14
|
+
*/
|
|
15
|
+
export async function uninstall(packages, options) {
|
|
16
|
+
printHeader()
|
|
17
|
+
|
|
18
|
+
if (!packages || packages.length === 0) {
|
|
19
|
+
log.error("Please specify at least one package to uninstall.")
|
|
20
|
+
log.info("Usage: lpm uninstall @lpm.dev/owner.package-name")
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const token = await getToken()
|
|
25
|
+
if (!token) {
|
|
26
|
+
log.error(
|
|
27
|
+
'You must be logged in to uninstall packages. Run "lpm login" first.',
|
|
28
|
+
)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pm = getPackageManager(options?.pm)
|
|
33
|
+
const spinner = createSpinner(
|
|
34
|
+
`Preparing to uninstall ${packages.join(", ")}...`,
|
|
35
|
+
).start()
|
|
36
|
+
|
|
37
|
+
const baseRegistryUrl = getRegistryUrl()
|
|
38
|
+
const registryUrl = baseRegistryUrl.endsWith("/api/registry")
|
|
39
|
+
? baseRegistryUrl
|
|
40
|
+
: `${baseRegistryUrl}/api/registry`
|
|
41
|
+
const registryHost = registryUrl.replace(/^https?:/, "")
|
|
42
|
+
|
|
43
|
+
const npmrcContent = `${registryHost}/:_authToken=${token}
|
|
44
|
+
@lpm.dev:registry=${registryUrl}
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
const projectNpmrcPath = path.resolve(process.cwd(), ".npmrc")
|
|
48
|
+
let existingNpmrc = null
|
|
49
|
+
let tempNpmrcPath = null
|
|
50
|
+
|
|
51
|
+
if (pm === "npm") {
|
|
52
|
+
tempNpmrcPath = path.resolve(process.cwd(), `.npmrc.lpm-${Date.now()}`)
|
|
53
|
+
fs.writeFileSync(tempNpmrcPath, npmrcContent)
|
|
54
|
+
} else {
|
|
55
|
+
if (fs.existsSync(projectNpmrcPath)) {
|
|
56
|
+
existingNpmrc = fs.readFileSync(projectNpmrcPath, "utf8")
|
|
57
|
+
}
|
|
58
|
+
fs.writeFileSync(projectNpmrcPath, npmrcContent)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
spinner.succeed(`Configuration generated. Running ${pm} uninstall...`)
|
|
62
|
+
|
|
63
|
+
const pmArgs =
|
|
64
|
+
pm === "npm"
|
|
65
|
+
? ["uninstall", ...packages, "--userconfig", tempNpmrcPath]
|
|
66
|
+
: ["uninstall", ...packages]
|
|
67
|
+
|
|
68
|
+
const child = spawn(pm, pmArgs, {
|
|
69
|
+
stdio: "inherit",
|
|
70
|
+
env: { ...process.env, LPM_TOKEN: token },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
if (pm === "npm") {
|
|
75
|
+
if (tempNpmrcPath && fs.existsSync(tempNpmrcPath)) {
|
|
76
|
+
fs.unlinkSync(tempNpmrcPath)
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
if (existingNpmrc !== null) {
|
|
80
|
+
fs.writeFileSync(projectNpmrcPath, existingNpmrc)
|
|
81
|
+
} else if (fs.existsSync(projectNpmrcPath)) {
|
|
82
|
+
fs.unlinkSync(projectNpmrcPath)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
child.on("close", code => {
|
|
88
|
+
cleanup()
|
|
89
|
+
if (code !== 0) {
|
|
90
|
+
log.error(`${pm} uninstall failed with code ${code}`)
|
|
91
|
+
process.exit(code)
|
|
92
|
+
} else {
|
|
93
|
+
log.success("Packages uninstalled successfully.")
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
child.on("error", err => {
|
|
98
|
+
log.error(`Failed to start ${pm}: ${err.message}`)
|
|
99
|
+
cleanup()
|
|
100
|
+
process.exit(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
process.on("SIGINT", () => {
|
|
104
|
+
cleanup()
|
|
105
|
+
process.exit()
|
|
106
|
+
})
|
|
107
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -183,6 +183,32 @@ export function setRetries(count) {
|
|
|
183
183
|
config.set("retries", count)
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// Package Manager Configuration
|
|
188
|
+
// ============================================================================
|
|
189
|
+
|
|
190
|
+
const VALID_PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"])
|
|
191
|
+
const DEFAULT_PACKAGE_MANAGER = "npm"
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get the configured package manager for install/uninstall operations.
|
|
195
|
+
* Priority: --pm flag > LPM_PACKAGE_MANAGER env > config > "npm"
|
|
196
|
+
* @param {string} [override] - CLI flag override (--pm value)
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function getPackageManager(override) {
|
|
200
|
+
const pm =
|
|
201
|
+
override ||
|
|
202
|
+
process.env.LPM_PACKAGE_MANAGER ||
|
|
203
|
+
config.get("packageManager", DEFAULT_PACKAGE_MANAGER)
|
|
204
|
+
|
|
205
|
+
if (!VALID_PACKAGE_MANAGERS.has(pm)) {
|
|
206
|
+
return DEFAULT_PACKAGE_MANAGER
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return pm
|
|
210
|
+
}
|
|
211
|
+
|
|
186
212
|
// ============================================================================
|
|
187
213
|
// General Configuration Access
|
|
188
214
|
// ============================================================================
|
|
@@ -197,6 +223,7 @@ export async function getAllConfig() {
|
|
|
197
223
|
|
|
198
224
|
return {
|
|
199
225
|
registryUrl: getRegistryUrl(),
|
|
226
|
+
packageManager: getPackageManager(),
|
|
200
227
|
timeout: getTimeout(),
|
|
201
228
|
retries: getRetries(),
|
|
202
229
|
secureStorage: usingKeychain ? "keychain" : "encrypted-file",
|
|
@@ -213,6 +240,7 @@ export function getConfigValue(key) {
|
|
|
213
240
|
const configMap = {
|
|
214
241
|
registry: getRegistryUrl,
|
|
215
242
|
registryUrl: getRegistryUrl,
|
|
243
|
+
packageManager: getPackageManager,
|
|
216
244
|
timeout: getTimeout,
|
|
217
245
|
retries: getRetries,
|
|
218
246
|
}
|
|
@@ -232,6 +260,15 @@ export function setConfigValue(key, value) {
|
|
|
232
260
|
const configMap = {
|
|
233
261
|
registry: setRegistryUrl,
|
|
234
262
|
registryUrl: setRegistryUrl,
|
|
263
|
+
packageManager: val => {
|
|
264
|
+
if (VALID_PACKAGE_MANAGERS.has(val)) {
|
|
265
|
+
config.set("packageManager", val)
|
|
266
|
+
} else {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Invalid package manager "${val}". Valid options: ${[...VALID_PACKAGE_MANAGERS].join(", ")}`,
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
},
|
|
235
272
|
timeout: val => setTimeout(Number(val)),
|
|
236
273
|
retries: val => setRetries(Number(val)),
|
|
237
274
|
}
|
package/lib/oidc.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OIDC (OpenID Connect) token exchange for secret-free
|
|
2
|
+
* OIDC (OpenID Connect) token exchange for secret-free CI operations.
|
|
3
3
|
*
|
|
4
4
|
* When running in GitHub Actions with `id-token: write` permission,
|
|
5
5
|
* this module requests an OIDC JWT from GitHub and exchanges it for
|
|
6
|
-
* a short-lived LPM
|
|
6
|
+
* a short-lived LPM token — no LPM_TOKEN secret required.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Two modes:
|
|
9
|
+
* - **Publish**: Requires trusted publisher config in LPM dashboard.
|
|
10
|
+
* - **Read (install)**: Requires GitHub/GitLab account linked to LPM user.
|
|
10
11
|
*
|
|
11
12
|
* @module cli/lib/oidc
|
|
12
13
|
*/
|
|
@@ -39,20 +40,10 @@ export function isGitLabCiWithOidc() {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
*
|
|
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
|
|
43
|
+
* Request a GitHub Actions OIDC JWT.
|
|
44
|
+
* @returns {Promise<string>} The raw JWT string
|
|
52
45
|
*/
|
|
53
|
-
|
|
54
|
-
// 1. Request OIDC JWT from GitHub Actions
|
|
55
|
-
// The audience must match EXPECTED_AUDIENCE in lib/registry/oidc.js on the server
|
|
46
|
+
async function requestGitHubOidcJwt() {
|
|
56
47
|
const githubTokenUrl = `${process.env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://lpm.dev`
|
|
57
48
|
|
|
58
49
|
const githubRes = await fetch(githubTokenUrl, {
|
|
@@ -73,40 +64,72 @@ export async function exchangeOidcToken(packageName) {
|
|
|
73
64
|
throw new Error("GitHub OIDC response did not contain a token value")
|
|
74
65
|
}
|
|
75
66
|
|
|
76
|
-
|
|
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
|
|
77
79
|
const registryUrl = getRegistryUrl()
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
)
|
|
83
93
|
|
|
84
94
|
if (!exchangeRes.ok) {
|
|
85
|
-
const
|
|
95
|
+
const text = await exchangeRes.text().catch(() => "")
|
|
86
96
|
throw new Error(
|
|
87
|
-
`OIDC token exchange failed (${exchangeRes.status})${
|
|
97
|
+
`OIDC token exchange failed (${exchangeRes.status})${text ? `: ${text}` : ""}`,
|
|
88
98
|
)
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
const
|
|
92
|
-
if (!token) {
|
|
101
|
+
const data = await exchangeRes.json()
|
|
102
|
+
if (!data.token) {
|
|
93
103
|
throw new Error("OIDC exchange response did not contain a token")
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
return
|
|
106
|
+
return data
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
/**
|
|
100
|
-
* Exchange a
|
|
110
|
+
* Exchange a GitHub Actions OIDC JWT for a short-lived LPM token.
|
|
101
111
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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.
|
|
104
127
|
*
|
|
105
|
-
* @param {string} packageName -
|
|
106
|
-
* @
|
|
107
|
-
* @
|
|
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
|
|
108
131
|
*/
|
|
109
|
-
export async function exchangeGitLabOidcToken(packageName) {
|
|
132
|
+
export async function exchangeGitLabOidcToken(packageName, options = {}) {
|
|
110
133
|
const oidcJwt = process.env.LPM_GITLAB_OIDC_TOKEN
|
|
111
134
|
if (!oidcJwt) {
|
|
112
135
|
throw new Error(
|
|
@@ -115,24 +138,29 @@ export async function exchangeGitLabOidcToken(packageName) {
|
|
|
115
138
|
)
|
|
116
139
|
}
|
|
117
140
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
headers: { "Content-Type": "application/json" },
|
|
122
|
-
body: JSON.stringify({ token: oidcJwt, package: packageName }),
|
|
141
|
+
const data = await exchangeJwtWithServer(oidcJwt, {
|
|
142
|
+
scope: options.scope || "publish",
|
|
143
|
+
packageName,
|
|
123
144
|
})
|
|
145
|
+
return data.token
|
|
146
|
+
}
|
|
124
147
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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" })
|
|
130
158
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!token) {
|
|
134
|
-
throw new Error("OIDC exchange response did not contain a token")
|
|
159
|
+
if (isGitLabCiWithOidc()) {
|
|
160
|
+
return exchangeGitLabOidcToken(null, { scope: "read" })
|
|
135
161
|
}
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
)
|
|
138
166
|
}
|