@lpm-registry/cli 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-03-22
6
+
7
+ ### Added
8
+
9
+ - **Upstream npm proxy mode (default)** — `lpm npmrc` and `lpm setup` now configure LPM as the default registry. All packages (LPM + npm) are fetched through LPM for faster, edge-cached installs via Cloudflare R2.
10
+ - **`--scoped` flag** — Use `lpm npmrc --scoped` or `lpm setup --scoped` to only route `@lpm.dev` packages through LPM (old behavior).
11
+ - **Custom registry detection** — If `.npmrc` already has a custom default registry (e.g., Artifactory), the CLI warns and automatically falls back to `--scoped` mode to avoid overriding it.
12
+ - **Pre-publish skills staleness warning** — When Agent Skills haven't changed since the previous version, the warning now appears before the publish confirmation prompt (instead of after), so you can cancel and update skills before publishing.
13
+
14
+ ### Changed
15
+
16
+ - `lpm npmrc` default output changed from `@lpm.dev:registry=...` (scoped) to `registry=...` (proxy mode)
17
+ - `lpm setup` and `lpm setup --oidc` default output changed to proxy mode
18
+ - Updated CLI output messaging to indicate proxy vs scoped mode
19
+ - Updated README with new default behavior and `--scoped` flag
20
+
21
+ ## [0.2.6] - 2026-03-21
22
+
23
+ ### Added
24
+
25
+ - **`lpm uninstall` command** — Mirrors `npm uninstall` with automatic registry auth. Aliases: `lpm un`, `lpm unlink`.
26
+ - **`--pm` flag for install/uninstall** — Choose package manager: `lpm install --pm pnpm`. Supports npm (default), pnpm, yarn, bun.
27
+ - **`lpm config set packageManager`** — Set default package manager globally. Also available via `LPM_PACKAGE_MANAGER` env var.
28
+
29
+ ### Changed
30
+
31
+ - `lpm install` now shows package list before install: "Found 15 LPM packages in package.json"
32
+ - Skills install now scoped to explicit packages when specified (`lpm install @lpm.dev/pkg` only fetches skills for that package)
33
+ - Skills install skips `file:`, `link:`, `workspace:` dependencies (local packages)
34
+ - `lpm doctor` registry check fixed — uses `/-/whoami` endpoint instead of non-existent `/health`
35
+ - Publish script updated with version bumping, git commit/tag/push, and `--dry-run` support
36
+
37
+ ### Fixed
38
+
39
+ - pnpm/yarn/bun compatibility — no longer passes npm-specific `--userconfig` flag. Uses project `.npmrc` with backup/restore for non-npm package managers.
40
+
5
41
  ## [0.2.5] - 2026-03-19
6
42
 
7
43
  ### Added
package/README.md CHANGED
@@ -65,23 +65,32 @@ 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
+
72
+ By default, LPM is configured as your default registry — all packages (LPM + npm) are fetched through LPM for faster, edge-cached installs.
71
73
 
72
74
  ```bash
73
- lpm setup
75
+ lpm npmrc # 30-day token, proxy mode (default)
76
+ lpm npmrc --days 7 # 7-day token
77
+ lpm npmrc --days 90 # 90-day token
78
+ lpm npmrc --scoped # Only route @lpm.dev packages through LPM
74
79
  ```
75
80
 
76
- This creates a `.npmrc` file with the LPM registry configuration:
81
+ Automatically adds `.npmrc` to `.gitignore` to prevent token leaks.
82
+
83
+ #### Setup .npmrc (CI/CD)
84
+
85
+ Configure `.npmrc` for CI/CD environments.
77
86
 
78
- ```ini
79
- # LPM Registry
80
- @lpm.dev:registry=https://lpm.dev/api/registry
81
- //lpm.dev/api/registry/:_authToken=${LPM_TOKEN}
87
+ ```bash
88
+ lpm setup # Writes ${LPM_TOKEN} placeholder (proxy mode)
89
+ lpm setup --oidc # OIDC — no secrets needed (GitHub Actions, GitLab CI)
90
+ lpm setup --scoped # Only route @lpm.dev packages through LPM
82
91
  ```
83
92
 
84
- For deployment, set the `LPM_TOKEN` environment variable on your platform (Vercel, Netlify, etc.).
93
+ For deployment platforms (Vercel, Netlify), set the `LPM_TOKEN` environment variable.
85
94
 
86
95
  ### Package Management
87
96
 
@@ -120,6 +129,7 @@ lpm install @lpm.dev/owner.package-name
120
129
  lpm install @lpm.dev/tolgaergin.utils @lpm.dev/acme.helpers
121
130
  lpm install # Install all @lpm.dev packages from package.json
122
131
  lpm install --no-skills # Skip fetching Agent Skills
132
+ lpm install --pm pnpm # Use pnpm instead of npm
123
133
  lpm i # Shortcut
124
134
  ```
125
135
 
@@ -128,6 +138,19 @@ Agent Skills are fetched by default for packages that include them.
128
138
  | Option | Description |
129
139
  | ------------- | ---------------------------------------- |
130
140
  | `--no-skills` | Skip fetching Agent Skills after install |
141
+ | `--pm <name>` | Package manager: npm (default), pnpm, yarn, bun |
142
+
143
+ Set a default package manager: `lpm config set packageManager pnpm`
144
+
145
+ #### Uninstall
146
+
147
+ Uninstall packages with automatic registry authentication.
148
+
149
+ ```bash
150
+ lpm uninstall @lpm.dev/owner.package-name
151
+ lpm uninstall --pm pnpm # Use pnpm
152
+ lpm un # Shortcut
153
+ ```
131
154
 
132
155
  #### Add (Source Code)
133
156
 
package/bin/lpm.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  skillsValidate,
37
37
  } from "../lib/commands/skills.js"
38
38
  import { rotateToken } from "../lib/commands/token-rotate.js"
39
+ import { uninstall } from "../lib/commands/uninstall.js"
39
40
  import { whoami } from "../lib/commands/whoami.js"
40
41
 
41
42
  // Load package.json
@@ -93,8 +94,16 @@ program
93
94
  .description("Install packages with automatic registry authentication")
94
95
  .option("--json", "Machine-readable JSON output")
95
96
  .option("--no-skills", "Skip fetching Agent Skills after install")
97
+ .option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)")
96
98
  .action(install)
97
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
+
98
107
  program
99
108
  .command("publish")
100
109
  .alias("p")
@@ -201,18 +210,26 @@ program
201
210
 
202
211
  program
203
212
  .command("setup")
204
- .description("Configure .npmrc for LPM packages (@lpm.dev scope)")
213
+ .description("Configure .npmrc for LPM registry")
205
214
  .option("-r, --registry <url>", "Custom registry URL")
206
215
  .option(
207
216
  "--oidc",
208
217
  "Exchange CI OIDC token for install access (no secrets needed)",
209
218
  )
219
+ .option(
220
+ "--scoped",
221
+ "Only route @lpm.dev packages through LPM (don't proxy npm)",
222
+ )
210
223
  .action(setup)
211
224
 
212
225
  program
213
226
  .command("npmrc")
214
227
  .description("Generate a read-only .npmrc token for local development")
215
228
  .option("-d, --days <number>", "Token expiry in days (default: 30)")
229
+ .option(
230
+ "--scoped",
231
+ "Only route @lpm.dev packages through LPM (don't proxy npm)",
232
+ )
216
233
  .action(npmrc)
217
234
 
218
235
  program
@@ -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
  })
@@ -82,15 +82,41 @@ export async function npmrc(options) {
82
82
  : `${registryUrl}/api/registry`
83
83
  const registryHost = fullRegistryUrl.replace(/^https?:/, "")
84
84
 
85
- // Read existing .npmrc and remove old LPM config lines
85
+ // Determine proxy vs scoped mode
86
+ let useScoped = !!options?.scoped
87
+
88
+ // Read existing .npmrc and check for custom default registry
86
89
  let npmrcContent = ""
87
90
  if (fs.existsSync(npmrcPath)) {
88
- npmrcContent = fs
89
- .readFileSync(npmrcPath, "utf8")
91
+ const existingContent = fs.readFileSync(npmrcPath, "utf8")
92
+
93
+ // Detect custom default registry (not npmjs.org and not lpm.dev)
94
+ if (!useScoped) {
95
+ const registryLine = existingContent
96
+ .split("\n")
97
+ .find(
98
+ line =>
99
+ line.match(/^registry=/) &&
100
+ !line.includes("registry.npmjs.org") &&
101
+ !line.includes("lpm.dev"),
102
+ )
103
+ if (registryLine) {
104
+ log.warn(`Found existing default registry: ${registryLine.trim()}`)
105
+ log.warn("Using --scoped mode to avoid overriding it.")
106
+ log.warn(
107
+ "To use LPM as your default registry, remove the existing registry= line first.",
108
+ )
109
+ useScoped = true
110
+ }
111
+ }
112
+
113
+ // Remove old LPM config lines (both scoped and unscoped formats)
114
+ npmrcContent = existingContent
90
115
  .split("\n")
91
116
  .filter(line => {
92
117
  return (
93
118
  !line.includes("@lpm.dev:registry") &&
119
+ !line.match(/^registry=.*lpm\.dev/) &&
94
120
  !line.includes("lpm.dev/api/registry/:_authToken") &&
95
121
  !line.includes("_authToken=lpm_") &&
96
122
  // biome-ignore lint/suspicious/noTemplateCurlyInString: matching literal npm token placeholder
@@ -102,8 +128,12 @@ export async function npmrc(options) {
102
128
  .trim()
103
129
  }
104
130
 
131
+ const registryLine = useScoped
132
+ ? `@lpm.dev:registry=${fullRegistryUrl}`
133
+ : `registry=${fullRegistryUrl}`
134
+
105
135
  const lpmConfig = `# LPM Registry (generated by lpm npmrc — do not commit)
106
- @lpm.dev:registry=${fullRegistryUrl}
136
+ ${registryLine}
107
137
  ${registryHost}/:_authToken=${readToken}`
108
138
 
109
139
  npmrcContent = npmrcContent ? `${npmrcContent}\n\n${lpmConfig}` : lpmConfig
@@ -129,6 +159,13 @@ ${registryHost}/:_authToken=${readToken}`
129
159
  log.info(".npmrc added to .gitignore to prevent token leaks.")
130
160
  }
131
161
 
162
+ if (useScoped) {
163
+ log.info("Only @lpm.dev packages will route through lpm.dev.")
164
+ } else {
165
+ log.info("All packages (LPM + npm) will route through lpm.dev.")
166
+ log.info("Note: First install may update resolved URLs in your lockfile.")
167
+ }
168
+
132
169
  const expiryDate = new Date(expiresAt).toLocaleDateString("en-US", {
133
170
  year: "numeric",
134
171
  month: "long",
@@ -522,8 +522,63 @@ export async function publish(options = {}) {
522
522
  swiftManifest,
523
523
  })
524
524
 
525
+ // 7b. Check for stale skills (compare local with published)
526
+ spinner.text = "Checking skills..."
527
+ const prePublishWarnings = []
528
+ try {
529
+ const localSkillsDir = path.join(process.cwd(), ".lpm", "skills")
530
+ if (fs.existsSync(localSkillsDir)) {
531
+ const skillFiles = fs
532
+ .readdirSync(localSkillsDir)
533
+ .filter(f => f.endsWith(".md"))
534
+ .sort()
535
+
536
+ if (skillFiles.length > 0) {
537
+ // Read local skills content
538
+ const localSkills = skillFiles.map(f => ({
539
+ name: f.replace(/\.md$/, ""),
540
+ rawContent: fs.readFileSync(path.join(localSkillsDir, f), "utf8"),
541
+ }))
542
+
543
+ // Fetch published skills for comparison
544
+ const cleanedName = name.startsWith("@lpm.dev/")
545
+ ? name.replace("@lpm.dev/", "")
546
+ : `${owner}.${packageName}`
547
+ const skillsResponse = await request(
548
+ `/skills?name=${encodeURIComponent(cleanedName)}`,
549
+ { method: "GET" },
550
+ ).catch(() => null)
551
+
552
+ if (skillsResponse?.ok) {
553
+ const published = await skillsResponse.json()
554
+ if (published.available && published.skills?.length > 0) {
555
+ const pubSorted = [...published.skills]
556
+ .map(s => ({ name: s.name, rawContent: s.rawContent }))
557
+ .sort((a, b) => a.name.localeCompare(b.name))
558
+
559
+ const isIdentical =
560
+ localSkills.length === pubSorted.length &&
561
+ localSkills.every(
562
+ (local, i) =>
563
+ local.name === pubSorted[i].name &&
564
+ local.rawContent === pubSorted[i].rawContent,
565
+ )
566
+
567
+ if (isIdentical) {
568
+ prePublishWarnings.push(
569
+ `Agent Skills haven't changed since v${published.version} - consider reviewing them for accuracy`,
570
+ )
571
+ }
572
+ }
573
+ }
574
+ }
575
+ }
576
+ } catch {
577
+ // Non-critical — skip skills staleness check
578
+ }
579
+
525
580
  spinner.stop()
526
- displayQualityReport(qualityResult)
581
+ displayQualityReport({ ...qualityResult, warnings: prePublishWarnings })
527
582
 
528
583
  // --dry-run: print summary and exit without uploading
529
584
  if (dryRun) {
@@ -848,11 +903,16 @@ export async function publish(options = {}) {
848
903
  )
849
904
  }
850
905
 
851
- // Show any server warnings (e.g., stale skills)
906
+ // Server may still return warnings for edge cases not caught client-side
852
907
  if (publishResult.warnings?.length > 0) {
853
- console.log("")
854
- for (const warning of publishResult.warnings) {
855
- log.warn(warning)
908
+ const newWarnings = publishResult.warnings.filter(
909
+ w => !prePublishWarnings.includes(w),
910
+ )
911
+ if (newWarnings.length > 0) {
912
+ console.log("")
913
+ for (const warning of newWarnings) {
914
+ log.warn(warning)
915
+ }
856
916
  }
857
917
  }
858
918
  } catch (error) {
@@ -8,6 +8,7 @@ import { log, printHeader } from "../ui.js"
8
8
 
9
9
  /**
10
10
  * Remove existing LPM config lines from .npmrc content.
11
+ * Handles both scoped (@lpm.dev:registry=) and unscoped (registry=...lpm.dev) formats.
11
12
  * @param {string} content
12
13
  * @returns {string}
13
14
  */
@@ -17,6 +18,7 @@ function removeLpmLines(content) {
17
18
  .filter(line => {
18
19
  return (
19
20
  !line.includes("@lpm.dev:registry") &&
21
+ !line.match(/^registry=.*lpm\.dev/) &&
20
22
  !line.includes("lpm.dev/api/registry/:_authToken") &&
21
23
  !line.includes("_authToken=lpm_") &&
22
24
  // biome-ignore lint/suspicious/noTemplateCurlyInString: matching literal npm token placeholder
@@ -28,6 +30,24 @@ function removeLpmLines(content) {
28
30
  .trim()
29
31
  }
30
32
 
33
+ /**
34
+ * Check if .npmrc has a custom default registry that is NOT npmjs.org or lpm.dev.
35
+ * @param {string} content - Existing .npmrc content
36
+ * @returns {string|null} The custom registry line, or null if none found
37
+ */
38
+ function detectCustomRegistry(content) {
39
+ return (
40
+ content
41
+ .split("\n")
42
+ .find(
43
+ line =>
44
+ line.match(/^registry=/) &&
45
+ !line.includes("registry.npmjs.org") &&
46
+ !line.includes("lpm.dev"),
47
+ ) || null
48
+ )
49
+ }
50
+
31
51
  /**
32
52
  * Configure .npmrc for LPM packages.
33
53
  *
@@ -55,6 +75,9 @@ export async function setup(options) {
55
75
  : `${registryUrl}/api/registry`
56
76
  const registryHost = fullRegistryUrl.replace(/^https?:/, "")
57
77
 
78
+ // Determine proxy vs scoped mode
79
+ let useScoped = !!options?.scoped
80
+
58
81
  // ── OIDC mode: exchange CI token for a real short-lived install token ──
59
82
  if (options?.oidc) {
60
83
  let oidcToken
@@ -70,11 +93,29 @@ export async function setup(options) {
70
93
  if (oidcToken) {
71
94
  let npmrcContent = ""
72
95
  if (fs.existsSync(npmrcPath)) {
73
- npmrcContent = removeLpmLines(fs.readFileSync(npmrcPath, "utf8"))
96
+ const existingContent = fs.readFileSync(npmrcPath, "utf8")
97
+
98
+ // Detect custom default registry
99
+ if (!useScoped) {
100
+ const customRegistry = detectCustomRegistry(existingContent)
101
+ if (customRegistry) {
102
+ log.warn(
103
+ `Found existing default registry: ${customRegistry.trim()}`,
104
+ )
105
+ log.warn("Using --scoped mode to avoid overriding it.")
106
+ useScoped = true
107
+ }
108
+ }
109
+
110
+ npmrcContent = removeLpmLines(existingContent)
74
111
  }
75
112
 
113
+ const registryLine = useScoped
114
+ ? `@lpm.dev:registry=${fullRegistryUrl}`
115
+ : `registry=${fullRegistryUrl}`
116
+
76
117
  const lpmConfig = `# LPM Registry (OIDC — expires in 30 minutes)
77
- @lpm.dev:registry=${fullRegistryUrl}
118
+ ${registryLine}
78
119
  ${registryHost}/:_authToken=${oidcToken}`
79
120
 
80
121
  npmrcContent = npmrcContent
@@ -83,13 +124,16 @@ ${registryHost}/:_authToken=${oidcToken}`
83
124
  fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
84
125
 
85
126
  p.note(
86
- `@lpm.dev:registry=${fullRegistryUrl}\nToken: OIDC read-only (30 min)`,
127
+ `${registryLine}\nToken: OIDC read-only (30 min)`,
87
128
  ".npmrc configuration",
88
129
  )
89
130
 
90
131
  console.log("")
91
132
  log.success(".npmrc configured with OIDC read-only token.")
92
133
  log.info("Token expires in 30 minutes. No LPM_TOKEN secret needed.")
134
+ if (!useScoped) {
135
+ log.info("All packages (LPM + npm) will route through lpm.dev.")
136
+ }
93
137
  console.log("")
94
138
 
95
139
  p.outro("Setup complete!")
@@ -104,8 +148,24 @@ ${registryHost}/:_authToken=${oidcToken}`
104
148
  if (fs.existsSync(npmrcPath)) {
105
149
  npmrcContent = fs.readFileSync(npmrcPath, "utf8")
106
150
 
107
- // Check if already configured
108
- if (npmrcContent.includes("@lpm.dev:registry")) {
151
+ // Detect custom default registry
152
+ if (!useScoped) {
153
+ const customRegistry = detectCustomRegistry(npmrcContent)
154
+ if (customRegistry) {
155
+ log.warn(`Found existing default registry: ${customRegistry.trim()}`)
156
+ log.warn("Using --scoped mode to avoid overriding it.")
157
+ log.warn(
158
+ "To use LPM as your default registry, remove the existing registry= line first.",
159
+ )
160
+ useScoped = true
161
+ }
162
+ }
163
+
164
+ // Check if already configured (both scoped and unscoped formats)
165
+ if (
166
+ npmrcContent.includes("@lpm.dev:registry") ||
167
+ npmrcContent.match(/^registry=.*lpm\.dev/m)
168
+ ) {
109
169
  const overwrite = await p.confirm({
110
170
  message: ".npmrc already has LPM configuration. Overwrite?",
111
171
  initialValue: false,
@@ -120,10 +180,14 @@ ${registryHost}/:_authToken=${oidcToken}`
120
180
  }
121
181
  }
122
182
 
183
+ const registryLine = useScoped
184
+ ? `@lpm.dev:registry=${fullRegistryUrl}`
185
+ : `registry=${fullRegistryUrl}`
186
+
123
187
  // Add LPM registry config with placeholder
124
188
  const lpmConfig = `
125
189
  # LPM Registry
126
- @lpm.dev:registry=${fullRegistryUrl}
190
+ ${registryLine}
127
191
  ${registryHost}/:_authToken=\${LPM_TOKEN}
128
192
  `.trim()
129
193
 
@@ -131,12 +195,18 @@ ${registryHost}/:_authToken=\${LPM_TOKEN}
131
195
  fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
132
196
 
133
197
  p.note(
134
- `@lpm.dev:registry=${fullRegistryUrl}\n${registryHost}/:_authToken=\${LPM_TOKEN}`,
198
+ `${registryLine}\n${registryHost}/:_authToken=\${LPM_TOKEN}`,
135
199
  ".npmrc configuration",
136
200
  )
137
201
 
138
202
  console.log("")
139
- log.success(".npmrc configured for LPM packages.")
203
+ log.success(".npmrc configured for LPM registry.")
204
+ if (useScoped) {
205
+ log.info("Only @lpm.dev packages will route through lpm.dev.")
206
+ } else {
207
+ log.info("All packages (LPM + npm) will route through lpm.dev.")
208
+ log.info("Note: First install may update resolved URLs in your lockfile.")
209
+ }
140
210
  log.info("For local development: Run `lpm npmrc` to generate a token.")
141
211
  log.info("For CI/CD: Set the LPM_TOKEN environment variable.")
142
212
  log.info("For CI/CD with OIDC: Run `lpm setup --oidc` (no secrets needed).")
@@ -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
  }
@@ -38,9 +38,9 @@ function progressBar(value, max, width = 18) {
38
38
  /**
39
39
  * Display quality report in the terminal.
40
40
  *
41
- * @param {{ score: number, checks: Array, meta: object }} result
41
+ * @param {{ score: number, checks: Array, meta: object, warnings?: string[] }} result
42
42
  */
43
- export function displayQualityReport({ score, checks, meta }) {
43
+ export function displayQualityReport({ score, checks, meta, warnings }) {
44
44
  const tierColor = TIER_COLORS[meta.tier] || chalk.white
45
45
  const tierLabel = TIER_LABELS[meta.tier] || meta.tier
46
46
 
@@ -76,6 +76,14 @@ export function displayQualityReport({ score, checks, meta }) {
76
76
  }
77
77
  }
78
78
 
79
+ // Show warnings (e.g., stale skills)
80
+ if (warnings?.length > 0) {
81
+ if (failed.length === 0) console.log("")
82
+ for (const warning of warnings) {
83
+ console.log(` ${chalk.yellow("⚠")} ${chalk.yellow(warning)}`)
84
+ }
85
+ }
86
+
79
87
  if (failed.length > 0 && score < 90) {
80
88
  console.log(
81
89
  chalk.dim(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpm-registry/cli",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for Licensed Package Manager",
5
5
  "author": "LPM <hello@lpm.dev> (https://lpm.dev)",
6
6
  "repository": {