@lpm-registry/cli 0.2.6 → 0.4.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 +31 -0
- package/README.md +11 -3
- package/bin/lpm.js +42 -1
- package/lib/commands/install.js +7 -4
- package/lib/commands/npmrc.js +41 -4
- package/lib/commands/publish.js +65 -5
- package/lib/commands/setup.js +78 -8
- package/lib/commands/skills.js +15 -1
- package/lib/editor-skills.js +321 -0
- package/lib/quality/display.js +10 -2
- package/package.json +3 -1
- package/scripts/install-binary.js +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-03-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Rust binary drop-in replacement** — `lpm` now automatically downloads and runs the native Rust binary on postinstall. 9x faster warm installs than pnpm, 24x faster than npm. Falls back to JS CLI if binary is unavailable.
|
|
10
|
+
- **GitHub Releases distribution** — Platform-specific binaries (macOS arm64/x64, Linux arm64/x64, Windows x64) hosted on GitHub Releases, downloaded automatically during `npm install -g @lpm-registry/cli`.
|
|
11
|
+
- **Auto-integrate Agent Skills with AI editors** — After `lpm skills install`, skills are automatically wired into detected AI editor configs (Claude Code `CLAUDE.md`, Cursor `.cursorrules`, etc.). Use `--no-editor-setup` to skip.
|
|
12
|
+
- **`--no-editor-setup` flag** for `lpm install` and `lpm skills install` — Skip auto-configuring AI editor integration for skills.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Binary download URL updated from `lpm.dev/releases` to GitHub Releases (`github.com/lpm-dev/rust-client/releases`)
|
|
17
|
+
- Skills install now passes `editorSetup` option through from `lpm install` to `lpm skills install`
|
|
18
|
+
- Fixed unused variable lint warning in `bin/lpm.js`
|
|
19
|
+
|
|
20
|
+
## [0.3.0] - 2026-03-22
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **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.
|
|
25
|
+
- **`--scoped` flag** — Use `lpm npmrc --scoped` or `lpm setup --scoped` to only route `@lpm.dev` packages through LPM (old behavior).
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `lpm npmrc` default output changed from `@lpm.dev:registry=...` (scoped) to `registry=...` (proxy mode)
|
|
32
|
+
- `lpm setup` and `lpm setup --oidc` default output changed to proxy mode
|
|
33
|
+
- Updated CLI output messaging to indicate proxy vs scoped mode
|
|
34
|
+
- Updated README with new default behavior and `--scoped` flag
|
|
35
|
+
|
|
5
36
|
## [0.2.6] - 2026-03-21
|
|
6
37
|
|
|
7
38
|
### Added
|
package/README.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# LPM CLI
|
|
2
2
|
|
|
3
|
-
The command-line interface for the Licensed Package Manager (LPM).
|
|
3
|
+
The command-line interface for the Licensed Package Manager (LPM). Ships with a native Rust binary for fast installs — 9x faster than pnpm, 24x faster than npm.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
+
# npm (recommended — auto-downloads native binary)
|
|
8
9
|
npm install -g @lpm-registry/cli
|
|
10
|
+
|
|
11
|
+
# curl (standalone, no Node required)
|
|
12
|
+
curl -fsSL https://raw.githubusercontent.com/lpm-dev/rust-client/main/install.sh | sh
|
|
9
13
|
```
|
|
10
14
|
|
|
11
15
|
## Command Shortcuts
|
|
@@ -69,10 +73,13 @@ lpm whoami
|
|
|
69
73
|
|
|
70
74
|
Generate a 30-day read-only token for your project. Makes `npm install` work with LPM packages.
|
|
71
75
|
|
|
76
|
+
By default, LPM is configured as your default registry — all packages (LPM + npm) are fetched through LPM for faster, edge-cached installs.
|
|
77
|
+
|
|
72
78
|
```bash
|
|
73
|
-
lpm npmrc # 30-day token (default)
|
|
79
|
+
lpm npmrc # 30-day token, proxy mode (default)
|
|
74
80
|
lpm npmrc --days 7 # 7-day token
|
|
75
81
|
lpm npmrc --days 90 # 90-day token
|
|
82
|
+
lpm npmrc --scoped # Only route @lpm.dev packages through LPM
|
|
76
83
|
```
|
|
77
84
|
|
|
78
85
|
Automatically adds `.npmrc` to `.gitignore` to prevent token leaks.
|
|
@@ -82,8 +89,9 @@ Automatically adds `.npmrc` to `.gitignore` to prevent token leaks.
|
|
|
82
89
|
Configure `.npmrc` for CI/CD environments.
|
|
83
90
|
|
|
84
91
|
```bash
|
|
85
|
-
lpm setup # Writes ${LPM_TOKEN} placeholder
|
|
92
|
+
lpm setup # Writes ${LPM_TOKEN} placeholder (proxy mode)
|
|
86
93
|
lpm setup --oidc # OIDC — no secrets needed (GitHub Actions, GitLab CI)
|
|
94
|
+
lpm setup --scoped # Only route @lpm.dev packages through LPM
|
|
87
95
|
```
|
|
88
96
|
|
|
89
97
|
For deployment platforms (Vercel, Netlify), set the `LPM_TOKEN` environment variable.
|
package/bin/lpm.js
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { execFileSync } from "node:child_process"
|
|
3
4
|
import fs from "node:fs"
|
|
4
5
|
import path from "node:path"
|
|
5
6
|
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
// ─── Try Rust binary first ──────────────────────────────────────────
|
|
9
|
+
const __dirname_early = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const binaryName = process.platform === "win32" ? "lpm-bin.exe" : "lpm-bin"
|
|
11
|
+
const binaryPath = path.join(__dirname_early, binaryName)
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(binaryPath)) {
|
|
14
|
+
try {
|
|
15
|
+
// Delegate to Rust binary with all args
|
|
16
|
+
execFileSync(binaryPath, process.argv.slice(2), {
|
|
17
|
+
stdio: "inherit",
|
|
18
|
+
env: process.env,
|
|
19
|
+
})
|
|
20
|
+
process.exit(0)
|
|
21
|
+
} catch (err) {
|
|
22
|
+
// execFileSync throws on non-zero exit — propagate the exit code
|
|
23
|
+
if (err.status != null) {
|
|
24
|
+
process.exit(err.status)
|
|
25
|
+
}
|
|
26
|
+
// Binary failed to execute — fall through to JS CLI
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── JS CLI fallback ────────────────────────────────────────────────
|
|
6
31
|
import { Command } from "commander"
|
|
7
32
|
import updateNotifier from "update-notifier"
|
|
8
33
|
import { add } from "../lib/commands/add.js"
|
|
@@ -94,6 +119,10 @@ program
|
|
|
94
119
|
.description("Install packages with automatic registry authentication")
|
|
95
120
|
.option("--json", "Machine-readable JSON output")
|
|
96
121
|
.option("--no-skills", "Skip fetching Agent Skills after install")
|
|
122
|
+
.option(
|
|
123
|
+
"--no-editor-setup",
|
|
124
|
+
"Skip auto-configuring AI editor integration for skills",
|
|
125
|
+
)
|
|
97
126
|
.option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)")
|
|
98
127
|
.action(install)
|
|
99
128
|
|
|
@@ -210,18 +239,26 @@ program
|
|
|
210
239
|
|
|
211
240
|
program
|
|
212
241
|
.command("setup")
|
|
213
|
-
.description("Configure .npmrc for LPM
|
|
242
|
+
.description("Configure .npmrc for LPM registry")
|
|
214
243
|
.option("-r, --registry <url>", "Custom registry URL")
|
|
215
244
|
.option(
|
|
216
245
|
"--oidc",
|
|
217
246
|
"Exchange CI OIDC token for install access (no secrets needed)",
|
|
218
247
|
)
|
|
248
|
+
.option(
|
|
249
|
+
"--scoped",
|
|
250
|
+
"Only route @lpm.dev packages through LPM (don't proxy npm)",
|
|
251
|
+
)
|
|
219
252
|
.action(setup)
|
|
220
253
|
|
|
221
254
|
program
|
|
222
255
|
.command("npmrc")
|
|
223
256
|
.description("Generate a read-only .npmrc token for local development")
|
|
224
257
|
.option("-d, --days <number>", "Token expiry in days (default: 30)")
|
|
258
|
+
.option(
|
|
259
|
+
"--scoped",
|
|
260
|
+
"Only route @lpm.dev packages through LPM (don't proxy npm)",
|
|
261
|
+
)
|
|
225
262
|
.action(npmrc)
|
|
226
263
|
|
|
227
264
|
program
|
|
@@ -323,6 +360,10 @@ skills
|
|
|
323
360
|
"Fetch and install skills from the registry (all deps or specific package)",
|
|
324
361
|
)
|
|
325
362
|
.option("--json", "Output in JSON format")
|
|
363
|
+
.option(
|
|
364
|
+
"--no-editor-setup",
|
|
365
|
+
"Skip auto-configuring AI editor integration for skills",
|
|
366
|
+
)
|
|
326
367
|
.action(skillsInstall)
|
|
327
368
|
|
|
328
369
|
skills
|
package/lib/commands/install.js
CHANGED
|
@@ -180,6 +180,7 @@ export async function install(packages, options) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
const fetchSkills = options?.skills !== false
|
|
183
|
+
const editorSetup = options?.editorSetup !== false
|
|
183
184
|
|
|
184
185
|
if (isJson) {
|
|
185
186
|
let stdout = ""
|
|
@@ -203,12 +204,13 @@ export async function install(packages, options) {
|
|
|
203
204
|
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
|
|
204
205
|
if (code !== 0) process.exit(code)
|
|
205
206
|
if (fetchSkills) {
|
|
207
|
+
const skillOpts = { json: true, editorSetup }
|
|
206
208
|
if (explicitPackages) {
|
|
207
209
|
for (const pkg of packages.filter(isLpmPackage)) {
|
|
208
|
-
await skillsInstall(pkg,
|
|
210
|
+
await skillsInstall(pkg, skillOpts)
|
|
209
211
|
}
|
|
210
212
|
} else {
|
|
211
|
-
await skillsInstall(null,
|
|
213
|
+
await skillsInstall(null, skillOpts)
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
})
|
|
@@ -235,12 +237,13 @@ export async function install(packages, options) {
|
|
|
235
237
|
log.success("Packages installed successfully.")
|
|
236
238
|
if (fetchSkills) {
|
|
237
239
|
console.log("")
|
|
240
|
+
const skillOpts = { editorSetup }
|
|
238
241
|
if (explicitPackages) {
|
|
239
242
|
for (const pkg of packages.filter(isLpmPackage)) {
|
|
240
|
-
await skillsInstall(pkg)
|
|
243
|
+
await skillsInstall(pkg, skillOpts)
|
|
241
244
|
}
|
|
242
245
|
} else {
|
|
243
|
-
await skillsInstall(null)
|
|
246
|
+
await skillsInstall(null, skillOpts)
|
|
244
247
|
}
|
|
245
248
|
}
|
|
246
249
|
}
|
package/lib/commands/npmrc.js
CHANGED
|
@@ -82,15 +82,41 @@ export async function npmrc(options) {
|
|
|
82
82
|
: `${registryUrl}/api/registry`
|
|
83
83
|
const registryHost = fullRegistryUrl.replace(/^https?:/, "")
|
|
84
84
|
|
|
85
|
-
//
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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",
|
package/lib/commands/publish.js
CHANGED
|
@@ -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
|
-
//
|
|
906
|
+
// Server may still return warnings for edge cases not caught client-side
|
|
852
907
|
if (publishResult.warnings?.length > 0) {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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) {
|
package/lib/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
108
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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).")
|
package/lib/commands/skills.js
CHANGED
|
@@ -12,6 +12,7 @@ import fs from "node:fs"
|
|
|
12
12
|
import path from "node:path"
|
|
13
13
|
import chalk from "chalk"
|
|
14
14
|
import { get } from "../api.js"
|
|
15
|
+
import { autoIntegrateSkills } from "../editor-skills.js"
|
|
15
16
|
import { createSpinner, log, printHeader } from "../ui.js"
|
|
16
17
|
|
|
17
18
|
// ============================================================================
|
|
@@ -616,12 +617,19 @@ export async function skillsInstall(packageInput, options = {}) {
|
|
|
616
617
|
if (spinner) spinner.stop()
|
|
617
618
|
|
|
618
619
|
if (isJson) {
|
|
620
|
+
const hasInstalled = results.some(r => r.status === "installed")
|
|
621
|
+
let editorSetup = null
|
|
622
|
+
if (hasInstalled && options.editorSetup !== false) {
|
|
623
|
+
const result = autoIntegrateSkills({ json: true })
|
|
624
|
+
editorSetup = result
|
|
625
|
+
}
|
|
619
626
|
console.log(
|
|
620
627
|
JSON.stringify(
|
|
621
628
|
{
|
|
622
629
|
installed: results.filter(r => r.status === "installed").length,
|
|
623
630
|
packages: results,
|
|
624
631
|
errors,
|
|
632
|
+
...(editorSetup ? { editorSetup } : {}),
|
|
625
633
|
},
|
|
626
634
|
null,
|
|
627
635
|
2,
|
|
@@ -665,7 +673,13 @@ export async function skillsInstall(packageInput, options = {}) {
|
|
|
665
673
|
if (installed.length > 0) {
|
|
666
674
|
console.log("")
|
|
667
675
|
console.log(chalk.dim(` Skills saved to ${SKILLS_DIR}/`))
|
|
668
|
-
|
|
676
|
+
|
|
677
|
+
// Auto-integrate with detected AI editors (unless --no-editor-setup)
|
|
678
|
+
if (options.editorSetup !== false) {
|
|
679
|
+
autoIntegrateSkills({ json: false })
|
|
680
|
+
} else {
|
|
681
|
+
printWiringInstructions()
|
|
682
|
+
}
|
|
669
683
|
}
|
|
670
684
|
|
|
671
685
|
console.log("")
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-integrate Agent Skills with AI editor config files.
|
|
3
|
+
*
|
|
4
|
+
* After `lpm skills install` writes skill files to `.lpm/skills/`,
|
|
5
|
+
* this module detects which AI editors are configured in the project
|
|
6
|
+
* and wires the skills in automatically (append reference, symlink, etc.).
|
|
7
|
+
*
|
|
8
|
+
* @module cli/lib/editor-skills
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs"
|
|
12
|
+
import path from "node:path"
|
|
13
|
+
import chalk from "chalk"
|
|
14
|
+
import { log } from "./ui.js"
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Constants
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
const SKILLS_DIR = ".lpm/skills"
|
|
21
|
+
|
|
22
|
+
const SKILLS_REFERENCE_LINE =
|
|
23
|
+
"See .lpm/skills/ for package-specific Agent Skills and guidelines."
|
|
24
|
+
|
|
25
|
+
/** Marker comment so we can detect our own additions and avoid duplicates. */
|
|
26
|
+
const LPM_MARKER = "<!-- lpm:skills -->"
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The block we append to markdown/text config files.
|
|
30
|
+
* Contains both a human-readable reference and a machine-detectable marker.
|
|
31
|
+
*/
|
|
32
|
+
function makeReferenceBlock() {
|
|
33
|
+
return `\n\n${LPM_MARKER}\n## LPM Agent Skills\n\n${SKILLS_REFERENCE_LINE}\n`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// AI Editor definitions (for skills wiring — separate from MCP editors)
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} AIEditor
|
|
42
|
+
* @property {string} id - Unique identifier
|
|
43
|
+
* @property {string} name - Display name
|
|
44
|
+
* @property {string} configPath - Relative path to the config file from project root
|
|
45
|
+
* @property {"append" | "symlink"} action - How to integrate skills
|
|
46
|
+
* @property {(projectRoot: string) => boolean} detect - Whether this editor is configured in the project
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** @type {AIEditor[]} */
|
|
50
|
+
const AI_EDITORS = [
|
|
51
|
+
{
|
|
52
|
+
id: "claude-code",
|
|
53
|
+
name: "Claude Code",
|
|
54
|
+
configPath: "CLAUDE.md",
|
|
55
|
+
action: "append",
|
|
56
|
+
detect: projectRoot => fs.existsSync(path.join(projectRoot, "CLAUDE.md")),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "cursor",
|
|
60
|
+
name: "Cursor",
|
|
61
|
+
configPath: ".cursor/rules/",
|
|
62
|
+
action: "symlink",
|
|
63
|
+
detect: projectRoot =>
|
|
64
|
+
fs.existsSync(path.join(projectRoot, ".cursor", "rules")),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "cursor-legacy",
|
|
68
|
+
name: "Cursor (legacy)",
|
|
69
|
+
configPath: ".cursorrules",
|
|
70
|
+
action: "append",
|
|
71
|
+
detect: projectRoot =>
|
|
72
|
+
fs.existsSync(path.join(projectRoot, ".cursorrules")),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "windsurf",
|
|
76
|
+
name: "Windsurf",
|
|
77
|
+
configPath: ".windsurfrules",
|
|
78
|
+
action: "append",
|
|
79
|
+
detect: projectRoot =>
|
|
80
|
+
fs.existsSync(path.join(projectRoot, ".windsurfrules")),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "github-copilot",
|
|
84
|
+
name: "GitHub Copilot",
|
|
85
|
+
configPath: ".github/copilot-instructions.md",
|
|
86
|
+
action: "append",
|
|
87
|
+
detect: projectRoot =>
|
|
88
|
+
fs.existsSync(
|
|
89
|
+
path.join(projectRoot, ".github", "copilot-instructions.md"),
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "augment",
|
|
94
|
+
name: "Augment",
|
|
95
|
+
configPath: ".augment/instructions.md",
|
|
96
|
+
action: "append",
|
|
97
|
+
detect: projectRoot =>
|
|
98
|
+
fs.existsSync(path.join(projectRoot, ".augment", "instructions.md")),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "cline",
|
|
102
|
+
name: "Cline",
|
|
103
|
+
configPath: ".clinerules",
|
|
104
|
+
action: "append",
|
|
105
|
+
detect: projectRoot => fs.existsSync(path.join(projectRoot, ".clinerules")),
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// Core logic
|
|
111
|
+
// ============================================================================
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detect which AI editors are configured in the project.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} projectRoot
|
|
117
|
+
* @returns {AIEditor[]}
|
|
118
|
+
*/
|
|
119
|
+
export function detectAIEditors(projectRoot) {
|
|
120
|
+
return AI_EDITORS.filter(editor => editor.detect(projectRoot))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a file already references .lpm/skills/.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} filePath
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
export function hasSkillsReference(filePath) {
|
|
130
|
+
if (!fs.existsSync(filePath)) return false
|
|
131
|
+
const content = fs.readFileSync(filePath, "utf-8")
|
|
132
|
+
return content.includes(".lpm/skills") || content.includes(LPM_MARKER)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Append a skills reference block to a markdown/text config file.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} filePath - Absolute path to the config file
|
|
139
|
+
* @returns {boolean} Whether the reference was added (false if already present)
|
|
140
|
+
*/
|
|
141
|
+
export function appendSkillsReference(filePath) {
|
|
142
|
+
if (hasSkillsReference(filePath)) return false
|
|
143
|
+
fs.appendFileSync(filePath, makeReferenceBlock())
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create a new CLAUDE.md with skills reference (fallback when no editor detected).
|
|
149
|
+
*
|
|
150
|
+
* @param {string} projectRoot
|
|
151
|
+
* @returns {boolean} Whether the file was created
|
|
152
|
+
*/
|
|
153
|
+
export function createClaudeMdWithReference(projectRoot) {
|
|
154
|
+
const claudeMdPath = path.join(projectRoot, "CLAUDE.md")
|
|
155
|
+
if (fs.existsSync(claudeMdPath)) return false
|
|
156
|
+
|
|
157
|
+
const content = `# Project Guidelines\n\n${LPM_MARKER}\n## LPM Agent Skills\n\n${SKILLS_REFERENCE_LINE}\n`
|
|
158
|
+
fs.writeFileSync(claudeMdPath, content, "utf-8")
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Collect all .md skill files from .lpm/skills/ (recursively through package subdirs).
|
|
164
|
+
*
|
|
165
|
+
* @param {string} projectRoot
|
|
166
|
+
* @returns {{ packageName: string, fileName: string, filePath: string }[]}
|
|
167
|
+
*/
|
|
168
|
+
export function collectSkillFiles(projectRoot) {
|
|
169
|
+
const skillsDir = path.join(projectRoot, SKILLS_DIR)
|
|
170
|
+
if (!fs.existsSync(skillsDir)) return []
|
|
171
|
+
|
|
172
|
+
const results = []
|
|
173
|
+
const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
174
|
+
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (entry.isDirectory()) {
|
|
177
|
+
// Package subdirectory (e.g., "neo.colors")
|
|
178
|
+
const pkgDir = path.join(skillsDir, entry.name)
|
|
179
|
+
const files = fs.readdirSync(pkgDir).filter(f => f.endsWith(".md"))
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
results.push({
|
|
182
|
+
packageName: entry.name,
|
|
183
|
+
fileName: file,
|
|
184
|
+
filePath: path.join(pkgDir, file),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
} else if (entry.name.endsWith(".md")) {
|
|
188
|
+
// Top-level skill file
|
|
189
|
+
results.push({
|
|
190
|
+
packageName: null,
|
|
191
|
+
fileName: entry.name,
|
|
192
|
+
filePath: path.join(skillsDir, entry.name),
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return results
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create symlinks for Cursor's .cursor/rules/ directory.
|
|
202
|
+
* Uses relative symlinks so they work across machines.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} projectRoot
|
|
205
|
+
* @returns {{ created: string[], skipped: string[] }}
|
|
206
|
+
*/
|
|
207
|
+
export function symlinkForCursor(projectRoot) {
|
|
208
|
+
const rulesDir = path.join(projectRoot, ".cursor", "rules")
|
|
209
|
+
const skillFiles = collectSkillFiles(projectRoot)
|
|
210
|
+
|
|
211
|
+
const created = []
|
|
212
|
+
const skipped = []
|
|
213
|
+
|
|
214
|
+
for (const { packageName, fileName, filePath } of skillFiles) {
|
|
215
|
+
// Build symlink name: "neo.colors-getting-started.md"
|
|
216
|
+
const linkName = packageName
|
|
217
|
+
? `${packageName}-${fileName}`
|
|
218
|
+
: `lpm-${fileName}`
|
|
219
|
+
|
|
220
|
+
const linkPath = path.join(rulesDir, linkName)
|
|
221
|
+
|
|
222
|
+
if (fs.existsSync(linkPath)) {
|
|
223
|
+
skipped.push(linkName)
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Compute relative path from .cursor/rules/ to the skill file
|
|
228
|
+
const relativePath = path.relative(rulesDir, filePath)
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
fs.symlinkSync(relativePath, linkPath)
|
|
232
|
+
created.push(linkName)
|
|
233
|
+
} catch {
|
|
234
|
+
// Fallback: copy if symlink fails (e.g., Windows without developer mode)
|
|
235
|
+
try {
|
|
236
|
+
fs.copyFileSync(filePath, linkPath)
|
|
237
|
+
created.push(linkName)
|
|
238
|
+
} catch {
|
|
239
|
+
skipped.push(linkName)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { created, skipped }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ============================================================================
|
|
248
|
+
// Main entry point
|
|
249
|
+
// ============================================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Auto-integrate installed skills with detected AI editors.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} options
|
|
255
|
+
* @param {string} [options.projectRoot] - Project root (defaults to cwd)
|
|
256
|
+
* @param {boolean} [options.json] - Output JSON instead of human-readable
|
|
257
|
+
* @param {string[]} [options.installedPackages] - Names of packages that had skills installed
|
|
258
|
+
* @returns {{ configured: { id: string, name: string, configPath: string }[], fallback: boolean }}
|
|
259
|
+
*/
|
|
260
|
+
export function autoIntegrateSkills(options = {}) {
|
|
261
|
+
const projectRoot = options.projectRoot || process.cwd()
|
|
262
|
+
const isJson = options.json
|
|
263
|
+
const configured = []
|
|
264
|
+
let fallback = false
|
|
265
|
+
|
|
266
|
+
const detectedEditors = detectAIEditors(projectRoot)
|
|
267
|
+
|
|
268
|
+
if (detectedEditors.length === 0) {
|
|
269
|
+
// No editor detected — print a helpful hint, don't auto-create files
|
|
270
|
+
fallback = true
|
|
271
|
+
if (!isJson) {
|
|
272
|
+
console.log("")
|
|
273
|
+
log.info(
|
|
274
|
+
"To use these skills with your AI coding tool, point your agent to the .lpm/skills/ folder.",
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
for (const editor of detectedEditors) {
|
|
279
|
+
if (editor.action === "symlink" && editor.id === "cursor") {
|
|
280
|
+
const { created } = symlinkForCursor(projectRoot)
|
|
281
|
+
if (created.length > 0) {
|
|
282
|
+
configured.push({
|
|
283
|
+
id: editor.id,
|
|
284
|
+
name: editor.name,
|
|
285
|
+
configPath: editor.configPath,
|
|
286
|
+
action: "symlinked",
|
|
287
|
+
files: created,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
} else if (editor.action === "append") {
|
|
291
|
+
const filePath = path.join(projectRoot, editor.configPath)
|
|
292
|
+
const added = appendSkillsReference(filePath)
|
|
293
|
+
if (added) {
|
|
294
|
+
configured.push({
|
|
295
|
+
id: editor.id,
|
|
296
|
+
name: editor.name,
|
|
297
|
+
configPath: editor.configPath,
|
|
298
|
+
action: "appended",
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Output
|
|
306
|
+
if (!isJson && configured.length > 0) {
|
|
307
|
+
console.log("")
|
|
308
|
+
console.log(chalk.dim(" Auto-configured for:"))
|
|
309
|
+
for (const c of configured) {
|
|
310
|
+
if (c.action === "symlinked") {
|
|
311
|
+
log.success(
|
|
312
|
+
`${c.name} (${c.configPath}) — ${c.files.length} file${c.files.length !== 1 ? "s" : ""} linked`,
|
|
313
|
+
)
|
|
314
|
+
} else {
|
|
315
|
+
log.success(`${c.name} (${c.configPath})`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { configured, fallback }
|
|
321
|
+
}
|
package/lib/quality/display.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CLI for Licensed Package Manager",
|
|
5
5
|
"author": "LPM <hello@lpm.dev> (https://lpm.dev)",
|
|
6
6
|
"repository": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
"bin",
|
|
31
31
|
"lib",
|
|
32
|
+
"scripts",
|
|
32
33
|
"!lib/__tests__",
|
|
33
34
|
"index.js",
|
|
34
35
|
"index.d.ts",
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
],
|
|
39
40
|
"scripts": {
|
|
40
41
|
"prepare": "husky",
|
|
42
|
+
"postinstall": "node scripts/install-binary.js",
|
|
41
43
|
"test": "vitest run",
|
|
42
44
|
"test:watch": "vitest",
|
|
43
45
|
"lint": "biome check .",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Download the platform-specific LPM Rust binary.
|
|
5
|
+
*
|
|
6
|
+
* Runs as a postinstall hook. Downloads the pre-built binary from
|
|
7
|
+
* lpm.dev/releases and places it in the package's bin/ directory.
|
|
8
|
+
*
|
|
9
|
+
* Falls back gracefully to the JS CLI if the binary isn't available
|
|
10
|
+
* for the current platform.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from "node:child_process"
|
|
14
|
+
import fs from "node:fs"
|
|
15
|
+
import http from "node:http"
|
|
16
|
+
import https from "node:https"
|
|
17
|
+
import path from "node:path"
|
|
18
|
+
import { fileURLToPath } from "node:url"
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
21
|
+
const pkgDir = path.join(__dirname, "..")
|
|
22
|
+
const binDir = path.join(pkgDir, "bin")
|
|
23
|
+
const binaryPath = path.join(
|
|
24
|
+
binDir,
|
|
25
|
+
process.platform === "win32" ? "lpm-bin.exe" : "lpm-bin",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// Platform → binary filename mapping
|
|
29
|
+
const PLATFORM_MAP = {
|
|
30
|
+
"darwin-arm64": "lpm-darwin-arm64",
|
|
31
|
+
"darwin-x64": "lpm-darwin-x64",
|
|
32
|
+
"linux-x64": "lpm-linux-x64",
|
|
33
|
+
"linux-arm64": "lpm-linux-arm64",
|
|
34
|
+
"win32-x64": "lpm-win32-x64.exe",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const GITHUB_RELEASES =
|
|
38
|
+
"https://github.com/lpm-dev/rust-client/releases/download"
|
|
39
|
+
const pkg = JSON.parse(
|
|
40
|
+
fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const platform = `${process.platform}-${process.arch}`
|
|
45
|
+
const binaryName = PLATFORM_MAP[platform]
|
|
46
|
+
|
|
47
|
+
if (!binaryName) {
|
|
48
|
+
console.log(`[lpm] No pre-built binary for ${platform}. Using JS CLI.`)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Skip if binary already exists and is executable
|
|
53
|
+
if (fs.existsSync(binaryPath)) {
|
|
54
|
+
try {
|
|
55
|
+
execSync(`"${binaryPath}" --version`, { stdio: "ignore", timeout: 5000 })
|
|
56
|
+
return
|
|
57
|
+
} catch {
|
|
58
|
+
// Binary exists but broken, re-download
|
|
59
|
+
fs.unlinkSync(binaryPath)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const url = `${GITHUB_RELEASES}/v${pkg.version}/${binaryName}`
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
console.log(`[lpm] Downloading native binary for ${platform}...`)
|
|
67
|
+
await download(url, binaryPath)
|
|
68
|
+
fs.chmodSync(binaryPath, 0o755)
|
|
69
|
+
console.log("[lpm] Native binary installed.")
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.log(`[lpm] Binary not available: ${err.message}. Using JS CLI.`)
|
|
72
|
+
// Clean up partial download
|
|
73
|
+
if (fs.existsSync(binaryPath)) fs.unlinkSync(binaryPath)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function download(url, dest, redirects = 0) {
|
|
78
|
+
if (redirects > 5) return Promise.reject(new Error("Too many redirects"))
|
|
79
|
+
|
|
80
|
+
const client = url.startsWith("https") ? https : http
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
client
|
|
84
|
+
.get(url, res => {
|
|
85
|
+
if (
|
|
86
|
+
res.statusCode >= 300 &&
|
|
87
|
+
res.statusCode < 400 &&
|
|
88
|
+
res.headers.location
|
|
89
|
+
) {
|
|
90
|
+
resolve(download(res.headers.location, dest, redirects + 1))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (res.statusCode !== 200) {
|
|
95
|
+
reject(new Error(`HTTP ${res.statusCode}`))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const out = fs.createWriteStream(dest)
|
|
100
|
+
res.pipe(out)
|
|
101
|
+
out.on("finish", () => {
|
|
102
|
+
out.close()
|
|
103
|
+
resolve()
|
|
104
|
+
})
|
|
105
|
+
out.on("error", reject)
|
|
106
|
+
})
|
|
107
|
+
.on("error", reject)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().catch(() => {
|
|
112
|
+
// Silently fail — JS CLI fallback
|
|
113
|
+
})
|