@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,1217 @@
1
+ import { exec } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { promisify } from "node:util"
6
+ import * as p from "@clack/prompts"
7
+ import chalk from "chalk"
8
+ import * as Diff from "diff"
9
+ import ora from "ora"
10
+ import * as tar from "tar"
11
+ import { getRegistryUrl, getToken } from "../config.js"
12
+ import { rewriteImports } from "../import-rewriter.js"
13
+ import {
14
+ getDefaultTarget,
15
+ getHandler,
16
+ hasCustomHandler,
17
+ } from "../install-targets.js"
18
+ import { verifyIntegrity } from "../integrity.js"
19
+ import {
20
+ expandSrcGlob,
21
+ filterFiles,
22
+ parseLpmPackageReference,
23
+ readLpmConfig,
24
+ resolveConditionalDependencies,
25
+ } from "../lpm-config.js"
26
+ import { promptForMissingConfig } from "../lpm-config-prompts.js"
27
+ import {
28
+ detectFramework,
29
+ getDefaultPath,
30
+ getProjectAliases,
31
+ getUserImportPrefix,
32
+ isSwiftProject,
33
+ resolveAliasForDirectory,
34
+ } from "../project-utils.js"
35
+ import { validateComponentPath, validateTarballPaths } from "../safe-path.js"
36
+ import {
37
+ autoLinkXcodePackage,
38
+ ensureXcodeLocalPackage,
39
+ getSpmTargets,
40
+ parsePlatforms,
41
+ parseSwiftTargetName,
42
+ printSwiftDependencyInstructions,
43
+ printXcodeSetupInstructions,
44
+ resolveTargetName,
45
+ } from "../swift-project.js"
46
+ import { skillsInstall } from "./skills.js"
47
+
48
+ const execAsync = promisify(exec)
49
+
50
+ export async function add(pkgName, options) {
51
+ // --json implies --yes (no interactive prompts)
52
+ if (options.json) {
53
+ options.yes = true
54
+ }
55
+
56
+ // Collect structured output for --json mode
57
+ const jsonOutput = {
58
+ success: false,
59
+ package: {},
60
+ files: [],
61
+ dependencies: { npm: [], lpm: [] },
62
+ config: {},
63
+ installPath: "",
64
+ alias: null,
65
+ warnings: [],
66
+ errors: [],
67
+ }
68
+ if (options.dryRun) {
69
+ jsonOutput.dryRun = true
70
+ }
71
+
72
+ const spinner = ora().start()
73
+
74
+ try {
75
+ // 1. Auth Check
76
+ const token = await getToken()
77
+ if (!token) {
78
+ spinner.fail("Not logged in. Run `lpm login` first.")
79
+ return
80
+ }
81
+
82
+ // 2. Resolve Package Name, Version & URL Config Params
83
+ const { name, version, inlineConfig, providedParams } =
84
+ parseLpmPackageReference(pkgName)
85
+
86
+ spinner.text = `Resolving ${chalk.cyan(name)}@${chalk.green(version)}...`
87
+
88
+ // 3. Fetch Metadata to get Tarball URL
89
+ const baseRegistryUrl = getRegistryUrl()
90
+ const registryUrl = baseRegistryUrl.endsWith("/api/registry")
91
+ ? baseRegistryUrl
92
+ : `${baseRegistryUrl}/api/registry`
93
+ const encodedName = name.replace("/", "%2f")
94
+
95
+ let meta
96
+ try {
97
+ const res = await fetch(`${registryUrl}/${encodedName}`, {
98
+ headers: { Authorization: `Bearer ${token}` },
99
+ })
100
+
101
+ if (!res.ok) {
102
+ const error = new Error(res.statusText)
103
+ error.response = { status: res.status }
104
+ throw error
105
+ }
106
+
107
+ meta = await res.json()
108
+ } catch (err) {
109
+ if (err.response?.status === 404) {
110
+ throw new Error(`Package '${name}' not found.`)
111
+ }
112
+ if (err.response?.status === 401 || err.response?.status === 403) {
113
+ throw new Error(
114
+ `Unauthorized access to '${name}'. Check your permissions.`,
115
+ )
116
+ }
117
+ throw err
118
+ }
119
+
120
+ // Resolve version
121
+ const distTags = meta["dist-tags"] || {}
122
+ const targetVersion = version === "latest" ? distTags.latest : version
123
+
124
+ if (!targetVersion || !meta.versions[targetVersion]) {
125
+ throw new Error(`Version '${version}' not found for package '${name}'.`)
126
+ }
127
+
128
+ const versionData = meta.versions[targetVersion]
129
+ const tarballUrl = versionData.dist?.tarball
130
+ const expectedIntegrity = versionData.dist?.integrity
131
+
132
+ if (!tarballUrl) {
133
+ throw new Error("No tarball URL found in package metadata.")
134
+ }
135
+
136
+ // 4. Download Tarball
137
+ spinner.text = `Downloading ${chalk.cyan(name)}...`
138
+
139
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lpm-add-"))
140
+ const tarballPath = path.join(tmpDir, "package.tgz")
141
+
142
+ const response = await fetch(tarballUrl, {
143
+ headers: { Authorization: `Bearer ${token}` },
144
+ })
145
+
146
+ if (!response.ok) {
147
+ throw new Error(`Failed to download tarball: ${response.statusText}`)
148
+ }
149
+
150
+ const tarballBuffer = Buffer.from(await response.arrayBuffer())
151
+
152
+ // 4.1 Verify Tarball Integrity
153
+ if (expectedIntegrity) {
154
+ spinner.text = "Verifying package integrity..."
155
+ const integrityResult = verifyIntegrity(tarballBuffer, expectedIntegrity)
156
+
157
+ if (!integrityResult.valid) {
158
+ throw new Error(
159
+ `${integrityResult.error}\nExpected: ${expectedIntegrity}\nActual: ${integrityResult.actual || "unknown"}`,
160
+ )
161
+ }
162
+ }
163
+
164
+ fs.writeFileSync(tarballPath, tarballBuffer)
165
+
166
+ // 5. Extract Tarball
167
+ spinner.text = "Extracting..."
168
+ const extractDir = path.join(tmpDir, "extracted")
169
+ fs.mkdirSync(extractDir)
170
+
171
+ const tarFiles = []
172
+ await tar.t({
173
+ file: tarballPath,
174
+ onReadEntry: entry => {
175
+ tarFiles.push(entry.path)
176
+ },
177
+ })
178
+
179
+ const pathValidation = validateTarballPaths(extractDir, tarFiles)
180
+ if (!pathValidation.valid) {
181
+ throw new Error(
182
+ `Package contains unsafe paths: ${pathValidation.invalidPaths.join(", ")}`,
183
+ )
184
+ }
185
+
186
+ await tar.x({
187
+ file: tarballPath,
188
+ cwd: extractDir,
189
+ strip: 1,
190
+ })
191
+
192
+ // 5.1 Read lpm.config.json (if present)
193
+ spinner.text = "Reading package configuration..."
194
+ const lpmConfig = readLpmConfig(extractDir)
195
+
196
+ // 5.2 Check for type-specific install handler (e.g., MCP servers)
197
+ const packageType = lpmConfig?.type
198
+ if (packageType && hasCustomHandler(packageType)) {
199
+ spinner.stop()
200
+ const handler = getHandler(packageType)
201
+ const result = await handler.install({
202
+ name,
203
+ version: targetVersion,
204
+ lpmConfig,
205
+ extractDir,
206
+ options,
207
+ })
208
+
209
+ // Cleanup temp files
210
+ fs.rmSync(tmpDir, { recursive: true, force: true })
211
+
212
+ if (result.success) {
213
+ console.log(chalk.green(`\n ${result.message}`))
214
+ } else {
215
+ console.log(chalk.red(`\n ${result.message}`))
216
+ }
217
+ return
218
+ }
219
+
220
+ // Track config-based file list (null = use legacy flow)
221
+ let configuredFiles = null
222
+ let mergedConfig = {}
223
+
224
+ if (lpmConfig?.configSchema) {
225
+ // Prompt for missing config params (unless --yes)
226
+ let interactiveAnswers = {}
227
+ if (!options.yes) {
228
+ spinner.stop()
229
+ interactiveAnswers = await promptForMissingConfig(
230
+ lpmConfig.configSchema,
231
+ inlineConfig,
232
+ lpmConfig.defaultConfig || {},
233
+ )
234
+ spinner.start()
235
+ }
236
+
237
+ // Interactive answers count as "provided" — the user explicitly chose
238
+ for (const key of Object.keys(interactiveAnswers)) {
239
+ providedParams.add(key)
240
+ }
241
+
242
+ // Merge config: defaults < interactive < inline (inline wins)
243
+ mergedConfig = {
244
+ ...(lpmConfig.defaultConfig || {}),
245
+ ...interactiveAnswers,
246
+ ...inlineConfig,
247
+ }
248
+
249
+ // For --yes with required fields: add them to providedParams
250
+ // so they use the default value instead of "include all"
251
+ if (options.yes && lpmConfig.configSchema) {
252
+ for (const [key, schema] of Object.entries(lpmConfig.configSchema)) {
253
+ if (schema.required && !providedParams.has(key)) {
254
+ providedParams.add(key)
255
+ }
256
+ }
257
+ }
258
+
259
+ // Filter files based on conditions and providedParams
260
+ if (lpmConfig.files) {
261
+ configuredFiles = filterFiles(
262
+ lpmConfig.files,
263
+ mergedConfig,
264
+ providedParams,
265
+ )
266
+ }
267
+ } else if (lpmConfig?.files) {
268
+ // lpm.config.json with files but no configSchema (simple conditional includes)
269
+ mergedConfig = { ...(lpmConfig.defaultConfig || {}), ...inlineConfig }
270
+
271
+ configuredFiles = filterFiles(
272
+ lpmConfig.files,
273
+ mergedConfig,
274
+ providedParams,
275
+ )
276
+ }
277
+
278
+ // 6. Determine Target Path
279
+ let targetDir
280
+ const projectRoot = process.cwd()
281
+ const framework = detectFramework()
282
+ const isSwift = isSwiftProject(framework)
283
+ let xcodeSetupNeeded = false
284
+ let swiftModuleName = null // The import name for Swift packages
285
+
286
+ // Check for type-specific default target (cursor-rules, github-action, etc.)
287
+ const typeDefaultTarget = packageType ? getDefaultTarget(packageType) : null
288
+
289
+ if (options.path) {
290
+ const pathResult = validateComponentPath(projectRoot, options.path)
291
+ if (!pathResult.valid) {
292
+ throw new Error(pathResult.error)
293
+ }
294
+ targetDir = pathResult.resolvedPath
295
+ } else if (typeDefaultTarget) {
296
+ // Type-aware default: skip the interactive path prompt.
297
+ const hasDestPaths = configuredFiles?.some(f => f.dest)
298
+ const relativeDefault = hasDestPaths ? "." : typeDefaultTarget
299
+
300
+ const pathResult = validateComponentPath(projectRoot, relativeDefault)
301
+ if (!pathResult.valid) {
302
+ throw new Error(pathResult.error)
303
+ }
304
+ targetDir = pathResult.resolvedPath
305
+ } else if (framework === "swift-xcode") {
306
+ // Xcode project: scaffold local SPM package with per-package targets
307
+ spinner.stop()
308
+
309
+ // Parse the source Package.swift to get the module name
310
+ const srcPackageSwift = path.join(extractDir, "Package.swift")
311
+ const originalTarget = parseSwiftTargetName(srcPackageSwift)
312
+ const srcPlatforms = parsePlatforms(srcPackageSwift)
313
+
314
+ // Resolve target name (auto-scope on conflict)
315
+ const manifestPath = path.join(
316
+ projectRoot,
317
+ "Packages",
318
+ "LPMComponents",
319
+ "Package.swift",
320
+ )
321
+ let resolvedTarget = originalTarget
322
+ let wasScoped = false
323
+
324
+ if (originalTarget) {
325
+ const resolved = resolveTargetName(originalTarget, name, manifestPath)
326
+ resolvedTarget = resolved.targetName
327
+ wasScoped = resolved.wasScoped
328
+ }
329
+
330
+ const {
331
+ created,
332
+ installPath,
333
+ targetName: finalTarget,
334
+ } = ensureXcodeLocalPackage(resolvedTarget, srcPlatforms)
335
+ xcodeSetupNeeded = created
336
+ targetDir = installPath
337
+ swiftModuleName = finalTarget
338
+
339
+ if (wasScoped) {
340
+ console.log(
341
+ chalk.yellow(` Target name "${originalTarget}" already exists.`),
342
+ )
343
+ console.log(
344
+ chalk.yellow(
345
+ ` Scoped to "${resolvedTarget}" — use: import ${resolvedTarget}`,
346
+ ),
347
+ )
348
+ }
349
+
350
+ // Override configuredFiles for swift-xcode: copy only .swift source files
351
+ // directly into the target directory, ignoring lpm.config.json dest mappings
352
+ // which would create nested Sources/ paths.
353
+ const swiftSourceDir = originalTarget
354
+ ? path.join(extractDir, "Sources", originalTarget)
355
+ : path.join(extractDir, "Sources")
356
+
357
+ if (fs.existsSync(swiftSourceDir)) {
358
+ const collectSwiftFiles = (dir, base) => {
359
+ const results = []
360
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
361
+ const rel = base ? `${base}/${entry.name}` : entry.name
362
+ if (entry.isDirectory()) {
363
+ results.push(
364
+ ...collectSwiftFiles(path.join(dir, entry.name), rel),
365
+ )
366
+ } else if (entry.name.endsWith(".swift")) {
367
+ results.push({
368
+ src: path.join("Sources", originalTarget || "", rel),
369
+ dest: rel,
370
+ })
371
+ }
372
+ }
373
+ return results
374
+ }
375
+ configuredFiles = collectSwiftFiles(swiftSourceDir, "")
376
+ }
377
+
378
+ spinner.start()
379
+ } else if (framework === "swift-spm") {
380
+ // SPM package: detect targets and let user pick
381
+ spinner.stop()
382
+ const targets = await getSpmTargets()
383
+ let swiftTarget = null
384
+
385
+ if (options.target) {
386
+ // Explicit --target flag
387
+ if (targets.length > 0 && !targets.includes(options.target)) {
388
+ throw new Error(
389
+ `SPM target '${options.target}' not found. Available targets: ${targets.join(", ")}`,
390
+ )
391
+ }
392
+ swiftTarget = options.target
393
+ } else if (targets.length === 1) {
394
+ swiftTarget = targets[0]
395
+ } else if (targets.length > 1) {
396
+ if (options.yes) {
397
+ // --yes: auto-select first non-test target
398
+ swiftTarget = targets[0]
399
+ } else {
400
+ const selected = await p.select({
401
+ message: "Which target should receive this package?",
402
+ options: targets.map(t => ({ value: t, label: t })),
403
+ })
404
+
405
+ if (p.isCancel(selected)) {
406
+ p.cancel("Operation cancelled.")
407
+ fs.rmSync(tmpDir, { recursive: true, force: true })
408
+ process.exit(0)
409
+ }
410
+ swiftTarget = selected
411
+ }
412
+ }
413
+
414
+ const defaultPath = getDefaultPath(framework, swiftTarget)
415
+
416
+ if (options.yes) {
417
+ // --yes: use default path without prompting
418
+ const pathResult = validateComponentPath(projectRoot, defaultPath)
419
+ if (!pathResult.valid) {
420
+ throw new Error(pathResult.error)
421
+ }
422
+ targetDir = pathResult.resolvedPath
423
+ } else {
424
+ const installPath = await p.text({
425
+ message: "Where would you like to install this component?",
426
+ placeholder: defaultPath,
427
+ })
428
+
429
+ if (p.isCancel(installPath)) {
430
+ p.cancel("Operation cancelled.")
431
+ fs.rmSync(tmpDir, { recursive: true, force: true })
432
+ process.exit(0)
433
+ }
434
+
435
+ const pathResult = validateComponentPath(
436
+ projectRoot,
437
+ installPath || defaultPath,
438
+ )
439
+ if (!pathResult.valid) {
440
+ throw new Error(pathResult.error)
441
+ }
442
+ targetDir = pathResult.resolvedPath
443
+ }
444
+ spinner.start()
445
+ } else {
446
+ const defaultPath = getDefaultPath(framework)
447
+
448
+ if (options.yes) {
449
+ // --yes: use framework-detected default path without prompting
450
+ const pathResult = validateComponentPath(projectRoot, defaultPath)
451
+ if (!pathResult.valid) {
452
+ throw new Error(pathResult.error)
453
+ }
454
+ targetDir = pathResult.resolvedPath
455
+ } else {
456
+ spinner.stop()
457
+ const installPath = await p.text({
458
+ message: "Where would you like to install this component?",
459
+ placeholder: defaultPath,
460
+ })
461
+
462
+ if (p.isCancel(installPath)) {
463
+ p.cancel("Operation cancelled.")
464
+ fs.rmSync(tmpDir, { recursive: true, force: true })
465
+ process.exit(0)
466
+ }
467
+
468
+ const pathResult = validateComponentPath(
469
+ projectRoot,
470
+ installPath || defaultPath,
471
+ )
472
+ if (!pathResult.valid) {
473
+ throw new Error(pathResult.error)
474
+ }
475
+ targetDir = pathResult.resolvedPath
476
+ spinner.start()
477
+ }
478
+ }
479
+
480
+ // 7. Determine import alias for rewriting (skip for Swift)
481
+ let buyerAlias = null
482
+ const authorAlias = lpmConfig?.importAlias || null
483
+
484
+ if (!typeDefaultTarget && !isSwift) {
485
+ if (options.alias) {
486
+ // Explicit --alias flag
487
+ buyerAlias = options.alias
488
+ } else {
489
+ const aliases = getProjectAliases()
490
+ const targetRelative = path
491
+ .relative(projectRoot, targetDir)
492
+ .replace(/\\/g, "/")
493
+ const detectedAlias = resolveAliasForDirectory(targetRelative, aliases)
494
+ // Build a sensible default: use tsconfig detection, or compose from alias prefix + install path
495
+ const aliasDefault =
496
+ detectedAlias || (targetRelative ? `@/${targetRelative}` : "")
497
+
498
+ if (!options.yes) {
499
+ spinner.stop()
500
+ const aliasAnswer = await p.text({
501
+ message:
502
+ "Import alias for this directory? (leave empty for relative imports)",
503
+ initialValue: aliasDefault,
504
+ })
505
+
506
+ if (p.isCancel(aliasAnswer)) {
507
+ p.cancel("Operation cancelled.")
508
+ fs.rmSync(tmpDir, { recursive: true, force: true })
509
+ process.exit(0)
510
+ }
511
+
512
+ if (aliasAnswer && aliasAnswer.trim() !== "") {
513
+ buyerAlias = aliasAnswer.trim()
514
+ }
515
+ spinner.start()
516
+ } else if (aliasDefault) {
517
+ buyerAlias = aliasDefault
518
+ }
519
+ }
520
+ }
521
+
522
+ // 8. Determine source (legacy flow or config-based)
523
+ const pkgJsonPath = path.join(extractDir, "package.json")
524
+ let pkgJson = {}
525
+ if (fs.existsSync(pkgJsonPath)) {
526
+ pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"))
527
+ }
528
+
529
+ let sourcePath = extractDir
530
+
531
+ if (configuredFiles) {
532
+ // Config-based flow: skip legacy source detection
533
+ // Files are already determined by configuredFiles
534
+ } else if (pkgJson.lpm?.source) {
535
+ sourcePath = path.join(extractDir, pkgJson.lpm.source)
536
+ } else {
537
+ if (options.yes) {
538
+ // --yes: default to copying everything
539
+ sourcePath = extractDir
540
+ } else {
541
+ spinner.stop()
542
+
543
+ const files = fs
544
+ .readdirSync(extractDir)
545
+ .filter(
546
+ f =>
547
+ ![
548
+ "package.json",
549
+ "node_modules",
550
+ ".git",
551
+ "lpm.config.json",
552
+ ].includes(f),
553
+ )
554
+
555
+ const selectedSource = await p.select({
556
+ message:
557
+ "No `lpm.source` found in package.json. What would you like to copy?",
558
+ options: [
559
+ { value: ".", label: "Copy everything (root)" },
560
+ ...files.map(f => ({ value: f, label: f })),
561
+ ],
562
+ })
563
+
564
+ if (p.isCancel(selectedSource)) {
565
+ p.cancel("Operation cancelled.")
566
+ fs.rmSync(tmpDir, { recursive: true, force: true })
567
+ process.exit(0)
568
+ }
569
+
570
+ sourcePath = path.join(extractDir, selectedSource)
571
+ spinner.start()
572
+ }
573
+ }
574
+
575
+ // Check source exists (for legacy flow)
576
+ if (!configuredFiles && !fs.existsSync(sourcePath)) {
577
+ spinner.fail(`Source path '${sourcePath}' does not exist.`)
578
+ fs.rmSync(tmpDir, { recursive: true, force: true })
579
+ return
580
+ }
581
+
582
+ // 9. Copy Files
583
+ spinner.text = `Installing to ${chalk.dim(targetDir)}...`
584
+ spinner.stop()
585
+
586
+ const userPrefix = getUserImportPrefix()
587
+
588
+ // Build file sets for smart import rewriting
589
+ const destFileSet = new Set()
590
+ const srcToDestMap = new Map()
591
+
592
+ if (configuredFiles) {
593
+ for (const fileRule of configuredFiles) {
594
+ const srcPaths = expandSrcGlob(fileRule.src, extractDir)
595
+ for (const relativeSrc of srcPaths) {
596
+ let destRel
597
+ if (fileRule.dest) {
598
+ if (fileRule.dest.endsWith("/")) {
599
+ destRel = path.join(fileRule.dest, path.basename(relativeSrc))
600
+ } else if (srcPaths.length > 1) {
601
+ const baseSrc = fileRule.src.replace(/\/\*\*$/, "")
602
+ const relFromBase = path.relative(
603
+ path.join(extractDir, baseSrc),
604
+ path.join(extractDir, relativeSrc),
605
+ )
606
+ destRel = path.join(fileRule.dest, relFromBase)
607
+ } else {
608
+ destRel = fileRule.dest
609
+ }
610
+ } else {
611
+ destRel = relativeSrc
612
+ }
613
+ const normalizedDest = destRel.replace(/\\/g, "/")
614
+ const normalizedSrc = relativeSrc.replace(/\\/g, "/")
615
+ destFileSet.add(normalizedDest)
616
+ srcToDestMap.set(normalizedSrc, normalizedDest)
617
+ }
618
+ }
619
+ } else if (sourcePath) {
620
+ // Legacy: walk source to build dest set
621
+ const buildDestSet = (srcDir, destBase) => {
622
+ if (!fs.existsSync(srcDir)) return
623
+ const stat = fs.statSync(srcDir)
624
+ if (stat.isDirectory()) {
625
+ for (const child of fs.readdirSync(srcDir)) {
626
+ buildDestSet(
627
+ path.join(srcDir, child),
628
+ destBase ? `${destBase}/${child}` : child,
629
+ )
630
+ }
631
+ } else {
632
+ const normalized = destBase.replace(/\\/g, "/")
633
+ destFileSet.add(normalized)
634
+ srcToDestMap.set(normalized, normalized)
635
+ }
636
+ }
637
+ const stat = fs.statSync(sourcePath)
638
+ if (stat.isFile()) {
639
+ const basename = path.basename(sourcePath)
640
+ destFileSet.add(basename)
641
+ srcToDestMap.set(basename, basename)
642
+ } else {
643
+ buildDestSet(sourcePath, "")
644
+ }
645
+ }
646
+
647
+ const useSmartRewrite = authorAlias || buyerAlias
648
+
649
+ // Populate JSON output metadata
650
+ jsonOutput.package = {
651
+ name: `@lpm.dev/${name.replace("@lpm.dev/", "")}`,
652
+ version: targetVersion,
653
+ ecosystem: lpmConfig?.ecosystem || (isSwift ? "swift" : "js"),
654
+ }
655
+ jsonOutput.installPath = targetDir
656
+ jsonOutput.alias = buyerAlias || null
657
+ if (Object.keys(mergedConfig).length > 0) {
658
+ jsonOutput.config = mergedConfig
659
+ }
660
+
661
+ // Build file list for JSON output and dry-run
662
+ const fileActions = []
663
+
664
+ // --dry-run: compute file list but skip writing
665
+ if (options.dryRun) {
666
+ if (configuredFiles) {
667
+ for (const fileRule of configuredFiles) {
668
+ const srcPaths = expandSrcGlob(fileRule.src, extractDir)
669
+ for (const relativeSrc of srcPaths) {
670
+ let destRelative
671
+ if (fileRule.dest) {
672
+ if (fileRule.dest.endsWith("/")) {
673
+ destRelative = path.join(
674
+ fileRule.dest,
675
+ path.basename(relativeSrc),
676
+ )
677
+ } else if (srcPaths.length > 1) {
678
+ const baseSrc = fileRule.src.replace(/\/\*\*$/, "")
679
+ const relFromBase = path.relative(
680
+ path.join(extractDir, baseSrc),
681
+ path.join(extractDir, relativeSrc),
682
+ )
683
+ destRelative = path.join(fileRule.dest, relFromBase)
684
+ } else {
685
+ destRelative = fileRule.dest
686
+ }
687
+ } else {
688
+ destRelative = relativeSrc
689
+ }
690
+ const destFile = path.join(targetDir, destRelative)
691
+ const exists = fs.existsSync(destFile)
692
+ const action = exists
693
+ ? options.force
694
+ ? "overwrite"
695
+ : "skip"
696
+ : "create"
697
+ fileActions.push({
698
+ src: relativeSrc,
699
+ dest: path.relative(projectRoot, destFile).replace(/\\/g, "/"),
700
+ action,
701
+ })
702
+ }
703
+ }
704
+ } else {
705
+ for (const destRel of destFileSet) {
706
+ const destFile = path.join(targetDir, destRel)
707
+ const exists = fs.existsSync(destFile)
708
+ const action = exists
709
+ ? options.force
710
+ ? "overwrite"
711
+ : "skip"
712
+ : "create"
713
+ fileActions.push({
714
+ src: destRel,
715
+ dest: path.relative(projectRoot, destFile).replace(/\\/g, "/"),
716
+ action,
717
+ })
718
+ }
719
+ }
720
+
721
+ jsonOutput.files = fileActions
722
+ jsonOutput.success = true
723
+
724
+ // Cleanup temp files
725
+ fs.rmSync(tmpDir, { recursive: true, force: true })
726
+
727
+ if (options.json) {
728
+ process.stdout.write(`${JSON.stringify(jsonOutput, null, 2)}\n`)
729
+ } else {
730
+ spinner.stop()
731
+ console.log(chalk.dim("\n Dry run — no files were written.\n"))
732
+ for (const f of fileActions) {
733
+ const icon =
734
+ f.action === "create"
735
+ ? chalk.green("+")
736
+ : f.action === "overwrite"
737
+ ? chalk.yellow("~")
738
+ : chalk.dim("-")
739
+ console.log(` ${icon} ${f.dest} (${f.action})`)
740
+ }
741
+ console.log("")
742
+ }
743
+ return
744
+ }
745
+
746
+ if (configuredFiles) {
747
+ // ---- Config-based file copy ----
748
+ for (const fileRule of configuredFiles) {
749
+ // Expand glob patterns in src
750
+ const srcPaths = expandSrcGlob(fileRule.src, extractDir)
751
+
752
+ for (const relativeSrc of srcPaths) {
753
+ const srcFile = path.join(extractDir, relativeSrc)
754
+
755
+ // Determine destination path
756
+ let destRelative
757
+ if (fileRule.dest) {
758
+ if (fileRule.dest.endsWith("/")) {
759
+ // Directory dest: preserve filename
760
+ const fileName = path.basename(relativeSrc)
761
+ destRelative = path.join(fileRule.dest, fileName)
762
+ } else if (srcPaths.length > 1) {
763
+ // Multiple src files mapped to a dest: use relative structure
764
+ const baseSrc = fileRule.src.replace(/\/\*\*$/, "")
765
+ const relFromBase = path.relative(
766
+ path.join(extractDir, baseSrc),
767
+ srcFile,
768
+ )
769
+ destRelative = path.join(fileRule.dest, relFromBase)
770
+ } else {
771
+ destRelative = fileRule.dest
772
+ }
773
+ } else {
774
+ destRelative = relativeSrc
775
+ }
776
+
777
+ const destFile = path.join(targetDir, destRelative)
778
+
779
+ // Read source content
780
+ let srcContent
781
+ try {
782
+ srcContent = fs.readFileSync(srcFile, "utf-8")
783
+
784
+ // Smart import rewriting
785
+ if (useSmartRewrite) {
786
+ srcContent = rewriteImports(srcContent, {
787
+ fileDestPath: destRelative.replace(/\\/g, "/"),
788
+ fileSrcPath: relativeSrc.replace(/\\/g, "/"),
789
+ destFileSet,
790
+ srcToDestMap,
791
+ authorAlias,
792
+ buyerAlias,
793
+ })
794
+ } else if (userPrefix !== "@") {
795
+ srcContent = srcContent.replace(
796
+ /from ['"]@\//g,
797
+ `from '${userPrefix}/`,
798
+ )
799
+ srcContent = srcContent.replace(
800
+ /import ['"]@\//g,
801
+ `import '${userPrefix}/`,
802
+ )
803
+ }
804
+ } catch (_e) {
805
+ // Binary file
806
+ }
807
+
808
+ const action = await smartCopyFile(
809
+ srcFile,
810
+ destFile,
811
+ srcContent,
812
+ options,
813
+ )
814
+ fileActions.push({
815
+ src: relativeSrc,
816
+ dest: path.relative(projectRoot, destFile).replace(/\\/g, "/"),
817
+ action: action || "created",
818
+ })
819
+ }
820
+ }
821
+ } else {
822
+ // ---- Legacy recursive copy ----
823
+ const smartCopy = async (src, dest) => {
824
+ const exists = fs.existsSync(src)
825
+ const stats = exists && fs.statSync(src)
826
+ const isDirectory = exists && stats.isDirectory()
827
+
828
+ if (isDirectory) {
829
+ if (!fs.existsSync(dest)) {
830
+ fs.mkdirSync(dest, { recursive: true })
831
+ }
832
+ const entries = fs.readdirSync(src)
833
+ for (const childItemName of entries) {
834
+ await smartCopy(
835
+ path.join(src, childItemName),
836
+ path.join(dest, childItemName),
837
+ )
838
+ }
839
+ } else {
840
+ let srcContent
841
+ try {
842
+ srcContent = fs.readFileSync(src, "utf-8")
843
+ if (useSmartRewrite) {
844
+ const destRel = path.relative(targetDir, dest).replace(/\\/g, "/")
845
+ srcContent = rewriteImports(srcContent, {
846
+ fileDestPath: destRel,
847
+ fileSrcPath: destRel,
848
+ destFileSet,
849
+ srcToDestMap,
850
+ authorAlias,
851
+ buyerAlias,
852
+ })
853
+ } else if (userPrefix !== "@") {
854
+ srcContent = srcContent.replace(
855
+ /from ['"]@\//g,
856
+ `from '${userPrefix}/`,
857
+ )
858
+ srcContent = srcContent.replace(
859
+ /import ['"]@\//g,
860
+ `import '${userPrefix}/`,
861
+ )
862
+ }
863
+ } catch (_e) {
864
+ // Binary
865
+ }
866
+
867
+ const action = await smartCopyFile(src, dest, srcContent, options)
868
+ const destRel = path.relative(projectRoot, dest).replace(/\\/g, "/")
869
+ const srcRel = path.relative(sourcePath, src).replace(/\\/g, "/")
870
+ fileActions.push({
871
+ src: srcRel,
872
+ dest: destRel,
873
+ action: action || "created",
874
+ })
875
+ }
876
+ }
877
+
878
+ const stat = fs.statSync(sourcePath)
879
+ if (stat.isFile()) {
880
+ if (!fs.existsSync(targetDir)) {
881
+ fs.mkdirSync(targetDir, { recursive: true })
882
+ }
883
+ const fileName = path.basename(sourcePath)
884
+ await smartCopy(sourcePath, path.join(targetDir, fileName))
885
+ } else {
886
+ await smartCopy(sourcePath, targetDir)
887
+ }
888
+ }
889
+
890
+ spinner.start()
891
+
892
+ // 10. Handle Dependencies
893
+ if (isSwift) {
894
+ // Swift: print dependency instructions instead of running npm install
895
+ spinner.stop()
896
+ printSwiftDependencyInstructions(versionData, msg =>
897
+ console.log(chalk.dim(msg)),
898
+ )
899
+ spinner.start()
900
+ } else if (lpmConfig?.dependencies) {
901
+ // Config-based conditional dependencies
902
+ const { npm: npmDeps, lpm: lpmDeps } = resolveConditionalDependencies(
903
+ lpmConfig.dependencies,
904
+ mergedConfig,
905
+ )
906
+
907
+ if (npmDeps.length > 0 || lpmDeps.length > 0) {
908
+ spinner.stop()
909
+
910
+ // Track deps for --json output
911
+ if (npmDeps.length > 0) jsonOutput.dependencies.npm = npmDeps
912
+ if (lpmDeps.length > 0) jsonOutput.dependencies.lpm = lpmDeps
913
+
914
+ if (npmDeps.length > 0) {
915
+ console.log(chalk.blue(`\nnpm dependencies: ${npmDeps.join(", ")}`))
916
+ }
917
+ if (lpmDeps.length > 0) {
918
+ console.log(chalk.blue(`\nLPM dependencies: ${lpmDeps.join(", ")}`))
919
+ }
920
+
921
+ // Determine whether to install deps
922
+ let shouldInstallDeps
923
+ if (options.installDeps === false) {
924
+ // --no-install-deps: explicitly skip
925
+ shouldInstallDeps = false
926
+ } else if (options.yes || options.installDeps === true) {
927
+ // --yes or --install-deps: auto-install
928
+ shouldInstallDeps = true
929
+ } else {
930
+ const installDeps = await p.confirm({
931
+ message: "Install these dependencies now?",
932
+ initialValue: true,
933
+ })
934
+
935
+ if (p.isCancel(installDeps)) {
936
+ p.cancel("Operation cancelled.")
937
+ fs.rmSync(tmpDir, { recursive: true, force: true })
938
+ process.exit(0)
939
+ }
940
+ shouldInstallDeps = installDeps
941
+ }
942
+
943
+ if (shouldInstallDeps && !options.dryRun) {
944
+ if (npmDeps.length > 0) {
945
+ const installSpinner = ora("Installing npm dependencies...").start()
946
+ try {
947
+ const pm = detectPackageManager()
948
+ const installCmd = pm === "npm" ? "install" : "add"
949
+ await execAsync(`${pm} ${installCmd} ${npmDeps.join(" ")}`)
950
+ installSpinner.succeed(
951
+ `npm dependencies (${npmDeps.join(", ")}) installed.`,
952
+ )
953
+ } catch (err) {
954
+ installSpinner.fail("Failed to install npm dependencies.")
955
+ console.error(err.message)
956
+ }
957
+ }
958
+
959
+ if (lpmDeps.length > 0) {
960
+ console.log(chalk.dim("\nTo install LPM dependencies, run:"))
961
+ for (const dep of lpmDeps) {
962
+ console.log(chalk.cyan(` lpm install ${dep}`))
963
+ }
964
+ }
965
+ }
966
+
967
+ spinner.start()
968
+ }
969
+ } else {
970
+ // Legacy: dependencies from package.json
971
+ const dependencies = pkgJson.dependencies || {}
972
+ const peerDependencies = pkgJson.peerDependencies || {}
973
+ const allDeps = { ...dependencies, ...peerDependencies }
974
+
975
+ const depNames = Object.keys(allDeps)
976
+
977
+ if (depNames.length > 0) {
978
+ spinner.stop()
979
+
980
+ // Track deps for --json output
981
+ jsonOutput.dependencies.npm = depNames.map(d => `${d}@${allDeps[d]}`)
982
+
983
+ console.log(
984
+ chalk.blue(
985
+ `\nComponent requires dependencies: ${depNames.join(", ")}`,
986
+ ),
987
+ )
988
+
989
+ // Determine whether to install deps
990
+ let shouldInstallDeps
991
+ if (options.installDeps === false) {
992
+ // --no-install-deps: explicitly skip
993
+ shouldInstallDeps = false
994
+ } else if (options.yes || options.installDeps === true) {
995
+ // --yes or --install-deps: auto-install
996
+ shouldInstallDeps = true
997
+ } else {
998
+ const installDeps = await p.confirm({
999
+ message: "Install these dependencies now?",
1000
+ initialValue: true,
1001
+ })
1002
+
1003
+ if (p.isCancel(installDeps)) {
1004
+ p.cancel("Operation cancelled.")
1005
+ fs.rmSync(tmpDir, { recursive: true, force: true })
1006
+ process.exit(0)
1007
+ }
1008
+ shouldInstallDeps = installDeps
1009
+ }
1010
+
1011
+ if (shouldInstallDeps && !options.dryRun) {
1012
+ const installSpinner = ora("Installing dependencies...").start()
1013
+ try {
1014
+ const pm = detectPackageManager()
1015
+ const installCmd = pm === "npm" ? "install" : "add"
1016
+ const depsString = depNames.map(d => `${d}@${allDeps[d]}`).join(" ")
1017
+
1018
+ await execAsync(`${pm} ${installCmd} ${depsString}`)
1019
+ installSpinner.succeed(
1020
+ `Dependencies (${depNames.join(", ")}) installed.`,
1021
+ )
1022
+ } catch (err) {
1023
+ installSpinner.fail("Failed to install dependencies.")
1024
+ console.error(err.message)
1025
+ }
1026
+ }
1027
+
1028
+ spinner.start()
1029
+ }
1030
+ }
1031
+
1032
+ // Cleanup
1033
+ fs.rmSync(tmpDir, { recursive: true, force: true })
1034
+
1035
+ // Populate final JSON output
1036
+ jsonOutput.files = fileActions
1037
+ jsonOutput.success = true
1038
+
1039
+ if (options.json) {
1040
+ if (options.skills !== false) {
1041
+ const pkgShortName = name.replace("@lpm.dev/", "")
1042
+ await skillsInstall(pkgShortName, { json: true })
1043
+ }
1044
+ process.stdout.write(`${JSON.stringify(jsonOutput, null, 2)}\n`)
1045
+ return
1046
+ }
1047
+
1048
+ // Show config summary if applicable
1049
+ if (configuredFiles && Object.keys(mergedConfig).length > 0) {
1050
+ const configSummary = Object.entries(mergedConfig)
1051
+ .map(([k, v]) => `${k}=${v}`)
1052
+ .join(", ")
1053
+ spinner.succeed(
1054
+ `Successfully added ${chalk.green(name)} to ${chalk.dim(targetDir)}\n ${chalk.dim(`Config: ${configSummary}`)}`,
1055
+ )
1056
+ } else {
1057
+ spinner.succeed(
1058
+ `Successfully added ${chalk.green(name)} to ${chalk.dim(targetDir)}`,
1059
+ )
1060
+ }
1061
+
1062
+ // Fetch Agent Skills (default: on, skip with --no-skills or --dry-run)
1063
+ if (options.skills !== false && !options.dryRun) {
1064
+ const pkgShortName = name.replace("@lpm.dev/", "")
1065
+ await skillsInstall(pkgShortName)
1066
+ }
1067
+
1068
+ // Swift-specific post-install messages
1069
+ if (framework === "swift-xcode") {
1070
+ // Try to auto-link the package to the Xcode project
1071
+ const linkResult = autoLinkXcodePackage(swiftModuleName)
1072
+
1073
+ if (linkResult.success) {
1074
+ console.log(chalk.green(`\n ✓ ${linkResult.message}`))
1075
+ if (swiftModuleName) {
1076
+ console.log(chalk.cyan(`\n Use in your Swift code:`))
1077
+ console.log(chalk.dim(` import ${swiftModuleName}`))
1078
+ }
1079
+ // Warn if Xcode may be open — it can revert external pbxproj changes
1080
+ console.log("")
1081
+ console.log(
1082
+ chalk.yellow(" If Xcode is open, close and reopen the project"),
1083
+ )
1084
+ console.log(chalk.yellow(" for the package to appear in the sidebar."))
1085
+ console.log(
1086
+ chalk.yellow(" Or: File → Add Package Dependencies → Add Local…"),
1087
+ )
1088
+ console.log(chalk.yellow(" → select Packages/LPMComponents"))
1089
+ } else if (xcodeSetupNeeded) {
1090
+ // Auto-link failed (no .xcodeproj found) — show manual instructions
1091
+ printXcodeSetupInstructions(
1092
+ msg => console.log(chalk.yellow(msg)),
1093
+ swiftModuleName,
1094
+ )
1095
+ }
1096
+ } else if (xcodeSetupNeeded) {
1097
+ printXcodeSetupInstructions(
1098
+ msg => console.log(chalk.yellow(msg)),
1099
+ swiftModuleName,
1100
+ )
1101
+ }
1102
+ if (isSwift && !xcodeSetupNeeded) {
1103
+ console.log(
1104
+ chalk.dim(" Files will be compiled automatically on next build."),
1105
+ )
1106
+ }
1107
+ } catch (error) {
1108
+ if (options.json) {
1109
+ jsonOutput.success = false
1110
+ jsonOutput.errors.push(error.message)
1111
+ process.stdout.write(`${JSON.stringify(jsonOutput, null, 2)}\n`)
1112
+ return
1113
+ }
1114
+ spinner.fail(`Failed to add package: ${error.message}`)
1115
+ if (process.env.DEBUG) console.error(error)
1116
+ }
1117
+ }
1118
+
1119
+ /**
1120
+ * Copy a single file with conflict handling.
1121
+ *
1122
+ * @param {string} src - Source file path
1123
+ * @param {string} dest - Destination file path
1124
+ * @param {string | undefined} srcContent - Pre-read source content (undefined for binary)
1125
+ * @param {object} options - Command options (force flag)
1126
+ */
1127
+ async function smartCopyFile(src, dest, srcContent, options) {
1128
+ const destDir = path.dirname(dest)
1129
+ if (!fs.existsSync(destDir)) {
1130
+ fs.mkdirSync(destDir, { recursive: true })
1131
+ }
1132
+
1133
+ if (fs.existsSync(dest)) {
1134
+ let destContent
1135
+ try {
1136
+ destContent = fs.readFileSync(dest, "utf-8")
1137
+ } catch (_e) {
1138
+ // Binary
1139
+ }
1140
+
1141
+ // Binary files
1142
+ if (srcContent === undefined || destContent === undefined) {
1143
+ fs.copyFileSync(src, dest)
1144
+ return "overwritten"
1145
+ }
1146
+
1147
+ if (srcContent !== destContent) {
1148
+ if (options.force) {
1149
+ fs.writeFileSync(dest, srcContent)
1150
+ console.log(chalk.green(`Overwrote ${path.basename(dest)}`))
1151
+ return "overwritten"
1152
+ }
1153
+
1154
+ if (options.yes) {
1155
+ // --yes without --force: skip conflicting files
1156
+ console.log(chalk.yellow(`Skipped ${path.basename(dest)} (conflict)`))
1157
+ return "skipped"
1158
+ }
1159
+
1160
+ let action = "diff"
1161
+ while (action === "diff") {
1162
+ const answer = await p.select({
1163
+ message: `Conflict in ${chalk.bold(path.basename(dest))}. What do you want to do?`,
1164
+ options: [
1165
+ { value: "overwrite", label: "Overwrite" },
1166
+ { value: "skip", label: "Skip" },
1167
+ { value: "diff", label: "Show Diff" },
1168
+ ],
1169
+ })
1170
+
1171
+ if (p.isCancel(answer)) {
1172
+ p.cancel("Operation cancelled.")
1173
+ process.exit(0)
1174
+ }
1175
+
1176
+ action = answer
1177
+
1178
+ if (action === "diff") {
1179
+ const diff = Diff.diffLines(destContent, srcContent)
1180
+ for (const part of diff) {
1181
+ const color = part.added ? "green" : part.removed ? "red" : "grey"
1182
+ process.stdout.write(chalk[color](part.value))
1183
+ }
1184
+ console.log("\n")
1185
+ } else if (action === "overwrite") {
1186
+ fs.writeFileSync(dest, srcContent)
1187
+ console.log(chalk.green(`Overwrote ${path.basename(dest)}`))
1188
+ return "overwritten"
1189
+ } else {
1190
+ console.log(chalk.yellow(`Skipped ${path.basename(dest)}`))
1191
+ return "skipped"
1192
+ }
1193
+ }
1194
+ }
1195
+ // Identical: skip silently
1196
+ return "unchanged"
1197
+ } else {
1198
+ if (srcContent !== undefined) {
1199
+ fs.writeFileSync(dest, srcContent)
1200
+ } else {
1201
+ fs.copyFileSync(src, dest)
1202
+ }
1203
+ return "created"
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * Detect the package manager used in the current project.
1209
+ *
1210
+ * @returns {string} Package manager name (npm, pnpm, yarn, bun)
1211
+ */
1212
+ function detectPackageManager() {
1213
+ if (fs.existsSync("pnpm-lock.yaml")) return "pnpm"
1214
+ if (fs.existsSync("yarn.lock")) return "yarn"
1215
+ if (fs.existsSync("bun.lockb")) return "bun"
1216
+ return "npm"
1217
+ }