@lpm-registry/mcp-server 0.1.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/README.md +687 -0
- package/bin/mcp-server.js +11 -0
- package/lib/api.js +145 -0
- package/lib/auth.js +42 -0
- package/lib/cache.js +73 -0
- package/lib/cli.js +99 -0
- package/lib/constants.js +35 -0
- package/lib/format.js +67 -0
- package/lib/resolve-version.js +49 -0
- package/lib/server.js +357 -0
- package/lib/tools/add.js +79 -0
- package/lib/tools/api-docs.js +78 -0
- package/lib/tools/browse-source.js +111 -0
- package/lib/tools/get-install-command.js +73 -0
- package/lib/tools/install.js +51 -0
- package/lib/tools/llm-context.js +78 -0
- package/lib/tools/package-context.js +168 -0
- package/lib/tools/package-info.js +156 -0
- package/lib/tools/package-skills.js +100 -0
- package/lib/tools/packages-by-owner.js +66 -0
- package/lib/tools/pool-stats.js +38 -0
- package/lib/tools/quality-report.js +50 -0
- package/lib/tools/search-owners.js +58 -0
- package/lib/tools/search.js +232 -0
- package/lib/tools/user-info.js +38 -0
- package/package.json +52 -0
package/lib/api.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// CRITICAL: Never use console.log() — it corrupts the MCP stdio transport
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MAX_RETRIES,
|
|
5
|
+
REQUEST_TIMEOUT_MS,
|
|
6
|
+
RETRY_BACKOFF_MULTIPLIER,
|
|
7
|
+
RETRY_BASE_DELAY_MS,
|
|
8
|
+
RETRY_MAX_DELAY_MS,
|
|
9
|
+
RETRYABLE_STATUS_CODES,
|
|
10
|
+
} from "./constants.js"
|
|
11
|
+
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getBackoffDelay(attempt) {
|
|
17
|
+
const delay = RETRY_BASE_DELAY_MS * RETRY_BACKOFF_MULTIPLIER ** attempt
|
|
18
|
+
const jitter = delay * 0.1 * (Math.random() * 2 - 1)
|
|
19
|
+
return Math.min(delay + jitter, RETRY_MAX_DELAY_MS)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseRetryAfter(retryAfter) {
|
|
23
|
+
if (!retryAfter) return RETRY_BASE_DELAY_MS
|
|
24
|
+
|
|
25
|
+
const seconds = parseInt(retryAfter, 10)
|
|
26
|
+
if (!Number.isNaN(seconds)) return seconds * 1000
|
|
27
|
+
|
|
28
|
+
const date = new Date(retryAfter)
|
|
29
|
+
if (!Number.isNaN(date.getTime())) {
|
|
30
|
+
return Math.max(0, date.getTime() - Date.now())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return RETRY_BASE_DELAY_MS
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Make a GET request with retry, timeout, and rate limit handling.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} url - Full URL to fetch
|
|
40
|
+
* @param {string | null} token - Bearer token (null to skip auth header)
|
|
41
|
+
* @returns {Promise<{ok: boolean, status: number, data: *}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function apiGet(url, token) {
|
|
44
|
+
const headers = {}
|
|
45
|
+
if (token) {
|
|
46
|
+
headers.Authorization = `Bearer ${token}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let lastError = null
|
|
50
|
+
|
|
51
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
52
|
+
const controller = new AbortController()
|
|
53
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(url, {
|
|
57
|
+
method: "GET",
|
|
58
|
+
headers,
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
clearTimeout(timeoutId)
|
|
63
|
+
|
|
64
|
+
// No retry on auth errors
|
|
65
|
+
if (response.status === 401 || response.status === 403) {
|
|
66
|
+
const data = await response.json().catch(() => ({}))
|
|
67
|
+
return { ok: false, status: response.status, data }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Rate limiting
|
|
71
|
+
if (response.status === 429) {
|
|
72
|
+
const retryAfter = response.headers.get("Retry-After")
|
|
73
|
+
const delayMs = parseRetryAfter(retryAfter)
|
|
74
|
+
|
|
75
|
+
if (attempt < MAX_RETRIES) {
|
|
76
|
+
await sleep(delayMs)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { ok: false, status: 429, data: { error: "Rate limited" } }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Retryable server errors
|
|
84
|
+
if (
|
|
85
|
+
RETRYABLE_STATUS_CODES.includes(response.status) &&
|
|
86
|
+
attempt < MAX_RETRIES
|
|
87
|
+
) {
|
|
88
|
+
await sleep(getBackoffDelay(attempt))
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = await response.json().catch(() => ({}))
|
|
93
|
+
return { ok: response.ok, status: response.status, data }
|
|
94
|
+
} catch (error) {
|
|
95
|
+
clearTimeout(timeoutId)
|
|
96
|
+
|
|
97
|
+
lastError = error
|
|
98
|
+
|
|
99
|
+
if (attempt < MAX_RETRIES) {
|
|
100
|
+
await sleep(getBackoffDelay(attempt))
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (error.name === "AbortError") {
|
|
105
|
+
return { ok: false, status: 0, data: { error: "Request timed out" } }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
status: 0,
|
|
111
|
+
data: { error: error.message || "Network error" },
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
status: 0,
|
|
119
|
+
data: { error: lastError?.message || "Network error" },
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* GET from the registry API (/api/registry prefix).
|
|
125
|
+
*
|
|
126
|
+
* @param {string} path - Path after /api/registry (e.g., '/-/whoami')
|
|
127
|
+
* @param {string | null} token
|
|
128
|
+
* @param {string} baseUrl
|
|
129
|
+
* @returns {Promise<{ok: boolean, status: number, data: *}>}
|
|
130
|
+
*/
|
|
131
|
+
export function registryGet(path, token, baseUrl) {
|
|
132
|
+
return apiGet(`${baseUrl}/api/registry${path}`, token)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* GET from the search API (/api/search prefix).
|
|
137
|
+
*
|
|
138
|
+
* @param {string} path - Path after /api/search (e.g., '/packages?q=react')
|
|
139
|
+
* @param {string | null} token
|
|
140
|
+
* @param {string} baseUrl
|
|
141
|
+
* @returns {Promise<{ok: boolean, status: number, data: *}>}
|
|
142
|
+
*/
|
|
143
|
+
export function searchGet(path, token, baseUrl) {
|
|
144
|
+
return apiGet(`${baseUrl}/api/search${path}`, token)
|
|
145
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_REGISTRY_URL,
|
|
3
|
+
KEYTAR_ACCOUNT_NAME,
|
|
4
|
+
KEYTAR_SERVICE_NAME,
|
|
5
|
+
} from "./constants.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the LPM auth token.
|
|
9
|
+
* Priority: LPM_TOKEN env var → OS keychain (via keytar) → null
|
|
10
|
+
*
|
|
11
|
+
* @returns {Promise<string | null>}
|
|
12
|
+
*/
|
|
13
|
+
export async function getToken() {
|
|
14
|
+
// 1. Environment variable (recommended for MCP configs)
|
|
15
|
+
if (process.env.LPM_TOKEN) {
|
|
16
|
+
return process.env.LPM_TOKEN
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 2. OS keychain (reads from same store as `lpm login`)
|
|
20
|
+
try {
|
|
21
|
+
const keytar = await import("keytar")
|
|
22
|
+
const token = await keytar.default.getPassword(
|
|
23
|
+
KEYTAR_SERVICE_NAME,
|
|
24
|
+
KEYTAR_ACCOUNT_NAME,
|
|
25
|
+
)
|
|
26
|
+
if (token) return token
|
|
27
|
+
} catch {
|
|
28
|
+
// keytar not available — this is expected in many environments
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the LPM registry base URL.
|
|
36
|
+
* Priority: LPM_REGISTRY_URL env var → default
|
|
37
|
+
*
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function getBaseUrl() {
|
|
41
|
+
return process.env.LPM_REGISTRY_URL || DEFAULT_REGISTRY_URL
|
|
42
|
+
}
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory cache with TTL support.
|
|
3
|
+
* Used to reduce API calls for repeated queries within an MCP session.
|
|
4
|
+
*/
|
|
5
|
+
export class MemoryCache {
|
|
6
|
+
constructor() {
|
|
7
|
+
this._store = new Map()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get a cached value. Returns undefined if missing or expired.
|
|
12
|
+
* @param {string} key
|
|
13
|
+
* @returns {*}
|
|
14
|
+
*/
|
|
15
|
+
get(key) {
|
|
16
|
+
const entry = this._store.get(key)
|
|
17
|
+
if (!entry) return undefined
|
|
18
|
+
|
|
19
|
+
if (Date.now() > entry.expiresAt) {
|
|
20
|
+
this._store.delete(key)
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return entry.value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Store a value with a TTL. If ttlMs is 0 or falsy, this is a no-op.
|
|
29
|
+
* @param {string} key
|
|
30
|
+
* @param {*} value
|
|
31
|
+
* @param {number} ttlMs
|
|
32
|
+
*/
|
|
33
|
+
set(key, value, ttlMs) {
|
|
34
|
+
if (!ttlMs) return
|
|
35
|
+
|
|
36
|
+
this._store.set(key, {
|
|
37
|
+
value,
|
|
38
|
+
expiresAt: Date.now() + ttlMs,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a valid (non-expired) entry exists.
|
|
44
|
+
* @param {string} key
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
has(key) {
|
|
48
|
+
return this.get(key) !== undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove a specific entry.
|
|
53
|
+
* @param {string} key
|
|
54
|
+
*/
|
|
55
|
+
delete(key) {
|
|
56
|
+
this._store.delete(key)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove all entries.
|
|
61
|
+
*/
|
|
62
|
+
clear() {
|
|
63
|
+
this._store.clear()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Number of entries (including potentially expired ones).
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
get size() {
|
|
71
|
+
return this._store.size
|
|
72
|
+
}
|
|
73
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile, execSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
const CLI_TIMEOUT_MS = 60_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the path to the `lpm` CLI binary.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. LPM_CLI_PATH env var (explicit override)
|
|
10
|
+
* 2. `which lpm` (global install)
|
|
11
|
+
* 3. null (caller should handle fallback)
|
|
12
|
+
*
|
|
13
|
+
* @returns {string|null}
|
|
14
|
+
*/
|
|
15
|
+
export function resolveCli() {
|
|
16
|
+
// 1. Explicit override
|
|
17
|
+
const envPath = process.env.LPM_CLI_PATH
|
|
18
|
+
if (envPath) return envPath
|
|
19
|
+
|
|
20
|
+
// 2. Global install
|
|
21
|
+
try {
|
|
22
|
+
const result = execSync("which lpm", {
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
timeout: 5_000,
|
|
25
|
+
}).trim()
|
|
26
|
+
if (result) return result
|
|
27
|
+
} catch {
|
|
28
|
+
// not found
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run an LPM CLI command and return parsed JSON output.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]} args - CLI arguments (e.g., ['add', '@lpm.dev/owner.pkg', '--json'])
|
|
38
|
+
* @param {{ timeout?: number }} options
|
|
39
|
+
* @returns {Promise<{ success: boolean, data: object|null, error: string|null }>}
|
|
40
|
+
*/
|
|
41
|
+
export async function runCli(args, options = {}) {
|
|
42
|
+
const cliPath = resolveCli()
|
|
43
|
+
|
|
44
|
+
if (!cliPath) {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
data: null,
|
|
48
|
+
error:
|
|
49
|
+
"LPM CLI not found. Install it with: npm install -g @lpm-registry/cli",
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const timeout = options.timeout || CLI_TIMEOUT_MS
|
|
54
|
+
|
|
55
|
+
return new Promise(resolve => {
|
|
56
|
+
const _child = execFile(
|
|
57
|
+
cliPath,
|
|
58
|
+
args,
|
|
59
|
+
{
|
|
60
|
+
timeout,
|
|
61
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
62
|
+
env: { ...process.env },
|
|
63
|
+
},
|
|
64
|
+
(err, stdout, stderr) => {
|
|
65
|
+
if (err && !stdout) {
|
|
66
|
+
// CLI failed without producing output
|
|
67
|
+
const message = err.killed
|
|
68
|
+
? `CLI command timed out after ${timeout / 1000}s`
|
|
69
|
+
: err.message || "CLI command failed"
|
|
70
|
+
resolve({ success: false, data: null, error: message })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Try to parse JSON from stdout
|
|
75
|
+
try {
|
|
76
|
+
const data = JSON.parse(stdout)
|
|
77
|
+
resolve({
|
|
78
|
+
success: data.success !== false,
|
|
79
|
+
data,
|
|
80
|
+
error:
|
|
81
|
+
data.success === false
|
|
82
|
+
? data.errors?.[0] || "Command failed"
|
|
83
|
+
: null,
|
|
84
|
+
})
|
|
85
|
+
} catch {
|
|
86
|
+
// CLI produced output but not valid JSON
|
|
87
|
+
resolve({
|
|
88
|
+
success: false,
|
|
89
|
+
data: null,
|
|
90
|
+
error:
|
|
91
|
+
stderr?.trim() ||
|
|
92
|
+
stdout?.trim() ||
|
|
93
|
+
"CLI returned non-JSON output",
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
}
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// CRITICAL: Never use console.log() in this package — it corrupts the MCP stdio transport
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_REGISTRY_URL = "https://lpm.dev"
|
|
4
|
+
|
|
5
|
+
export const KEYTAR_SERVICE_NAME = "lpm-cli"
|
|
6
|
+
export const KEYTAR_ACCOUNT_NAME = "auth-token"
|
|
7
|
+
|
|
8
|
+
export const REQUEST_TIMEOUT_MS = 30_000
|
|
9
|
+
export const MAX_RETRIES = 2
|
|
10
|
+
export const RETRY_BASE_DELAY_MS = 1_000
|
|
11
|
+
export const RETRY_MAX_DELAY_MS = 10_000
|
|
12
|
+
export const RETRY_BACKOFF_MULTIPLIER = 2
|
|
13
|
+
|
|
14
|
+
export const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]
|
|
15
|
+
|
|
16
|
+
export const CACHE_TTL = {
|
|
17
|
+
SHORT: 5 * 60 * 1000,
|
|
18
|
+
LONG: 60 * 60 * 1000,
|
|
19
|
+
NONE: 0,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ERROR_MESSAGES = {
|
|
23
|
+
noToken:
|
|
24
|
+
"No LPM token found. Set LPM_TOKEN environment variable or run `lpm login` first.",
|
|
25
|
+
unauthorized:
|
|
26
|
+
"Authentication failed. Your token may be expired or revoked. Run `lpm login` to re-authenticate.",
|
|
27
|
+
notFound: "Package not found. Check the name format: owner.package-name",
|
|
28
|
+
accessDenied: "Access denied. This package may be private.",
|
|
29
|
+
rateLimited: "Rate limited. Please wait and try again.",
|
|
30
|
+
networkError: "Cannot reach lpm.dev. Check your internet connection.",
|
|
31
|
+
timeout: "Request timed out. Try again later.",
|
|
32
|
+
invalidName:
|
|
33
|
+
"Invalid package name format. Expected: owner.package-name or @lpm.dev/owner.package-name",
|
|
34
|
+
searchNoParams: 'At least one of "query" or "category" is required.',
|
|
35
|
+
}
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ERROR_MESSAGES } from "./constants.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a successful MCP tool response.
|
|
5
|
+
* @param {string} text
|
|
6
|
+
* @returns {{ content: Array<{ type: string, text: string }> }}
|
|
7
|
+
*/
|
|
8
|
+
export function textResponse(text) {
|
|
9
|
+
return {
|
|
10
|
+
content: [{ type: "text", text }],
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create an error MCP tool response.
|
|
16
|
+
* @param {string} message
|
|
17
|
+
* @returns {{ content: Array<{ type: string, text: string }>, isError: true }}
|
|
18
|
+
*/
|
|
19
|
+
export function errorResponse(message) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: message }],
|
|
22
|
+
isError: true,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a successful MCP tool response with JSON data.
|
|
28
|
+
* @param {*} data
|
|
29
|
+
* @returns {{ content: Array<{ type: string, text: string }> }}
|
|
30
|
+
*/
|
|
31
|
+
export function jsonResponse(data) {
|
|
32
|
+
return textResponse(JSON.stringify(data, null, 2))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a package name in 'owner.pkg' or '@lpm.dev/owner.pkg' format.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} input
|
|
39
|
+
* @returns {{ owner: string, name: string }}
|
|
40
|
+
* @throws {Error} if the format is invalid
|
|
41
|
+
*/
|
|
42
|
+
export function parseName(input) {
|
|
43
|
+
if (!input || typeof input !== "string") {
|
|
44
|
+
throw new Error(ERROR_MESSAGES.invalidName)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let cleaned = input.trim()
|
|
48
|
+
|
|
49
|
+
// Strip @lpm.dev/ prefix
|
|
50
|
+
if (cleaned.startsWith("@lpm.dev/")) {
|
|
51
|
+
cleaned = cleaned.slice(9)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dotIndex = cleaned.indexOf(".")
|
|
55
|
+
if (dotIndex < 1 || dotIndex >= cleaned.length - 1) {
|
|
56
|
+
throw new Error(ERROR_MESSAGES.invalidName)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const owner = cleaned.substring(0, dotIndex)
|
|
60
|
+
const name = cleaned.substring(dotIndex + 1)
|
|
61
|
+
|
|
62
|
+
if (!owner || !name) {
|
|
63
|
+
throw new Error(ERROR_MESSAGES.invalidName)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { owner, name }
|
|
67
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the installed version of an LPM package from the local package.json.
|
|
6
|
+
* Walks up the directory tree to find the nearest package.json containing
|
|
7
|
+
* the requested package in dependencies or devDependencies.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} packageName - Package name (owner.name or @lpm.dev/owner.name)
|
|
10
|
+
* @param {string} [cwd] - Starting directory (defaults to process.cwd())
|
|
11
|
+
* @returns {string | null} - Resolved version string or null if not found
|
|
12
|
+
*/
|
|
13
|
+
export function resolveInstalledVersion(packageName, cwd) {
|
|
14
|
+
// Normalize to @lpm.dev/ format for package.json lookup
|
|
15
|
+
const lpmName = packageName.startsWith("@lpm.dev/")
|
|
16
|
+
? packageName
|
|
17
|
+
: `@lpm.dev/${packageName}`
|
|
18
|
+
|
|
19
|
+
let dir = cwd || process.cwd()
|
|
20
|
+
const _root = dirname(dir) === dir ? dir : undefined
|
|
21
|
+
|
|
22
|
+
// Walk up directory tree (max 10 levels to avoid infinite loops)
|
|
23
|
+
for (let i = 0; i < 10; i++) {
|
|
24
|
+
const pkgPath = join(dir, "package.json")
|
|
25
|
+
if (existsSync(pkgPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
|
28
|
+
const allDeps = {
|
|
29
|
+
...pkg.dependencies,
|
|
30
|
+
...pkg.devDependencies,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const version = allDeps[lpmName]
|
|
34
|
+
if (version) {
|
|
35
|
+
// Strip semver range chars (^, ~, >=, <=, >, <, =)
|
|
36
|
+
return version.replace(/^[\^~>=<]+/, "")
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Invalid package.json, continue walking up
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parent = dirname(dir)
|
|
44
|
+
if (parent === dir) break // Reached filesystem root
|
|
45
|
+
dir = parent
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null
|
|
49
|
+
}
|