@lpm-registry/cli 0.2.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 +36 -0
- package/LICENSE +15 -0
- package/README.md +406 -0
- package/bin/lpm.js +334 -0
- package/index.d.ts +131 -0
- package/index.js +31 -0
- package/lib/api.js +324 -0
- package/lib/commands/add.js +1217 -0
- package/lib/commands/audit.js +283 -0
- package/lib/commands/cache.js +209 -0
- package/lib/commands/check-name.js +112 -0
- package/lib/commands/config.js +174 -0
- package/lib/commands/doctor.js +142 -0
- package/lib/commands/info.js +215 -0
- package/lib/commands/init.js +146 -0
- package/lib/commands/install.js +217 -0
- package/lib/commands/login.js +547 -0
- package/lib/commands/logout.js +94 -0
- package/lib/commands/marketplace-compare.js +164 -0
- package/lib/commands/marketplace-earnings.js +89 -0
- package/lib/commands/mcp-setup.js +363 -0
- package/lib/commands/open.js +82 -0
- package/lib/commands/outdated.js +291 -0
- package/lib/commands/pool-stats.js +100 -0
- package/lib/commands/publish.js +707 -0
- package/lib/commands/quality.js +211 -0
- package/lib/commands/remove.js +82 -0
- package/lib/commands/run.js +14 -0
- package/lib/commands/search.js +143 -0
- package/lib/commands/setup.js +92 -0
- package/lib/commands/skills.js +863 -0
- package/lib/commands/token-rotate.js +25 -0
- package/lib/commands/whoami.js +129 -0
- package/lib/config.js +240 -0
- package/lib/constants.js +190 -0
- package/lib/ecosystem.js +501 -0
- package/lib/editors.js +215 -0
- package/lib/import-rewriter.js +364 -0
- package/lib/install-targets/mcp-server.js +245 -0
- package/lib/install-targets/vscode-extension.js +178 -0
- package/lib/install-targets.js +82 -0
- package/lib/integrity.js +179 -0
- package/lib/lpm-config-prompts.js +102 -0
- package/lib/lpm-config.js +408 -0
- package/lib/project-utils.js +152 -0
- package/lib/quality/checks.js +654 -0
- package/lib/quality/display.js +139 -0
- package/lib/quality/score.js +115 -0
- package/lib/quality/swift-checks.js +447 -0
- package/lib/safe-path.js +180 -0
- package/lib/secure-store.js +288 -0
- package/lib/swift-project.js +637 -0
- package/lib/ui.js +40 -0
- package/package.json +74 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Import Rewriter for LPM Source Packages
|
|
3
|
+
*
|
|
4
|
+
* Resolves import specifiers against the installed file set and rewrites
|
|
5
|
+
* internal imports to match the buyer's project alias setup.
|
|
6
|
+
*
|
|
7
|
+
* Pure-function module — no I/O, no fs, no side effects.
|
|
8
|
+
* All path logic uses forward-slash (POSIX) conventions.
|
|
9
|
+
*
|
|
10
|
+
* @module cli/lib/import-rewriter
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} RewriteOptions
|
|
15
|
+
* @property {string} fileDestPath - Dest-relative path of the file being processed
|
|
16
|
+
* @property {Set<string>} destFileSet - All dest-relative paths being installed
|
|
17
|
+
* @property {string} [fileSrcPath] - Src-relative path (for relative import resolution)
|
|
18
|
+
* @property {Map<string,string>} [srcToDestMap] - Maps src paths → dest paths
|
|
19
|
+
* @property {string} [authorAlias] - Author's import alias prefix (e.g., "@/")
|
|
20
|
+
* @property {string} [buyerAlias] - Buyer's alias for the dest directory (e.g., "@/components/design-system")
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** File extensions to try when resolving extensionless imports */
|
|
24
|
+
const EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Matches import/export specifiers on a single line.
|
|
28
|
+
*
|
|
29
|
+
* Captures:
|
|
30
|
+
* - from 'specifier' / from "specifier"
|
|
31
|
+
* - import 'specifier' / import "specifier" (side-effect)
|
|
32
|
+
* - import('specifier') / import("specifier") (dynamic)
|
|
33
|
+
* - export { x } from 'specifier'
|
|
34
|
+
*
|
|
35
|
+
* Groups: $1 = keyword prefix, $2 = quote char, $3 = specifier
|
|
36
|
+
*/
|
|
37
|
+
const SPECIFIER_RE = /(from\s+|import\s*\(\s*|import\s+)(['"])(.*?)\2/g
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Rewrite internal imports in file content to use the buyer's alias.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} content - File source content
|
|
43
|
+
* @param {RewriteOptions} options
|
|
44
|
+
* @returns {string} Content with internal imports rewritten
|
|
45
|
+
*/
|
|
46
|
+
export function rewriteImports(content, options) {
|
|
47
|
+
const {
|
|
48
|
+
fileDestPath,
|
|
49
|
+
destFileSet,
|
|
50
|
+
fileSrcPath,
|
|
51
|
+
srcToDestMap,
|
|
52
|
+
authorAlias,
|
|
53
|
+
buyerAlias,
|
|
54
|
+
} = options
|
|
55
|
+
|
|
56
|
+
// Nothing to do if no buyer alias (relative imports already work)
|
|
57
|
+
// and no author alias to resolve
|
|
58
|
+
if (!buyerAlias && !authorAlias) return content
|
|
59
|
+
|
|
60
|
+
const fileDestDir = dirname(fileDestPath)
|
|
61
|
+
const fileSrcDir = fileSrcPath ? dirname(fileSrcPath) : fileDestDir
|
|
62
|
+
|
|
63
|
+
// Build src file set from srcToDestMap keys for author alias lookups
|
|
64
|
+
const srcFileSet = srcToDestMap ? new Set(srcToDestMap.keys()) : null
|
|
65
|
+
|
|
66
|
+
const lines = content.split("\n")
|
|
67
|
+
const result = []
|
|
68
|
+
let inBlockComment = false
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
// Track block comments (best-effort)
|
|
72
|
+
if (inBlockComment) {
|
|
73
|
+
if (line.includes("*/")) inBlockComment = false
|
|
74
|
+
result.push(line)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (line.trimStart().startsWith("/*")) {
|
|
78
|
+
if (!line.includes("*/")) inBlockComment = true
|
|
79
|
+
result.push(line)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
// Skip single-line comments
|
|
83
|
+
if (line.trimStart().startsWith("//")) {
|
|
84
|
+
result.push(line)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
result.push(
|
|
89
|
+
rewriteLineImports(
|
|
90
|
+
line,
|
|
91
|
+
fileDestDir,
|
|
92
|
+
fileSrcDir,
|
|
93
|
+
destFileSet,
|
|
94
|
+
srcFileSet,
|
|
95
|
+
srcToDestMap,
|
|
96
|
+
authorAlias,
|
|
97
|
+
buyerAlias,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result.join("\n")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Rewrite import specifiers on a single line.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} line
|
|
109
|
+
* @param {string} fileDestDir
|
|
110
|
+
* @param {string} fileSrcDir
|
|
111
|
+
* @param {Set<string>} destFileSet
|
|
112
|
+
* @param {Set<string>|null} srcFileSet
|
|
113
|
+
* @param {Map<string,string>|null} srcToDestMap
|
|
114
|
+
* @param {string} [authorAlias]
|
|
115
|
+
* @param {string} [buyerAlias]
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function rewriteLineImports(
|
|
119
|
+
line,
|
|
120
|
+
fileDestDir,
|
|
121
|
+
fileSrcDir,
|
|
122
|
+
destFileSet,
|
|
123
|
+
srcFileSet,
|
|
124
|
+
srcToDestMap,
|
|
125
|
+
authorAlias,
|
|
126
|
+
buyerAlias,
|
|
127
|
+
) {
|
|
128
|
+
return line.replace(SPECIFIER_RE, (match, prefix, quote, specifier) => {
|
|
129
|
+
const resolvedDestPath = resolveSpecifier(
|
|
130
|
+
specifier,
|
|
131
|
+
fileDestDir,
|
|
132
|
+
fileSrcDir,
|
|
133
|
+
destFileSet,
|
|
134
|
+
srcFileSet,
|
|
135
|
+
srcToDestMap,
|
|
136
|
+
authorAlias,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if (!resolvedDestPath) {
|
|
140
|
+
// External import or unresolvable — leave unchanged
|
|
141
|
+
return match
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Internal import — compute new specifier
|
|
145
|
+
const newSpecifier = computeNewSpecifier(
|
|
146
|
+
resolvedDestPath,
|
|
147
|
+
fileDestDir,
|
|
148
|
+
buyerAlias,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if (!newSpecifier) {
|
|
152
|
+
// No buyer alias and cannot compute — leave unchanged
|
|
153
|
+
return match
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return `${prefix}${quote}${newSpecifier}${quote}`
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve an import specifier to a dest file path if it's internal.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} specifier - The import specifier
|
|
164
|
+
* @param {string} fileDestDir - Dest directory of the importing file
|
|
165
|
+
* @param {string} fileSrcDir - Src directory of the importing file
|
|
166
|
+
* @param {Set<string>} destFileSet - All dest paths
|
|
167
|
+
* @param {Set<string>|null} srcFileSet - All src paths (for author alias)
|
|
168
|
+
* @param {Map<string,string>|null} srcToDestMap - src → dest mapping
|
|
169
|
+
* @param {string} [authorAlias] - Author's import alias prefix
|
|
170
|
+
* @returns {string|null} Resolved dest path, or null if external
|
|
171
|
+
*/
|
|
172
|
+
function resolveSpecifier(
|
|
173
|
+
specifier,
|
|
174
|
+
fileDestDir,
|
|
175
|
+
fileSrcDir,
|
|
176
|
+
destFileSet,
|
|
177
|
+
srcFileSet,
|
|
178
|
+
srcToDestMap,
|
|
179
|
+
authorAlias,
|
|
180
|
+
) {
|
|
181
|
+
// 1. Relative import: ./foo or ../bar
|
|
182
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
183
|
+
// Resolve against src directory, then map to dest
|
|
184
|
+
if (srcToDestMap && srcToDestMap.size > 0) {
|
|
185
|
+
const resolvedSrc = normalizePath(joinPath(fileSrcDir, specifier))
|
|
186
|
+
const srcMatch = tryResolveFile(resolvedSrc, new Set(srcToDestMap.keys()))
|
|
187
|
+
if (srcMatch) {
|
|
188
|
+
return srcToDestMap.get(srcMatch) || null
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fallback: resolve against dest directory directly
|
|
193
|
+
const resolvedDest = normalizePath(joinPath(fileDestDir, specifier))
|
|
194
|
+
return tryResolveFile(resolvedDest, destFileSet)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. Author alias import: starts with the declared importAlias
|
|
198
|
+
if (authorAlias && specifier.startsWith(authorAlias)) {
|
|
199
|
+
const aliasRelative = specifier.slice(authorAlias.length)
|
|
200
|
+
|
|
201
|
+
// Look up in src file set, then map to dest
|
|
202
|
+
if (srcFileSet && srcToDestMap) {
|
|
203
|
+
const srcMatch = tryResolveFile(aliasRelative, srcFileSet)
|
|
204
|
+
if (srcMatch) {
|
|
205
|
+
return srcToDestMap.get(srcMatch) || null
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Fallback: try direct match in dest file set
|
|
210
|
+
return tryResolveFile(aliasRelative, destFileSet)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3. Bare specifier (react, next/link, @scope/pkg) — external
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve an import specifier to a dest file path.
|
|
219
|
+
*
|
|
220
|
+
* Exported for testing.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} specifier - Import specifier
|
|
223
|
+
* @param {string} fileDir - Directory of the importing file
|
|
224
|
+
* @param {Set<string>} fileSet - File paths to match against
|
|
225
|
+
* @param {string} [authorAlias] - Author's import alias prefix
|
|
226
|
+
* @returns {string|null} Matching file path, or null if external
|
|
227
|
+
*/
|
|
228
|
+
export function resolveImportToFilePath(
|
|
229
|
+
specifier,
|
|
230
|
+
fileDir,
|
|
231
|
+
fileSet,
|
|
232
|
+
authorAlias,
|
|
233
|
+
) {
|
|
234
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
235
|
+
const resolved = normalizePath(joinPath(fileDir, specifier))
|
|
236
|
+
return tryResolveFile(resolved, fileSet)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (authorAlias && specifier.startsWith(authorAlias)) {
|
|
240
|
+
const aliasRelative = specifier.slice(authorAlias.length)
|
|
241
|
+
return tryResolveFile(aliasRelative, fileSet)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Try to resolve a path against a file set, trying extensions and index files.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} candidatePath - Path to resolve (no leading ./)
|
|
251
|
+
* @param {Set<string>} fileSet - Set of file paths
|
|
252
|
+
* @returns {string|null} Matching path or null
|
|
253
|
+
*/
|
|
254
|
+
function tryResolveFile(candidatePath, fileSet) {
|
|
255
|
+
// Normalize: remove leading ./
|
|
256
|
+
candidatePath = candidatePath.replace(/^\.\//, "")
|
|
257
|
+
|
|
258
|
+
// 1. Exact match
|
|
259
|
+
if (fileSet.has(candidatePath)) return candidatePath
|
|
260
|
+
|
|
261
|
+
// 2. Try appending extensions
|
|
262
|
+
for (const ext of EXTENSIONS) {
|
|
263
|
+
const withExt = candidatePath + ext
|
|
264
|
+
if (fileSet.has(withExt)) return withExt
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 3. Try as directory: append /index.ext
|
|
268
|
+
for (const ext of EXTENSIONS) {
|
|
269
|
+
const indexPath = `${candidatePath}/index${ext}`
|
|
270
|
+
if (fileSet.has(indexPath)) return indexPath
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Compute the new import specifier for an internal file.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} resolvedDestPath - Dest path of the imported file
|
|
280
|
+
* @param {string} fileDestDir - Dest directory of the importing file
|
|
281
|
+
* @param {string} [buyerAlias] - Buyer's alias (e.g., "@/components/design-system")
|
|
282
|
+
* @returns {string|null} New specifier, or null if no rewrite needed
|
|
283
|
+
*/
|
|
284
|
+
function computeNewSpecifier(resolvedDestPath, _fileDestDir, buyerAlias) {
|
|
285
|
+
const cleanPath = stripImportExtension(resolvedDestPath)
|
|
286
|
+
|
|
287
|
+
if (buyerAlias) {
|
|
288
|
+
// Buyer wants alias-based imports
|
|
289
|
+
const alias = buyerAlias.endsWith("/") ? buyerAlias : `${buyerAlias}/`
|
|
290
|
+
return alias + cleanPath
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// No buyer alias — no rewrite needed
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Strip file extension and /index suffix for clean import paths.
|
|
299
|
+
*
|
|
300
|
+
* "components/dialog/Dialog.jsx" → "components/dialog/Dialog"
|
|
301
|
+
* "components/dialog/index.js" → "components/dialog"
|
|
302
|
+
*
|
|
303
|
+
* @param {string} filePath
|
|
304
|
+
* @returns {string}
|
|
305
|
+
*/
|
|
306
|
+
function stripImportExtension(filePath) {
|
|
307
|
+
// Strip /index.ext → parent dir
|
|
308
|
+
const indexMatch = filePath.match(/\/index\.(js|jsx|ts|tsx)$/)
|
|
309
|
+
if (indexMatch) {
|
|
310
|
+
return filePath.slice(0, -`/index.${indexMatch[1]}`.length)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Strip .ext
|
|
314
|
+
const extMatch = filePath.match(/\.(js|jsx|ts|tsx)$/)
|
|
315
|
+
if (extMatch) {
|
|
316
|
+
return filePath.slice(0, -`.${extMatch[1]}`.length)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return filePath
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Normalize a path by resolving . and .. segments.
|
|
324
|
+
* Pure string operation — no filesystem access.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} p - Path with forward slashes
|
|
327
|
+
* @returns {string} Normalized path
|
|
328
|
+
*/
|
|
329
|
+
function normalizePath(p) {
|
|
330
|
+
const segments = p.split("/")
|
|
331
|
+
const resolved = []
|
|
332
|
+
for (const seg of segments) {
|
|
333
|
+
if (seg === "..") {
|
|
334
|
+
resolved.pop()
|
|
335
|
+
} else if (seg !== "." && seg !== "") {
|
|
336
|
+
resolved.push(seg)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return resolved.join("/")
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Join two path segments with forward slashes.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} base
|
|
346
|
+
* @param {string} relative
|
|
347
|
+
* @returns {string}
|
|
348
|
+
*/
|
|
349
|
+
function joinPath(base, relative) {
|
|
350
|
+
if (!base || base === ".") return relative
|
|
351
|
+
return `${base}/${relative}`
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get the directory portion of a path.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} filePath
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
function dirname(filePath) {
|
|
361
|
+
const lastSlash = filePath.lastIndexOf("/")
|
|
362
|
+
if (lastSlash === -1) return ""
|
|
363
|
+
return filePath.substring(0, lastSlash)
|
|
364
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Install Target
|
|
3
|
+
*
|
|
4
|
+
* Handles `lpm add` for packages with `type: "mcp-server"` in lpm.config.json.
|
|
5
|
+
*
|
|
6
|
+
* Instead of copying source files into the project, this handler:
|
|
7
|
+
* 1. Installs the package globally (so `npx` can find it, or node can run it)
|
|
8
|
+
* 2. Detects installed AI editors
|
|
9
|
+
* 3. Prompts for env vars defined in mcpConfig.env
|
|
10
|
+
* 4. Writes MCP server config entries to each editor
|
|
11
|
+
*
|
|
12
|
+
* @module cli/lib/install-targets/mcp-server
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as p from "@clack/prompts"
|
|
16
|
+
import chalk from "chalk"
|
|
17
|
+
import {
|
|
18
|
+
addMcpServer,
|
|
19
|
+
detectEditors,
|
|
20
|
+
EDITORS,
|
|
21
|
+
hasMcpServer,
|
|
22
|
+
removeMcpServerEntry,
|
|
23
|
+
shortPath,
|
|
24
|
+
} from "../editors.js"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Derive the MCP server name from the package name.
|
|
28
|
+
* @lpm.dev/owner.my-mcp → "lpm-owner-my-mcp"
|
|
29
|
+
* @param {string} pkgName - Full package reference (e.g., "@lpm.dev/owner.my-mcp")
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function deriveServerName(pkgName) {
|
|
33
|
+
// Strip @lpm.dev/ prefix and replace dots/slashes with dashes
|
|
34
|
+
return `lpm-${pkgName.replace("@lpm.dev/", "").replace(/[./]/g, "-")}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the server config object from lpm.config.json's mcpConfig + user answers.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} mcpConfig - mcpConfig from lpm.config.json
|
|
41
|
+
* @param {string} pkgName - Package name for npx fallback
|
|
42
|
+
* @param {object} envAnswers - User-provided env var values
|
|
43
|
+
* @returns {object} MCP server config for editor JSON
|
|
44
|
+
*/
|
|
45
|
+
function buildServerConfig(mcpConfig, pkgName, envAnswers) {
|
|
46
|
+
const config = {}
|
|
47
|
+
|
|
48
|
+
if (mcpConfig?.command) {
|
|
49
|
+
config.command = mcpConfig.command
|
|
50
|
+
config.args = mcpConfig.args || []
|
|
51
|
+
} else {
|
|
52
|
+
// Fallback: use npx to run the package
|
|
53
|
+
config.command = "npx"
|
|
54
|
+
config.args = [pkgName]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Add env vars (only non-empty values)
|
|
58
|
+
const env = {}
|
|
59
|
+
for (const [key, value] of Object.entries(envAnswers)) {
|
|
60
|
+
if (value) env[key] = value
|
|
61
|
+
}
|
|
62
|
+
if (Object.keys(env).length > 0) {
|
|
63
|
+
config.env = env
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return config
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Prompt user for env vars defined in mcpConfig.env.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} envSchema - env field from mcpConfig (key → { prompt, required })
|
|
73
|
+
* @returns {Promise<object>} key → value answers
|
|
74
|
+
*/
|
|
75
|
+
async function promptForEnvVars(envSchema) {
|
|
76
|
+
if (!envSchema || Object.keys(envSchema).length === 0) return {}
|
|
77
|
+
|
|
78
|
+
const answers = {}
|
|
79
|
+
|
|
80
|
+
for (const [key, schema] of Object.entries(envSchema)) {
|
|
81
|
+
const label =
|
|
82
|
+
typeof schema === "object" ? schema.prompt : `Enter value for ${key}`
|
|
83
|
+
const required = typeof schema === "object" ? schema.required : false
|
|
84
|
+
|
|
85
|
+
const value = await p.text({
|
|
86
|
+
message: label || `Enter value for ${key}`,
|
|
87
|
+
placeholder: required ? "(required)" : "(optional, press Enter to skip)",
|
|
88
|
+
validate: val => {
|
|
89
|
+
if (required && !val?.trim()) return `${key} is required`
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (p.isCancel(value)) {
|
|
94
|
+
p.cancel("Installation cancelled.")
|
|
95
|
+
process.exit(0)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (value?.trim()) {
|
|
99
|
+
answers[key] = value.trim()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return answers
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Install an MCP server package into the user's AI editors.
|
|
108
|
+
*
|
|
109
|
+
* Called by `lpm add` when the package has `type: "mcp-server"` in lpm.config.json.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} params
|
|
112
|
+
* @param {string} params.name - Package name (e.g., "@lpm.dev/owner.my-mcp")
|
|
113
|
+
* @param {string} params.version - Package version
|
|
114
|
+
* @param {object} params.lpmConfig - Parsed lpm.config.json
|
|
115
|
+
* @param {string} params.extractDir - Temp directory with extracted package files
|
|
116
|
+
* @param {object} params.options - CLI options (force, yes)
|
|
117
|
+
* @returns {Promise<{ success: boolean, message: string }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function installMcpServer({
|
|
120
|
+
name,
|
|
121
|
+
version: _version,
|
|
122
|
+
lpmConfig,
|
|
123
|
+
extractDir: _extractDir,
|
|
124
|
+
options,
|
|
125
|
+
}) {
|
|
126
|
+
const mcpConfig = lpmConfig?.mcpConfig || {}
|
|
127
|
+
const serverName = deriveServerName(name)
|
|
128
|
+
|
|
129
|
+
// 1. Detect installed editors
|
|
130
|
+
const detected = detectEditors()
|
|
131
|
+
|
|
132
|
+
if (detected.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
message:
|
|
136
|
+
"No supported AI editors detected. Install Claude Code, Cursor, VS Code, Claude Desktop, or Windsurf first.",
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 2. Prompt for env vars (unless --yes)
|
|
141
|
+
let envAnswers = {}
|
|
142
|
+
if (mcpConfig.env && !options?.yes) {
|
|
143
|
+
envAnswers = await promptForEnvVars(mcpConfig.env)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 3. Build server config
|
|
147
|
+
const serverConfig = buildServerConfig(mcpConfig, name, envAnswers)
|
|
148
|
+
|
|
149
|
+
// 4. Let user select editors (unless --yes → all detected)
|
|
150
|
+
let selectedEditors = detected
|
|
151
|
+
|
|
152
|
+
if (!options?.yes && detected.length > 1) {
|
|
153
|
+
const selectOptions = detected.map(editor => {
|
|
154
|
+
const installed = hasMcpServer(
|
|
155
|
+
editor.globalPath,
|
|
156
|
+
editor.serverKey,
|
|
157
|
+
serverName,
|
|
158
|
+
)
|
|
159
|
+
return {
|
|
160
|
+
value: editor.id,
|
|
161
|
+
label: installed
|
|
162
|
+
? `${editor.name} ${chalk.dim("(will update)")}`
|
|
163
|
+
: editor.name,
|
|
164
|
+
hint: shortPath(editor.globalPath),
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const selected = await p.multiselect({
|
|
169
|
+
message: "Configure MCP server in:",
|
|
170
|
+
options: selectOptions,
|
|
171
|
+
initialValues: selectOptions.map(o => o.value),
|
|
172
|
+
required: true,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
if (p.isCancel(selected)) {
|
|
176
|
+
p.cancel("Installation cancelled.")
|
|
177
|
+
process.exit(0)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
selectedEditors = detected.filter(e => selected.includes(e.id))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 5. Write config to each editor
|
|
184
|
+
let count = 0
|
|
185
|
+
const configured = []
|
|
186
|
+
|
|
187
|
+
for (const editor of selectedEditors) {
|
|
188
|
+
try {
|
|
189
|
+
addMcpServer(
|
|
190
|
+
editor.globalPath,
|
|
191
|
+
editor.serverKey,
|
|
192
|
+
serverName,
|
|
193
|
+
serverConfig,
|
|
194
|
+
)
|
|
195
|
+
configured.push(editor.name)
|
|
196
|
+
count++
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error(
|
|
199
|
+
chalk.red(` Failed to configure ${editor.name}: ${err.message}`),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (count === 0) {
|
|
205
|
+
return {
|
|
206
|
+
success: false,
|
|
207
|
+
message: "Failed to configure any editors.",
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: true,
|
|
213
|
+
message: `MCP server configured in ${configured.join(", ")}. Restart your editors to activate.`,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Remove an MCP server package from the user's AI editors.
|
|
219
|
+
*
|
|
220
|
+
* @param {object} params
|
|
221
|
+
* @param {string} params.name - Package name
|
|
222
|
+
* @returns {Promise<{ success: boolean, message: string }>}
|
|
223
|
+
*/
|
|
224
|
+
export async function removeMcpServer({ name }) {
|
|
225
|
+
const serverName = deriveServerName(name)
|
|
226
|
+
let count = 0
|
|
227
|
+
|
|
228
|
+
for (const editor of EDITORS) {
|
|
229
|
+
if (removeMcpServerEntry(editor.globalPath, editor.serverKey, serverName)) {
|
|
230
|
+
count++
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (count === 0) {
|
|
235
|
+
return {
|
|
236
|
+
success: true,
|
|
237
|
+
message: "MCP server was not configured in any editor.",
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
success: true,
|
|
243
|
+
message: `Removed MCP server from ${count} editor${count > 1 ? "s" : ""}. Restart your editors to apply.`,
|
|
244
|
+
}
|
|
245
|
+
}
|