@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,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
|
+
}
|