@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,164 @@
1
+ import chalk from "chalk"
2
+ import ora from "ora"
3
+ import { get } from "../api.js"
4
+
5
+ /**
6
+ * Format cents to a dollar string.
7
+ * @param {number} cents
8
+ * @returns {string}
9
+ */
10
+ function formatCents(cents) {
11
+ if (!cents && cents !== 0) return "—"
12
+ return `$${(cents / 100).toFixed(2)}`
13
+ }
14
+
15
+ /**
16
+ * Parse package name to extract category or clean name.
17
+ * @param {string} input
18
+ * @returns {{ isPackage: boolean, owner?: string, name?: string, category?: string }}
19
+ */
20
+ function parseInput(input) {
21
+ let cleaned = input
22
+ if (cleaned.startsWith("@lpm.dev/")) {
23
+ cleaned = cleaned.replace("@lpm.dev/", "")
24
+ }
25
+ const dotIndex = cleaned.indexOf(".")
26
+ if (dotIndex > 0 && dotIndex < cleaned.length - 1) {
27
+ return {
28
+ isPackage: true,
29
+ owner: cleaned.substring(0, dotIndex),
30
+ name: cleaned.substring(dotIndex + 1),
31
+ }
32
+ }
33
+ // Treat as category/keyword
34
+ return { isPackage: false, category: input }
35
+ }
36
+
37
+ /**
38
+ * Find and display comparable marketplace packages.
39
+ *
40
+ * @param {string} input - Package name or category
41
+ * @param {Object} [options]
42
+ * @param {boolean} [options.json] - Output as JSON
43
+ * @param {string} [options.category] - Filter by category
44
+ * @param {number} [options.limit] - Max results
45
+ */
46
+ export async function marketplaceCompare(input, options = {}) {
47
+ if (!input || input.trim() === "") {
48
+ if (options.json) {
49
+ console.log(
50
+ JSON.stringify({ error: "Package name or category required." }),
51
+ )
52
+ } else {
53
+ console.error(chalk.red("Error: Package name or category required."))
54
+ console.log(
55
+ chalk.dim("Usage: lpm marketplace compare owner.package-name"),
56
+ )
57
+ console.log(
58
+ chalk.dim(" lpm marketplace compare --category ui-components"),
59
+ )
60
+ }
61
+ process.exit(1)
62
+ }
63
+
64
+ const parsed = parseInput(input.trim())
65
+ const category =
66
+ options.category || (parsed.isPackage ? null : parsed.category)
67
+ const query = parsed.isPackage ? parsed.name : null
68
+
69
+ const params = new URLSearchParams()
70
+ if (category) params.set("category", category)
71
+ if (query) params.set("q", query)
72
+ if (options.limit) params.set("limit", String(options.limit))
73
+
74
+ const spinner = options.json
75
+ ? null
76
+ : ora("Fetching comparable packages...").start()
77
+
78
+ try {
79
+ const response = await get(
80
+ `/marketplace/comparables?${params.toString()}`,
81
+ {
82
+ onRetry: spinner
83
+ ? (attempt, max) => {
84
+ spinner.text = `Fetching (retry ${attempt}/${max})...`
85
+ }
86
+ : undefined,
87
+ },
88
+ )
89
+
90
+ const data = await response.json().catch(() => ({}))
91
+
92
+ if (!response.ok) {
93
+ throw new Error(data.error || `Request failed: ${response.status}`)
94
+ }
95
+
96
+ if (options.json) {
97
+ console.log(JSON.stringify(data, null, 2))
98
+ return
99
+ }
100
+
101
+ if (spinner) spinner.stop()
102
+
103
+ const comparables = data.comparables || []
104
+
105
+ console.log("")
106
+ if (category) {
107
+ console.log(chalk.bold(` Marketplace: ${category}`))
108
+ } else if (parsed.isPackage) {
109
+ console.log(
110
+ chalk.bold(` Similar to: @lpm.dev/${parsed.owner}.${parsed.name}`),
111
+ )
112
+ }
113
+
114
+ if (data.stats?.priceRange) {
115
+ const pr = data.stats.priceRange
116
+ console.log(
117
+ chalk.dim(
118
+ ` Price range: ${formatCents(pr.minCents)} — ${formatCents(pr.maxCents)} (median: ${formatCents(pr.medianCents)})`,
119
+ ),
120
+ )
121
+ }
122
+ console.log(chalk.dim(` ${data.stats?.total || 0} packages found`))
123
+ console.log("")
124
+
125
+ if (comparables.length === 0) {
126
+ console.log(chalk.dim(" No comparable packages found."))
127
+ console.log("")
128
+ return
129
+ }
130
+
131
+ // Table
132
+ const nameWidth = Math.max(30, ...comparables.map(c => c.name.length + 2))
133
+ console.log(
134
+ ` ${chalk.dim("Package".padEnd(nameWidth))}${chalk.dim("Price".padStart(10))}${chalk.dim("Quality".padStart(10))}${chalk.dim("Downloads".padStart(12))}${chalk.dim("Mode".padStart(14))}`,
135
+ )
136
+ console.log(chalk.dim(` ${"─".repeat(nameWidth + 46)}`))
137
+
138
+ for (const pkg of comparables) {
139
+ const name = pkg.name.padEnd(nameWidth)
140
+ const price = pkg.pricing
141
+ ? formatCents(pkg.pricing.minPriceCents).padStart(10)
142
+ : chalk.dim("Pool".padStart(10))
143
+ const quality =
144
+ pkg.qualityScore !== null
145
+ ? `${pkg.qualityScore}/100`.padStart(10)
146
+ : chalk.dim("—".padStart(10))
147
+ const downloads = (pkg.downloadCount || 0).toLocaleString().padStart(12)
148
+ const mode = pkg.distributionMode.padStart(14)
149
+ console.log(` ${name}${price}${quality}${downloads}${chalk.dim(mode)}`)
150
+ }
151
+
152
+ console.log("")
153
+ } catch (error) {
154
+ if (spinner) spinner.fail(chalk.red("Failed to fetch comparable packages."))
155
+ if (options.json) {
156
+ console.log(JSON.stringify({ error: error.message }))
157
+ } else {
158
+ console.error(chalk.red(` ${error.message}`))
159
+ }
160
+ process.exit(1)
161
+ }
162
+ }
163
+
164
+ export default marketplaceCompare
@@ -0,0 +1,89 @@
1
+ import chalk from "chalk"
2
+ import ora from "ora"
3
+ import { get } from "../api.js"
4
+
5
+ /**
6
+ * Format cents to a dollar string.
7
+ * @param {number} cents
8
+ * @returns {string}
9
+ */
10
+ function formatCents(cents) {
11
+ if (!cents && cents !== 0) return "$0.00"
12
+ return `$${(cents / 100).toFixed(2)}`
13
+ }
14
+
15
+ /**
16
+ * Fetch and display Marketplace earnings for the authenticated user.
17
+ *
18
+ * @param {Object} [options]
19
+ * @param {boolean} [options.json] - Output as JSON
20
+ */
21
+ export async function marketplaceEarnings(options = {}) {
22
+ const spinner = options.json
23
+ ? null
24
+ : ora("Fetching marketplace earnings...").start()
25
+
26
+ try {
27
+ const response = await get("/marketplace/earnings", {
28
+ skipRetry: false,
29
+ onRetry: spinner
30
+ ? (attempt, max) => {
31
+ spinner.text = `Fetching (retry ${attempt}/${max})...`
32
+ }
33
+ : undefined,
34
+ })
35
+
36
+ const data = await response.json().catch(() => ({}))
37
+
38
+ if (!response.ok) {
39
+ throw new Error(data.error || `Request failed: ${response.status}`)
40
+ }
41
+
42
+ if (options.json) {
43
+ console.log(JSON.stringify(data, null, 2))
44
+ return
45
+ }
46
+
47
+ if (spinner) spinner.stop()
48
+
49
+ console.log("")
50
+ console.log(chalk.bold(" Marketplace Earnings"))
51
+ console.log("")
52
+
53
+ if (!data.totalSales) {
54
+ console.log(chalk.dim(" No marketplace sales yet."))
55
+ console.log(
56
+ chalk.dim(
57
+ " Publish a package with Marketplace distribution to start selling.",
58
+ ),
59
+ )
60
+ console.log("")
61
+ return
62
+ }
63
+
64
+ console.log(
65
+ ` ${chalk.dim("Total Sales:")} ${data.totalSales.toLocaleString()}`,
66
+ )
67
+ console.log(
68
+ ` ${chalk.dim("Gross Revenue:")} ${formatCents(data.grossRevenueCents)}`,
69
+ )
70
+ console.log(
71
+ ` ${chalk.dim("Platform Fees:")} ${chalk.red(formatCents(data.platformFeesCents))}`,
72
+ )
73
+ console.log(
74
+ ` ${chalk.bold("Net Revenue:")} ${chalk.green(formatCents(data.netRevenueCents))}`,
75
+ )
76
+ console.log("")
77
+ } catch (error) {
78
+ if (spinner)
79
+ spinner.fail(chalk.red("Failed to fetch marketplace earnings."))
80
+ if (options.json) {
81
+ console.log(JSON.stringify({ error: error.message }))
82
+ } else {
83
+ console.error(chalk.red(` ${error.message}`))
84
+ }
85
+ process.exit(1)
86
+ }
87
+ }
88
+
89
+ export default marketplaceEarnings
@@ -0,0 +1,363 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import * as p from "@clack/prompts"
6
+ import chalk from "chalk"
7
+ import { getRegistryUrl, getToken } from "../config.js"
8
+ import { DEFAULT_REGISTRY_URL } from "../constants.js"
9
+ import {
10
+ addMcpServer,
11
+ EDITORS,
12
+ getMcpServerConfig,
13
+ hasMcpServer,
14
+ removeMcpServerEntry,
15
+ shortPath,
16
+ } from "../editors.js"
17
+
18
+ const SERVER_NAME = "lpm-registry"
19
+
20
+ const MCP_SERVER_PACKAGE = "@lpm-registry/mcp-server"
21
+ const THIS_FILE_DIR = path.dirname(fileURLToPath(import.meta.url))
22
+ const LOCAL_MCP_SERVER_CANDIDATES = [
23
+ path.resolve(process.cwd(), "mcp-server", "bin", "mcp-server.js"),
24
+ path.resolve(process.cwd(), "..", "mcp-server", "bin", "mcp-server.js"),
25
+ path.resolve(
26
+ THIS_FILE_DIR,
27
+ "..",
28
+ "..",
29
+ "..",
30
+ "..",
31
+ "mcp-server",
32
+ "bin",
33
+ "mcp-server.js",
34
+ ),
35
+ ]
36
+
37
+ function resolveNpxCommand() {
38
+ const lookupCommand = process.platform === "win32" ? "where" : "which"
39
+ const result = spawnSync(lookupCommand, ["npx"], { encoding: "utf-8" })
40
+
41
+ if (result.status === 0) {
42
+ const lines = (result.stdout || "")
43
+ .split("\n")
44
+ .map(line => line.trim())
45
+ .filter(Boolean)
46
+
47
+ if (lines.length > 0) {
48
+ return lines[0]
49
+ }
50
+ }
51
+
52
+ return "npx"
53
+ }
54
+
55
+ function getLocalMcpServerPath() {
56
+ for (const candidate of LOCAL_MCP_SERVER_CANDIDATES) {
57
+ if (fs.existsSync(candidate)) {
58
+ return candidate
59
+ }
60
+ }
61
+
62
+ return null
63
+ }
64
+
65
+ export function getServerConfig(registryUrl = DEFAULT_REGISTRY_URL) {
66
+ const localServerPath = getLocalMcpServerPath()
67
+ const hasCustomRegistry =
68
+ typeof registryUrl === "string" &&
69
+ registryUrl.length > 0 &&
70
+ registryUrl !== DEFAULT_REGISTRY_URL
71
+
72
+ const env = hasCustomRegistry ? { LPM_REGISTRY_URL: registryUrl } : undefined
73
+
74
+ if (localServerPath) {
75
+ return {
76
+ command: process.execPath,
77
+ args: [localServerPath],
78
+ ...(env ? { env } : {}),
79
+ }
80
+ }
81
+
82
+ return {
83
+ command: resolveNpxCommand(),
84
+ args: ["-y", `${MCP_SERVER_PACKAGE}@latest`],
85
+ ...(env ? { env } : {}),
86
+ }
87
+ }
88
+
89
+ function isCommandRunnable(command) {
90
+ if (!command) return false
91
+
92
+ if (path.isAbsolute(command)) {
93
+ return fs.existsSync(command)
94
+ }
95
+
96
+ const lookupCommand = process.platform === "win32" ? "where" : "which"
97
+ const result = spawnSync(lookupCommand, [command], { stdio: "ignore" })
98
+ return result.status === 0
99
+ }
100
+
101
+ function getEditorStatus(editor, isProject = false) {
102
+ const configPath =
103
+ isProject && editor.projectPath
104
+ ? path.resolve(process.cwd(), editor.projectPath)
105
+ : editor.globalPath
106
+
107
+ const entry = getMcpServerConfig(configPath, editor.serverKey, SERVER_NAME)
108
+ const installed = !!entry
109
+
110
+ if (!installed) {
111
+ return {
112
+ editor,
113
+ configPath,
114
+ installed: false,
115
+ runnable: false,
116
+ entry: null,
117
+ }
118
+ }
119
+
120
+ const command = entry.command
121
+ const runnable = isCommandRunnable(command)
122
+
123
+ return {
124
+ editor,
125
+ configPath,
126
+ installed: true,
127
+ runnable,
128
+ entry,
129
+ }
130
+ }
131
+
132
+ // ============================================================================
133
+ // Setup command
134
+ // ============================================================================
135
+
136
+ export async function mcpSetup(options = {}) {
137
+ p.intro(chalk.bgCyan(chalk.black(" lpm mcp setup ")))
138
+ const registryUrl = getRegistryUrl()
139
+ const serverConfig = getServerConfig(registryUrl)
140
+
141
+ // Check authentication and offer login
142
+ let token = await getToken()
143
+ if (!token) {
144
+ const shouldLogin = await p.confirm({
145
+ message:
146
+ "Not logged in. Login now for full MCP functionality? (recommended)",
147
+ initialValue: true,
148
+ })
149
+
150
+ if (p.isCancel(shouldLogin)) {
151
+ p.cancel("Setup cancelled.")
152
+ process.exit(0)
153
+ }
154
+
155
+ if (shouldLogin) {
156
+ p.log.info("Opening browser for login...\n")
157
+ const result = spawnSync("lpm", ["login"], {
158
+ stdio: "inherit",
159
+ })
160
+
161
+ if (result.status === 0) {
162
+ token = await getToken()
163
+ } else {
164
+ p.log.warn("Login did not complete. Continuing without authentication.")
165
+ }
166
+ }
167
+ }
168
+
169
+ const isProject = !!options.project
170
+
171
+ // Detect installed editors
172
+ const detected = EDITORS.filter(e => e.detect())
173
+
174
+ if (detected.length === 0) {
175
+ p.log.warn("No supported editors detected on this machine.")
176
+ p.note(
177
+ "Supported: Claude Code, Cursor, VS Code, Claude Desktop, Windsurf",
178
+ "Supported editors",
179
+ )
180
+ p.outro("Install a supported editor and try again.")
181
+ return
182
+ }
183
+
184
+ // Filter to editors that support project config when --project is used
185
+ const eligible = isProject ? detected.filter(e => e.projectPath) : detected
186
+
187
+ if (eligible.length === 0) {
188
+ p.log.warn("No detected editors support project-level MCP config.")
189
+ p.outro(
190
+ `Try ${chalk.cyan("lpm mcp setup")} without --project for global setup.`,
191
+ )
192
+ return
193
+ }
194
+
195
+ // Build multiselect options
196
+ const selectOptions = eligible.map(editor => {
197
+ const configPath =
198
+ isProject && editor.projectPath
199
+ ? path.resolve(process.cwd(), editor.projectPath)
200
+ : editor.globalPath
201
+
202
+ const installed = hasMcpServer(configPath, editor.serverKey, SERVER_NAME)
203
+
204
+ return {
205
+ value: editor.id,
206
+ label: installed
207
+ ? `${editor.name} ${chalk.dim("(already configured)")}`
208
+ : editor.name,
209
+ hint: shortPath(configPath),
210
+ }
211
+ })
212
+
213
+ const selected = await p.multiselect({
214
+ message: isProject
215
+ ? "Add LPM MCP server to (project-level):"
216
+ : "Add LPM MCP server to:",
217
+ options: selectOptions,
218
+ initialValues: selectOptions.map(o => o.value),
219
+ required: true,
220
+ })
221
+
222
+ if (p.isCancel(selected)) {
223
+ p.cancel("Setup cancelled.")
224
+ process.exit(0)
225
+ }
226
+
227
+ // Write configs
228
+ let count = 0
229
+ for (const editorId of selected) {
230
+ const editor = EDITORS.find(e => e.id === editorId)
231
+ const configPath =
232
+ isProject && editor.projectPath
233
+ ? path.resolve(process.cwd(), editor.projectPath)
234
+ : editor.globalPath
235
+
236
+ try {
237
+ addMcpServer(configPath, editor.serverKey, SERVER_NAME, {
238
+ ...serverConfig,
239
+ })
240
+ p.log.success(`${editor.name} ${chalk.dim(shortPath(configPath))}`)
241
+ count++
242
+ } catch (err) {
243
+ p.log.error(`${editor.name}: ${err.message}`)
244
+ }
245
+ }
246
+
247
+ if (count > 0) {
248
+ const authLine = token
249
+ ? `Auth: Using keychain token from ${chalk.cyan("lpm login")}`
250
+ : `Auth: Run ${chalk.cyan("lpm login")} to enable authenticated tools`
251
+
252
+ p.note(authLine, `Added to ${count} editor${count > 1 ? "s" : ""}`)
253
+
254
+ if (registryUrl !== DEFAULT_REGISTRY_URL) {
255
+ p.log.info(
256
+ `Using custom registry URL for MCP: ${chalk.cyan(registryUrl)}`,
257
+ )
258
+ }
259
+ }
260
+
261
+ p.outro("Restart your editors to activate the MCP server.")
262
+ }
263
+
264
+ // ============================================================================
265
+ // Remove command
266
+ // ============================================================================
267
+
268
+ export async function mcpRemove(options = {}) {
269
+ p.intro(chalk.bgCyan(chalk.black(" lpm mcp remove ")))
270
+
271
+ const isProject = !!options.project
272
+ let count = 0
273
+
274
+ for (const editor of EDITORS) {
275
+ if (isProject && !editor.projectPath) continue
276
+
277
+ const configPath =
278
+ isProject && editor.projectPath
279
+ ? path.resolve(process.cwd(), editor.projectPath)
280
+ : editor.globalPath
281
+
282
+ if (removeMcpServerEntry(configPath, editor.serverKey, SERVER_NAME)) {
283
+ p.log.success(
284
+ `Removed from ${editor.name} ${chalk.dim(shortPath(configPath))}`,
285
+ )
286
+ count++
287
+ }
288
+ }
289
+
290
+ if (count === 0) {
291
+ p.log.info("LPM MCP server was not configured in any editor.")
292
+ }
293
+
294
+ p.outro(
295
+ count > 0 ? "Done. Restart your editors to apply." : "Nothing to remove.",
296
+ )
297
+ }
298
+
299
+ // ============================================================================
300
+ // Status command
301
+ // ============================================================================
302
+
303
+ export async function mcpStatus(options = {}) {
304
+ p.intro(chalk.bgCyan(chalk.black(" lpm mcp status ")))
305
+
306
+ const token = await getToken()
307
+ const isVerbose = !!options.verbose
308
+ let found = 0
309
+ let notRunnable = 0
310
+
311
+ for (const editor of EDITORS) {
312
+ const status = getEditorStatus(editor, false)
313
+ const globalInstalled = status.installed
314
+
315
+ if (globalInstalled) {
316
+ if (status.runnable) {
317
+ p.log.success(
318
+ `${editor.name} ${chalk.dim(shortPath(editor.globalPath))}`,
319
+ )
320
+ } else {
321
+ p.log.warn(
322
+ `${editor.name} ${chalk.dim(shortPath(editor.globalPath))} ${chalk.dim("(configured, command not found)")}`,
323
+ )
324
+ notRunnable++
325
+ }
326
+
327
+ if (isVerbose) {
328
+ const args = Array.isArray(status.entry?.args) ? status.entry.args : []
329
+ const command = status.entry?.command || "(missing)"
330
+ p.log.info(` command: ${command}`)
331
+ p.log.info(` args: ${args.length > 0 ? args.join(" ") : "(none)"}`)
332
+ if (!status.runnable) {
333
+ p.log.info(
334
+ ` hint: use ${chalk.cyan("lpm mcp setup")} again to refresh config on this machine`,
335
+ )
336
+ }
337
+ }
338
+ found++
339
+ } else if (isVerbose) {
340
+ p.log.info(
341
+ `${editor.name} ${chalk.dim(shortPath(editor.globalPath))} ${chalk.dim("(not configured)")}`,
342
+ )
343
+ }
344
+ }
345
+
346
+ if (found === 0) {
347
+ p.log.info("LPM MCP server is not configured in any editor.")
348
+ p.note(`Run ${chalk.cyan("lpm mcp setup")} to configure it.`, "Get started")
349
+ } else if (notRunnable > 0) {
350
+ p.note(
351
+ `${notRunnable} configured editor${notRunnable > 1 ? "s" : ""} have a command that is not runnable in this environment.`,
352
+ "Action needed",
353
+ )
354
+ }
355
+
356
+ const authStatus = token
357
+ ? chalk.green("Authenticated")
358
+ : chalk.yellow(`Not logged in — run ${chalk.cyan("lpm login")}`)
359
+
360
+ p.log.info(`Auth: ${authStatus}`)
361
+
362
+ p.outro("")
363
+ }
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import open from "open"
4
+ import { request } from "../api.js"
5
+ import { getRegistryUrl } from "../config.js"
6
+ import { createSpinner } from "../ui.js"
7
+
8
+ /**
9
+ * Parse owner from LPM package name.
10
+ * @param {string} name - Package name (e.g., '@lpm.dev/owner.pkg-name')
11
+ * @returns {{ owner: string, pkgName: string } | null}
12
+ */
13
+ function parseOwner(name) {
14
+ if (name.startsWith("@lpm.dev/")) {
15
+ const nameWithOwner = name.replace("@lpm.dev/", "")
16
+ const dotIndex = nameWithOwner.indexOf(".")
17
+ if (dotIndex === -1) return null
18
+ return {
19
+ owner: nameWithOwner.substring(0, dotIndex),
20
+ pkgName: nameWithOwner.substring(dotIndex + 1),
21
+ }
22
+ }
23
+ return null
24
+ }
25
+
26
+ export async function openDashboard() {
27
+ const spinner = createSpinner("Opening dashboard...").start()
28
+ const registryUrl = getRegistryUrl()
29
+
30
+ // Default URL
31
+ let url = `${registryUrl}/dashboard`
32
+
33
+ // Check if we are in a package directory
34
+ const pkgPath = path.join(process.cwd(), "package.json")
35
+ if (fs.existsSync(pkgPath)) {
36
+ try {
37
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
38
+ const parsed = parseOwner(pkg.name)
39
+
40
+ if (parsed) {
41
+ const { owner, pkgName } = parsed
42
+ spinner.text = `Detected package ${pkg.name}. Checking owner...`
43
+
44
+ // Fetch user info to determine if owner is personal or org
45
+ try {
46
+ const response = await request("/-/whoami")
47
+ if (response.ok) {
48
+ const data = await response.json()
49
+
50
+ // Check if owner matches user's personal username
51
+ if (data.profile_username && data.profile_username === owner) {
52
+ url = `${registryUrl}/dashboard/packages`
53
+ spinner.text = `Opening personal packages dashboard...`
54
+ }
55
+ // Check if owner matches one of user's organizations
56
+ else if (data.organizations?.some(org => org.slug === owner)) {
57
+ url = `${registryUrl}/dashboard/orgs/${owner}/packages`
58
+ spinner.text = `Opening ${owner} organization packages...`
59
+ }
60
+ // Owner not recognized, fall back to public package page
61
+ else {
62
+ url = `${registryUrl}/${owner}.${pkgName}`
63
+ spinner.text = `Opening public package page...`
64
+ }
65
+ } else {
66
+ // Not authenticated, open public package page
67
+ url = `${registryUrl}/${owner}.${pkgName}`
68
+ spinner.text = `Opening public package page...`
69
+ }
70
+ } catch (_apiError) {
71
+ // API error, fall back to dashboard
72
+ url = `${registryUrl}/dashboard`
73
+ }
74
+ }
75
+ } catch (_e) {
76
+ // ignore JSON parse errors
77
+ }
78
+ }
79
+
80
+ await open(url)
81
+ spinner.succeed(`Opened ${url}`)
82
+ }