@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 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
- #### Setup .npmrc
68
+ #### Generate .npmrc Token (Local Dev)
69
69
 
70
- Configure your project's `.npmrc` for LPM packages. Required for CI/CD deployments.
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 setup
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
- This creates a `.npmrc` file with the LPM registry configuration:
78
+ Automatically adds `.npmrc` to `.gitignore` to prevent token leaks.
77
79
 
78
- ```ini
79
- # LPM Registry
80
- @lpm.dev:registry=https://lpm.dev/api/registry
81
- //lpm.dev/api/registry/:_authToken=${LPM_TOKEN}
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 on your platform (Vercel, Netlify, etc.).
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)")
@@ -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
- const res = await fetch(`${registryUrl}/api/registry/health`)
30
- if (res.ok) {
31
- spinnerApi.succeed(`Registry reachable at ${registryUrl}`)
32
- checks.push({ name: "Registry API", status: "ok" })
33
- } else {
34
- spinnerApi.fail(`Registry returned status ${res.status}`)
35
- checks.push({ name: "Registry API", status: "fail" })
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" })
@@ -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
- `Installing ${packages.length} LPM packages from package.json...`,
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
- // Write to temp file
130
- const tempNpmrcPath = path.resolve(process.cwd(), `.npmrc.lpm-${Date.now()}`)
131
- fs.writeFileSync(tempNpmrcPath, npmrcContent)
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("Configuration generated. Running npm install...")
152
+ spinner.succeed(`Configuration generated. Running ${pm} install...`)
135
153
  }
136
154
 
137
- // Run npm install
138
- const npmArgs = ["install", ...packages, "--userconfig", tempNpmrcPath]
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("npm", npmArgs, {
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 (fs.existsSync(tempNpmrcPath)) {
148
- fs.unlinkSync(tempNpmrcPath)
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 ? [`npm install failed with code ${code}`] : [],
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) await skillsInstall(null, { json: true })
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 npm: ${err.message}`],
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(`npm install failed with code ${code}`)
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
- await skillsInstall(null)
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 npm: ${err.message}`)
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
+ }
@@ -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
- * Sets up the @lpm.dev scope to point to the LPM registry.
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
- // Remove existing LPM config lines
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
- // Build registry URL
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 login` to authenticate.")
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!")
@@ -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]) => name.startsWith("@lpm.dev/"))
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 publishing.
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 publish token — no LPM_TOKEN secret required.
6
+ * a short-lived LPM token — no LPM_TOKEN secret required.
7
7
  *
8
- * The package must have a matching trusted publisher configured in
9
- * the LPM dashboard (Workflow tab) before this will succeed.
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
- * 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
43
+ * Request a GitHub Actions OIDC JWT.
44
+ * @returns {Promise<string>} The raw JWT string
52
45
  */
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
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
- // 2. Exchange the OIDC JWT for a short-lived LPM publish token
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 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
- })
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 body = await exchangeRes.text().catch(() => "")
95
+ const text = await exchangeRes.text().catch(() => "")
86
96
  throw new Error(
87
- `OIDC token exchange failed (${exchangeRes.status})${body ? `: ${body}` : ""}`,
97
+ `OIDC token exchange failed (${exchangeRes.status})${text ? `: ${text}` : ""}`,
88
98
  )
89
99
  }
90
100
 
91
- const { token } = await exchangeRes.json()
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 token
106
+ return data
97
107
  }
98
108
 
99
109
  /**
100
- * Exchange a GitLab CI OIDC JWT for a short-lived LPM publish token.
110
+ * Exchange a GitHub Actions OIDC JWT for a short-lived LPM token.
101
111
  *
102
- * GitLab injects the JWT directly into LPM_GITLAB_OIDC_TOKEN via id_tokens — no
103
- * intermediate HTTP fetch is needed (unlike GitHub Actions).
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 - 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
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 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 }),
141
+ const data = await exchangeJwtWithServer(oidcJwt, {
142
+ scope: options.scope || "publish",
143
+ packageName,
123
144
  })
145
+ return data.token
146
+ }
124
147
 
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
- )
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
- const { token } = await exchangeRes.json()
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
- return token
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpm-registry/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "CLI for Licensed Package Manager",
5
5
  "author": "LPM <hello@lpm.dev> (https://lpm.dev)",
6
6
  "repository": {