@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +15 -0
  3. package/README.md +406 -0
  4. package/bin/lpm.js +334 -0
  5. package/index.d.ts +131 -0
  6. package/index.js +31 -0
  7. package/lib/api.js +324 -0
  8. package/lib/commands/add.js +1217 -0
  9. package/lib/commands/audit.js +283 -0
  10. package/lib/commands/cache.js +209 -0
  11. package/lib/commands/check-name.js +112 -0
  12. package/lib/commands/config.js +174 -0
  13. package/lib/commands/doctor.js +142 -0
  14. package/lib/commands/info.js +215 -0
  15. package/lib/commands/init.js +146 -0
  16. package/lib/commands/install.js +217 -0
  17. package/lib/commands/login.js +547 -0
  18. package/lib/commands/logout.js +94 -0
  19. package/lib/commands/marketplace-compare.js +164 -0
  20. package/lib/commands/marketplace-earnings.js +89 -0
  21. package/lib/commands/mcp-setup.js +363 -0
  22. package/lib/commands/open.js +82 -0
  23. package/lib/commands/outdated.js +291 -0
  24. package/lib/commands/pool-stats.js +100 -0
  25. package/lib/commands/publish.js +707 -0
  26. package/lib/commands/quality.js +211 -0
  27. package/lib/commands/remove.js +82 -0
  28. package/lib/commands/run.js +14 -0
  29. package/lib/commands/search.js +143 -0
  30. package/lib/commands/setup.js +92 -0
  31. package/lib/commands/skills.js +863 -0
  32. package/lib/commands/token-rotate.js +25 -0
  33. package/lib/commands/whoami.js +129 -0
  34. package/lib/config.js +240 -0
  35. package/lib/constants.js +190 -0
  36. package/lib/ecosystem.js +501 -0
  37. package/lib/editors.js +215 -0
  38. package/lib/import-rewriter.js +364 -0
  39. package/lib/install-targets/mcp-server.js +245 -0
  40. package/lib/install-targets/vscode-extension.js +178 -0
  41. package/lib/install-targets.js +82 -0
  42. package/lib/integrity.js +179 -0
  43. package/lib/lpm-config-prompts.js +102 -0
  44. package/lib/lpm-config.js +408 -0
  45. package/lib/project-utils.js +152 -0
  46. package/lib/quality/checks.js +654 -0
  47. package/lib/quality/display.js +139 -0
  48. package/lib/quality/score.js +115 -0
  49. package/lib/quality/swift-checks.js +447 -0
  50. package/lib/safe-path.js +180 -0
  51. package/lib/secure-store.js +288 -0
  52. package/lib/swift-project.js +637 -0
  53. package/lib/ui.js +40 -0
  54. package/package.json +74 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * LPM Package Config System
3
+ *
4
+ * Handles lpm.config.json parsing, validation, condition evaluation,
5
+ * file filtering, and dependency resolution
6
+ * for the `lpm add` command.
7
+ *
8
+ * Pure logic module — no I/O beyond file reads, no prompts.
9
+ *
10
+ * @module cli/lib/lpm-config
11
+ */
12
+
13
+ import fs from "node:fs"
14
+ import path from "node:path"
15
+
16
+ /** Maximum lpm.config.json file size (1 MB) */
17
+ const MAX_CONFIG_FILE_SIZE = 1024 * 1024
18
+
19
+ /** Maximum number of file rules allowed */
20
+ const MAX_FILE_RULES = 1000
21
+
22
+ /**
23
+ * Parse a package reference that may include version and URL query params.
24
+ *
25
+ * Supports:
26
+ * @lpm.dev/owner.pkg
27
+ * @lpm.dev/owner.pkg@1.0.0
28
+ * @lpm.dev/owner.pkg?component=dialog&styling=panda
29
+ * @lpm.dev/owner.pkg@1.0.0?component=dialog,button&styling=panda
30
+ *
31
+ * @param {string} ref - Package reference string
32
+ * @returns {{ name: string, version: string, inlineConfig: Record<string, string>, providedParams: Set<string> }}
33
+ */
34
+ export function parseLpmPackageReference(ref) {
35
+ // Split query string first
36
+ const queryIndex = ref.indexOf("?")
37
+ let packagePart = ref
38
+ let queryString = ""
39
+
40
+ if (queryIndex !== -1) {
41
+ packagePart = ref.substring(0, queryIndex)
42
+ queryString = ref.substring(queryIndex + 1)
43
+ }
44
+
45
+ // Extract version from package part
46
+ let name = packagePart
47
+ let version = "latest"
48
+
49
+ // Find version separator: last @ after position 0 (position 0 is the scope @)
50
+ const lastAt = packagePart.lastIndexOf("@")
51
+ if (lastAt > 0) {
52
+ name = packagePart.substring(0, lastAt)
53
+ version = packagePart.substring(lastAt + 1)
54
+ }
55
+
56
+ // Parse query params
57
+ const inlineConfig = {}
58
+ const providedParams = new Set()
59
+
60
+ if (queryString) {
61
+ const params = new URLSearchParams(queryString)
62
+ for (const [key, value] of params.entries()) {
63
+ inlineConfig[key] = value
64
+ providedParams.add(key)
65
+ }
66
+ }
67
+
68
+ return { name, version, inlineConfig, providedParams }
69
+ }
70
+
71
+ /**
72
+ * Read and validate lpm.config.json from an extracted package directory.
73
+ *
74
+ * @param {string} extractDir - Path to extracted tarball directory
75
+ * @returns {import('./lpm-config.js').LpmConfig | null} Parsed config or null if not found
76
+ */
77
+ export function readLpmConfig(extractDir) {
78
+ const configPath = path.join(extractDir, "lpm.config.json")
79
+
80
+ if (!fs.existsSync(configPath)) {
81
+ return null
82
+ }
83
+
84
+ const stat = fs.statSync(configPath)
85
+ if (stat.size > MAX_CONFIG_FILE_SIZE) {
86
+ throw new Error(
87
+ `lpm.config.json exceeds maximum size of ${MAX_CONFIG_FILE_SIZE / 1024 / 1024}MB`,
88
+ )
89
+ }
90
+
91
+ const raw = fs.readFileSync(configPath, "utf-8")
92
+ const config = JSON.parse(raw)
93
+
94
+ const errors = validateLpmConfig(config)
95
+ if (errors.length > 0) {
96
+ throw new Error(`Invalid lpm.config.json:\n ${errors.join("\n ")}`)
97
+ }
98
+
99
+ return config
100
+ }
101
+
102
+ /**
103
+ * Validate a parsed lpm.config.json object.
104
+ *
105
+ * @param {object} config - Parsed lpm.config.json
106
+ * @returns {string[]} Array of error messages (empty if valid)
107
+ */
108
+ export function validateLpmConfig(config) {
109
+ const errors = []
110
+
111
+ // Validate file rules
112
+ if (config.files) {
113
+ if (!Array.isArray(config.files)) {
114
+ errors.push("'files' must be an array")
115
+ } else {
116
+ if (config.files.length > MAX_FILE_RULES) {
117
+ errors.push(
118
+ `Too many file rules (${config.files.length}). Maximum is ${MAX_FILE_RULES}.`,
119
+ )
120
+ }
121
+
122
+ for (let i = 0; i < config.files.length; i++) {
123
+ const rule = config.files[i]
124
+
125
+ if (rule.src) {
126
+ const normalizedSrc = rule.src.replace(/\\/g, "/")
127
+ if (normalizedSrc.includes("..")) {
128
+ errors.push(
129
+ `files[${i}].src contains path traversal: "${rule.src}"`,
130
+ )
131
+ }
132
+ if (normalizedSrc.startsWith("/")) {
133
+ errors.push(
134
+ `files[${i}].src must be a relative path: "${rule.src}"`,
135
+ )
136
+ }
137
+ }
138
+
139
+ if (rule.dest) {
140
+ const normalizedDest = rule.dest.replace(/\\/g, "/")
141
+ if (normalizedDest.includes("..")) {
142
+ errors.push(
143
+ `files[${i}].dest contains path traversal: "${rule.dest}"`,
144
+ )
145
+ }
146
+ if (normalizedDest.startsWith("/")) {
147
+ errors.push(
148
+ `files[${i}].dest must be a relative path: "${rule.dest}"`,
149
+ )
150
+ }
151
+ }
152
+
153
+ if (
154
+ rule.include &&
155
+ !["always", "never", "when"].includes(rule.include)
156
+ ) {
157
+ errors.push(
158
+ `files[${i}].include must be "always", "never", or "when". Got: "${rule.include}"`,
159
+ )
160
+ }
161
+
162
+ if (rule.include === "when" && !rule.condition) {
163
+ errors.push(`files[${i}] has include "when" but no condition object`)
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ // Validate configSchema
170
+ if (config.configSchema) {
171
+ if (
172
+ typeof config.configSchema !== "object" ||
173
+ Array.isArray(config.configSchema)
174
+ ) {
175
+ errors.push("'configSchema' must be an object")
176
+ } else {
177
+ for (const [key, entry] of Object.entries(config.configSchema)) {
178
+ if (!entry.type) {
179
+ errors.push(`configSchema.${key} is missing 'type'`)
180
+ } else if (!["select", "boolean"].includes(entry.type)) {
181
+ errors.push(
182
+ `configSchema.${key}.type must be "select" or "boolean". Got: "${entry.type}"`,
183
+ )
184
+ }
185
+
186
+ if (entry.type === "select") {
187
+ if (
188
+ !entry.options ||
189
+ !Array.isArray(entry.options) ||
190
+ entry.options.length === 0
191
+ ) {
192
+ errors.push(
193
+ `configSchema.${key} is type "select" but has no options`,
194
+ )
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ // Validate importAlias
202
+ if (config.importAlias !== undefined) {
203
+ if (typeof config.importAlias !== "string") {
204
+ errors.push('\'importAlias\' must be a string (e.g., "@/", "~/")')
205
+ } else if (!config.importAlias.endsWith("/")) {
206
+ errors.push(
207
+ '\'importAlias\' must end with "/" (e.g., "@/", "~/", "@src/")',
208
+ )
209
+ }
210
+ }
211
+
212
+ return errors
213
+ }
214
+
215
+ /**
216
+ * Evaluate whether a file rule should be included based on config and provided params.
217
+ *
218
+ * Implements "include all by default": if a condition key was NOT explicitly
219
+ * provided by the user, the file is included regardless of its condition value.
220
+ *
221
+ * @param {object} fileRule - File rule from lpm.config.json
222
+ * @param {Record<string, string>} mergedConfig - Merged configuration values
223
+ * @param {Set<string>} providedParams - Set of parameter keys explicitly provided by the user
224
+ * @returns {boolean} Whether the file should be included
225
+ */
226
+ export function evaluateCondition(fileRule, mergedConfig, providedParams) {
227
+ if (!fileRule.include || fileRule.include === "always") return true
228
+ if (fileRule.include === "never") return false
229
+
230
+ if (fileRule.include === "when" && fileRule.condition) {
231
+ // All condition entries must match (AND logic)
232
+ for (const [conditionKey, conditionValue] of Object.entries(
233
+ fileRule.condition,
234
+ )) {
235
+ // "Include all by default": if param was NOT provided, skip this check (include the file)
236
+ if (!providedParams.has(conditionKey)) {
237
+ continue
238
+ }
239
+
240
+ const configValue = mergedConfig[conditionKey]
241
+
242
+ // Handle comma-separated multi-select values
243
+ if (typeof configValue === "string" && configValue.includes(",")) {
244
+ const selectedValues = configValue.split(",").map(v => v.trim())
245
+ if (!selectedValues.includes(String(conditionValue))) {
246
+ return false
247
+ }
248
+ } else {
249
+ // Single value or boolean comparison
250
+ if (String(configValue) !== String(conditionValue)) {
251
+ return false
252
+ }
253
+ }
254
+ }
255
+
256
+ return true
257
+ }
258
+
259
+ // Default: include
260
+ return true
261
+ }
262
+
263
+ /**
264
+ * Filter file rules based on merged config and provided params.
265
+ *
266
+ * @param {object[]} files - Array of file rules from lpm.config.json
267
+ * @param {Record<string, string>} mergedConfig - Merged configuration values
268
+ * @param {Set<string>} providedParams - Set of parameter keys explicitly provided
269
+ * @returns {object[]} Filtered file rules that should be included
270
+ */
271
+ export function filterFiles(files, mergedConfig, providedParams) {
272
+ return files.filter(fileRule =>
273
+ evaluateCondition(fileRule, mergedConfig, providedParams),
274
+ )
275
+ }
276
+
277
+ /**
278
+ * Resolve conditional dependencies based on config choices.
279
+ *
280
+ * @param {Record<string, Record<string, string[]>>} depConfig - Dependencies config from lpm.config.json
281
+ * @param {Record<string, string>} mergedConfig - Merged configuration values
282
+ * @returns {{ npm: string[], lpm: string[] }} Separated npm and LPM dependencies
283
+ */
284
+ export function resolveConditionalDependencies(depConfig, mergedConfig) {
285
+ const allDeps = new Set()
286
+
287
+ for (const [configKey, depMap] of Object.entries(depConfig)) {
288
+ const selectedValue = mergedConfig[configKey]
289
+ if (!selectedValue) continue
290
+
291
+ // Handle comma-separated values
292
+ const values =
293
+ typeof selectedValue === "string" && selectedValue.includes(",")
294
+ ? selectedValue.split(",").map(v => v.trim())
295
+ : [selectedValue]
296
+
297
+ for (const value of values) {
298
+ const deps = depMap[value]
299
+ if (Array.isArray(deps)) {
300
+ for (const dep of deps) {
301
+ allDeps.add(dep)
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ const npm = []
308
+ const lpm = []
309
+
310
+ for (const dep of allDeps) {
311
+ if (dep.startsWith("@lpm.dev/")) {
312
+ lpm.push(dep)
313
+ } else {
314
+ npm.push(dep)
315
+ }
316
+ }
317
+
318
+ return { npm, lpm }
319
+ }
320
+
321
+ /**
322
+ * Expand glob-like src patterns to actual file paths.
323
+ *
324
+ * Supports simple patterns:
325
+ * - Exact paths: "lib/utils.js"
326
+ * - Directory wildcards: "components/dialog/**"
327
+ *
328
+ * @param {string} srcPattern - Source pattern from file rule
329
+ * @param {string} extractDir - Extracted tarball directory
330
+ * @returns {string[]} Array of matching file paths (relative to extractDir)
331
+ */
332
+ export function expandSrcGlob(srcPattern, extractDir) {
333
+ // If pattern doesn't contain *, it's an exact path
334
+ if (!srcPattern.includes("*")) {
335
+ const fullPath = path.join(extractDir, srcPattern)
336
+ if (fs.existsSync(fullPath)) {
337
+ return [srcPattern]
338
+ }
339
+ return []
340
+ }
341
+
342
+ // Handle ** (directory wildcard)
343
+ if (srcPattern.endsWith("/**")) {
344
+ const baseDir = srcPattern.slice(0, -3) // Remove /**
345
+ const fullBaseDir = path.join(extractDir, baseDir)
346
+
347
+ if (
348
+ !fs.existsSync(fullBaseDir) ||
349
+ !fs.statSync(fullBaseDir).isDirectory()
350
+ ) {
351
+ return []
352
+ }
353
+
354
+ const results = []
355
+ const collectFiles = (dir, relativeTo) => {
356
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
357
+ for (const entry of entries) {
358
+ const fullEntryPath = path.join(dir, entry.name)
359
+ const relPath = path.relative(extractDir, fullEntryPath)
360
+
361
+ if (entry.isDirectory()) {
362
+ collectFiles(fullEntryPath, relativeTo)
363
+ } else {
364
+ results.push(relPath)
365
+ }
366
+ }
367
+ }
368
+
369
+ collectFiles(fullBaseDir, extractDir)
370
+ return results
371
+ }
372
+
373
+ // Handle dir/*.ext patterns (single-directory wildcard)
374
+ const lastSlash = srcPattern.lastIndexOf("/")
375
+ const dirPart = lastSlash >= 0 ? srcPattern.slice(0, lastSlash) : "."
376
+ const filePart = lastSlash >= 0 ? srcPattern.slice(lastSlash + 1) : srcPattern
377
+
378
+ if (filePart.includes("*")) {
379
+ const fullDir = path.join(extractDir, dirPart)
380
+ if (!fs.existsSync(fullDir) || !fs.statSync(fullDir).isDirectory()) {
381
+ return []
382
+ }
383
+
384
+ // Convert glob pattern to regex: *.mdc → /^.*\.mdc$/
385
+ const escaped = filePart
386
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
387
+ .replace(/\*/g, ".*")
388
+ const regex = new RegExp(`^${escaped}$`)
389
+
390
+ const entries = fs.readdirSync(fullDir, { withFileTypes: true })
391
+ const results = []
392
+ for (const entry of entries) {
393
+ if (entry.isFile() && regex.test(entry.name)) {
394
+ results.push(
395
+ dirPart === "." ? entry.name : path.join(dirPart, entry.name),
396
+ )
397
+ }
398
+ }
399
+ return results
400
+ }
401
+
402
+ // For other patterns, treat as exact path
403
+ const fullPath = path.join(extractDir, srcPattern)
404
+ if (fs.existsSync(fullPath)) {
405
+ return [srcPattern]
406
+ }
407
+ return []
408
+ }
@@ -0,0 +1,152 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+
4
+ /**
5
+ * Detect the consumer project type.
6
+ * Returns a framework string for JS projects, or a Swift project type.
7
+ * Priority: Package.swift → .xcodeproj → .xcworkspace → JS frameworks → unknown
8
+ */
9
+ export function detectFramework() {
10
+ const cwd = process.cwd()
11
+
12
+ // Check for Swift projects first
13
+ if (fs.existsSync(path.join(cwd, "Package.swift"))) return "swift-spm"
14
+
15
+ // Check for Xcode projects (without Package.swift = app project)
16
+ const entries = fs.readdirSync(cwd)
17
+ if (entries.some(e => e.endsWith(".xcodeproj"))) return "swift-xcode"
18
+ if (entries.some(e => e.endsWith(".xcworkspace"))) return "swift-xcode"
19
+
20
+ // JS frameworks
21
+ const pkgPath = path.join(cwd, "package.json")
22
+ if (!fs.existsSync(pkgPath)) return "unknown"
23
+
24
+ try {
25
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
26
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
27
+
28
+ if (deps.next) {
29
+ if (fs.existsSync(path.join(cwd, "app"))) return "next-app"
30
+ return "next-pages"
31
+ }
32
+ if (deps["@remix-run/react"]) return "remix"
33
+ if (deps.vite) return "vite"
34
+ } catch (_e) {
35
+ return "unknown"
36
+ }
37
+ return "unknown"
38
+ }
39
+
40
+ /**
41
+ * Check if the detected framework is a Swift project.
42
+ * @param {string} framework
43
+ * @returns {boolean}
44
+ */
45
+ export function isSwiftProject(framework) {
46
+ return framework === "swift-spm" || framework === "swift-xcode"
47
+ }
48
+
49
+ export function getDefaultPath(framework, swiftTarget) {
50
+ switch (framework) {
51
+ case "swift-spm":
52
+ return swiftTarget ? `Sources/${swiftTarget}` : "Sources"
53
+ case "swift-xcode":
54
+ // Target-specific path is set in add.js via ensureXcodeLocalPackage().
55
+ // This default is a fallback only used when no target name is known.
56
+ return swiftTarget
57
+ ? `Packages/LPMComponents/Sources/${swiftTarget}`
58
+ : "Packages/LPMComponents/Sources/LPMComponents"
59
+ case "next-app":
60
+ if (fs.existsSync(path.join(process.cwd(), "components"))) {
61
+ return "components"
62
+ }
63
+ return "src/components"
64
+ case "next-pages":
65
+ case "vite":
66
+ case "remix":
67
+ return "src/components"
68
+ default:
69
+ return "components"
70
+ }
71
+ }
72
+
73
+ export function getProjectAliases() {
74
+ const cwd = process.cwd()
75
+ const tsConfigPath = path.join(cwd, "tsconfig.json")
76
+ const jsConfigPath = path.join(cwd, "jsconfig.json")
77
+
78
+ let configPath
79
+ if (fs.existsSync(tsConfigPath)) configPath = tsConfigPath
80
+ else if (fs.existsSync(jsConfigPath)) configPath = jsConfigPath
81
+
82
+ if (!configPath) return {}
83
+
84
+ try {
85
+ // Simple JSON parse (might fail with comments)
86
+ const content = fs.readFileSync(configPath, "utf-8")
87
+ // Basic comment stripping
88
+ const jsonContent = content.replace(
89
+ /\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm,
90
+ "$1",
91
+ )
92
+ const config = JSON.parse(jsonContent)
93
+ return config.compilerOptions?.paths || {}
94
+ } catch (_e) {
95
+ return {}
96
+ }
97
+ }
98
+
99
+ export function getUserImportPrefix() {
100
+ const aliases = getProjectAliases()
101
+ // Look for an alias that points to ./src or ./
102
+ for (const [alias, paths] of Object.entries(aliases)) {
103
+ if (
104
+ Array.isArray(paths) &&
105
+ paths.some(p => p.startsWith("./src") || p.startsWith("src"))
106
+ ) {
107
+ return alias.replace("/*", "")
108
+ }
109
+ }
110
+ return "@" // Default fallback
111
+ }
112
+
113
+ /**
114
+ * Resolve the import alias that maps to a given directory.
115
+ *
116
+ * Given a target directory like "src/components/design-system" and aliases
117
+ * like { "@/*": ["./src/*"] }, returns "@/components/design-system".
118
+ *
119
+ * @param {string} targetDirRelative - Target directory relative to project root
120
+ * @param {Record<string, string[]>} aliases - Parsed tsconfig/jsconfig paths
121
+ * @returns {string | null} The resolved alias path, or null if no alias covers this directory
122
+ */
123
+ export function resolveAliasForDirectory(targetDirRelative, aliases) {
124
+ const normalized = targetDirRelative
125
+ .replace(/\\/g, "/")
126
+ .replace(/^\.\//, "")
127
+ .replace(/\/$/, "")
128
+
129
+ for (const [aliasPattern, aliasPaths] of Object.entries(aliases)) {
130
+ if (!aliasPattern.endsWith("/*")) continue
131
+ if (!Array.isArray(aliasPaths)) continue
132
+
133
+ const aliasPrefix = aliasPattern.slice(0, -2) // "@/*" → "@"
134
+
135
+ for (const aliasPath of aliasPaths) {
136
+ const mappedDir = aliasPath
137
+ .replace(/^\.\//, "")
138
+ .replace(/\/\*$/, "")
139
+ .replace(/\/$/, "")
140
+
141
+ if (normalized.startsWith(`${mappedDir}/`)) {
142
+ const remainder = normalized.slice(mappedDir.length + 1)
143
+ return `${aliasPrefix}/${remainder}`
144
+ }
145
+ if (normalized === mappedDir) {
146
+ return aliasPrefix
147
+ }
148
+ }
149
+ }
150
+
151
+ return null
152
+ }