@lpm-registry/cli 0.58.0 → 0.59.1

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 (62) hide show
  1. package/README.md +25 -44
  2. package/bin/lpm.js +2 -354
  3. package/bin/lpx.js +4 -0
  4. package/bin/native.js +154 -0
  5. package/package.json +42 -82
  6. package/scripts/install-binary.js +219 -550
  7. package/CHANGELOG.md +0 -197
  8. package/LICENSE +0 -15
  9. package/index.d.ts +0 -131
  10. package/index.js +0 -31
  11. package/lib/api.js +0 -398
  12. package/lib/commands/add.js +0 -1217
  13. package/lib/commands/audit.js +0 -283
  14. package/lib/commands/cache.js +0 -209
  15. package/lib/commands/check-name.js +0 -112
  16. package/lib/commands/config.js +0 -186
  17. package/lib/commands/doctor.js +0 -140
  18. package/lib/commands/info.js +0 -215
  19. package/lib/commands/init.js +0 -146
  20. package/lib/commands/install.js +0 -264
  21. package/lib/commands/login.js +0 -595
  22. package/lib/commands/logout.js +0 -94
  23. package/lib/commands/marketplace-compare.js +0 -164
  24. package/lib/commands/marketplace-earnings.js +0 -89
  25. package/lib/commands/mcp-setup.js +0 -363
  26. package/lib/commands/npmrc.js +0 -178
  27. package/lib/commands/open.js +0 -82
  28. package/lib/commands/outdated.js +0 -291
  29. package/lib/commands/pool-stats.js +0 -100
  30. package/lib/commands/publish.js +0 -949
  31. package/lib/commands/quality.js +0 -211
  32. package/lib/commands/remove.js +0 -82
  33. package/lib/commands/run.js +0 -14
  34. package/lib/commands/search.js +0 -143
  35. package/lib/commands/setup.js +0 -221
  36. package/lib/commands/skills.js +0 -898
  37. package/lib/commands/token-rotate.js +0 -25
  38. package/lib/commands/uninstall.js +0 -107
  39. package/lib/commands/whoami.js +0 -129
  40. package/lib/config.js +0 -328
  41. package/lib/constants.js +0 -190
  42. package/lib/ecosystem.js +0 -501
  43. package/lib/editor-skills.js +0 -321
  44. package/lib/editors.js +0 -215
  45. package/lib/import-rewriter.js +0 -364
  46. package/lib/install-targets/mcp-server.js +0 -245
  47. package/lib/install-targets/vscode-extension.js +0 -178
  48. package/lib/install-targets.js +0 -82
  49. package/lib/integrity.js +0 -179
  50. package/lib/lpm-config-prompts.js +0 -102
  51. package/lib/lpm-config.js +0 -408
  52. package/lib/oidc.js +0 -166
  53. package/lib/project-utils.js +0 -152
  54. package/lib/quality/checks.js +0 -687
  55. package/lib/quality/display.js +0 -155
  56. package/lib/quality/score.js +0 -118
  57. package/lib/quality/swift-checks.js +0 -447
  58. package/lib/safe-path.js +0 -180
  59. package/lib/secure-store.js +0 -321
  60. package/lib/swift-project.js +0 -637
  61. package/lib/ui.js +0 -40
  62. package/scripts/__tests__/install-binary.test.js +0 -346
@@ -1,949 +0,0 @@
1
- import { exec } from "node:child_process"
2
- import { createHash } from "node:crypto"
3
- import fs from "node:fs"
4
- import path from "node:path"
5
- import { promisify } from "node:util"
6
- import * as p from "@clack/prompts"
7
- import { request, verifyTokenScope } from "../api.js"
8
- import {
9
- clearTransientToken,
10
- getRegistryUrl,
11
- setTransientToken,
12
- } from "../config.js"
13
- import { SUCCESS_MESSAGES, WARNING_MESSAGES } from "../constants.js"
14
- import {
15
- createEcosystemTarball,
16
- detectEcosystem,
17
- detectXCFramework,
18
- extractSwiftMetadata,
19
- readSwiftManifest,
20
- } from "../ecosystem.js"
21
- import { generateIntegrity } from "../integrity.js"
22
- import {
23
- exchangeGitLabOidcToken,
24
- exchangeOidcToken,
25
- isGitHubActionsWithOidc,
26
- isGitLabCiWithOidc,
27
- } from "../oidc.js"
28
- import { displayQualityReport } from "../quality/display.js"
29
- import { runQualityChecks } from "../quality/score.js"
30
- import { createSpinner, log, printHeader } from "../ui.js"
31
-
32
- const execAsync = promisify(exec)
33
- const readFileAsync = promisify(fs.readFile)
34
-
35
- /**
36
- * Parse package name in the @lpm.dev/owner.package format
37
- * @returns {{ owner: string, pkgName: string } | { error: string }}
38
- */
39
- function parsePackageName(name) {
40
- // New format: @lpm.dev/owner.package-name
41
- if (name.startsWith("@lpm.dev/")) {
42
- const nameWithOwner = name.replace("@lpm.dev/", "")
43
- const dotIndex = nameWithOwner.indexOf(".")
44
- if (dotIndex === -1) {
45
- return { error: "Invalid format. Expected @lpm.dev/owner.package-name" }
46
- }
47
- return {
48
- owner: nameWithOwner.substring(0, dotIndex),
49
- pkgName: nameWithOwner.substring(dotIndex + 1),
50
- }
51
- }
52
-
53
- // Legacy format: @scope/package-name
54
- if (name.startsWith("@")) {
55
- const match = name.match(/^@([^/]+)\/(.+)$/)
56
- if (match) {
57
- return {
58
- owner: match[1],
59
- pkgName: match[2],
60
- isLegacy: true,
61
- }
62
- }
63
- }
64
-
65
- return {
66
- error: "Invalid package name. Use @lpm.dev/owner.package-name format",
67
- }
68
- }
69
-
70
- /**
71
- * Run the interactive init flow for non-JS packages.
72
- * Generates a minimal package.json with name, version, and description.
73
- *
74
- * @param {string} ecosystem - Detected ecosystem
75
- * @returns {Promise<object>} Generated package.json contents
76
- */
77
- async function initNonJsPackage(ecosystem) {
78
- log.info(`Detected ${ecosystem} project without package.json.`)
79
- log.info("Creating package metadata for LPM registry...")
80
- console.log("")
81
-
82
- // Get username from auth to auto-prefix owner
83
- const whoamiResponse = await request("/-/whoami")
84
- if (!whoamiResponse.ok) {
85
- throw new Error("Could not determine your username. Run `lpm login` first.")
86
- }
87
- const whoami = await whoamiResponse.json()
88
-
89
- // Build available owners list
90
- const availableOwners = []
91
- if (whoami.profile_username) {
92
- availableOwners.push({
93
- value: whoami.profile_username,
94
- label: `${whoami.profile_username} (personal)`,
95
- })
96
- }
97
- for (const org of whoami.organizations || []) {
98
- availableOwners.push({
99
- value: org.slug,
100
- label: `${org.slug} (organization)`,
101
- })
102
- }
103
-
104
- if (availableOwners.length === 0) {
105
- throw new Error(
106
- "No available owners. Set your username at the dashboard or create an organization.",
107
- )
108
- }
109
-
110
- // Select owner
111
- let owner
112
- if (availableOwners.length === 1) {
113
- owner = availableOwners[0].value
114
- } else {
115
- const selected = await p.select({
116
- message: "Publish under which owner?",
117
- options: availableOwners,
118
- })
119
- if (p.isCancel(selected)) {
120
- p.cancel("Cancelled.")
121
- process.exit(0)
122
- }
123
- owner = selected
124
- }
125
-
126
- // Suggest package name from directory name
127
- const dirName = path
128
- .basename(process.cwd())
129
- .toLowerCase()
130
- .replace(/[^a-z0-9-]/g, "-")
131
- const packageNameInput = await p.text({
132
- message: "Package name",
133
- placeholder: dirName,
134
- defaultValue: dirName,
135
- validate: value => {
136
- if (!value) return "Package name is required"
137
- if (!/^[a-z0-9][a-z0-9-]*$/.test(value))
138
- return "Must start with a letter/number and contain only lowercase letters, numbers, and hyphens"
139
- },
140
- })
141
- if (p.isCancel(packageNameInput)) {
142
- p.cancel("Cancelled.")
143
- process.exit(0)
144
- }
145
- const packageName = packageNameInput || dirName
146
-
147
- const versionInput = await p.text({
148
- message: "Version",
149
- placeholder: "1.0.0",
150
- defaultValue: "1.0.0",
151
- validate: value => {
152
- if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(value))
153
- return "Must be valid semver (e.g. 1.0.0)"
154
- },
155
- })
156
- if (p.isCancel(versionInput)) {
157
- p.cancel("Cancelled.")
158
- process.exit(0)
159
- }
160
-
161
- const description = await p.text({
162
- message: "Description (optional)",
163
- placeholder: "",
164
- })
165
- if (p.isCancel(description)) {
166
- p.cancel("Cancelled.")
167
- process.exit(0)
168
- }
169
-
170
- const fullName = `@lpm.dev/${owner}.${packageName}`
171
- const pkg = {
172
- name: fullName,
173
- version: versionInput || "1.0.0",
174
- }
175
- if (description) {
176
- pkg.description = description
177
- }
178
-
179
- // Write package.json
180
- const packageJsonPath = path.resolve(process.cwd(), "package.json")
181
- fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`)
182
- log.success(`Created package.json for ${fullName}`)
183
- console.log("")
184
-
185
- return pkg
186
- }
187
-
188
- /**
189
- * Read README from the current directory.
190
- * Searches for common README filenames.
191
- *
192
- * @returns {string|null}
193
- */
194
- function readReadme() {
195
- const readmeFilenames = [
196
- "README.md",
197
- "readme.md",
198
- "README",
199
- "Readme.md",
200
- "README.txt",
201
- ]
202
-
203
- for (const filename of readmeFilenames) {
204
- const readmePath = path.resolve(process.cwd(), filename)
205
-
206
- if (!readmePath.startsWith(process.cwd())) {
207
- continue
208
- }
209
-
210
- if (fs.existsSync(readmePath)) {
211
- try {
212
- const stats = fs.statSync(readmePath)
213
-
214
- const MAX_README_SIZE = 1024 * 1024
215
- if (stats.size > MAX_README_SIZE) {
216
- log.warn(
217
- `README file is too large (${(stats.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB. Skipping README.`,
218
- )
219
- return null
220
- }
221
-
222
- const readmeBuffer = fs.readFileSync(readmePath)
223
-
224
- const isBinary = readmeBuffer.some(
225
- byte =>
226
- byte === 0 ||
227
- (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13),
228
- )
229
- if (isBinary) {
230
- log.warn("README appears to be binary. Skipping.")
231
- return null
232
- }
233
-
234
- let readme = readmeBuffer.toString("utf8").trim()
235
-
236
- if (readme.length > MAX_README_SIZE) {
237
- readme = readme.substring(0, MAX_README_SIZE)
238
- log.warn("README truncated to 1MB.")
239
- }
240
-
241
- return readme
242
- } catch (_err) {
243
- // Continue to next filename
244
- }
245
- }
246
- }
247
-
248
- return null
249
- }
250
-
251
- /**
252
- * Read lpm.config.json from the current directory.
253
- *
254
- * @returns {object|null}
255
- */
256
- function readLpmConfig() {
257
- const lpmConfigPath = path.resolve(process.cwd(), "lpm.config.json")
258
- if (fs.existsSync(lpmConfigPath)) {
259
- try {
260
- const lpmConfigRaw = fs.readFileSync(lpmConfigPath, "utf-8")
261
- return JSON.parse(lpmConfigRaw)
262
- } catch (_err) {
263
- log.warn("Could not parse lpm.config.json. Skipping.")
264
- }
265
- }
266
- return null
267
- }
268
-
269
- /**
270
- * Pack using npm pack (JS ecosystem).
271
- *
272
- * @returns {Promise<{ tarballPath: string, npmPackMeta: object }>}
273
- */
274
- async function packWithNpm() {
275
- const { stdout } = await execAsync("npm pack --json")
276
- const packResult = JSON.parse(stdout)
277
- const packInfo = packResult[0]
278
- const tarballFilename = packInfo.filename
279
- const tarballPath = path.resolve(process.cwd(), tarballFilename)
280
-
281
- return {
282
- tarballPath,
283
- npmPackMeta: {
284
- unpackedSize: packInfo.unpackedSize,
285
- fileCount: packInfo.files?.length || 0,
286
- files: packInfo.files || [],
287
- },
288
- }
289
- }
290
-
291
- /**
292
- * Pack using tar (non-JS ecosystems).
293
- *
294
- * @param {string} ecosystem - Ecosystem identifier
295
- * @param {string} name - Package name
296
- * @param {string} version - Package version
297
- * @returns {Promise<{ tarballPath: string, npmPackMeta: object }>}
298
- */
299
- async function packWithTar(ecosystem, name, version) {
300
- const result = await createEcosystemTarball(ecosystem, name, version)
301
- return {
302
- tarballPath: result.tarballPath,
303
- npmPackMeta: {
304
- unpackedSize: result.unpackedSize,
305
- fileCount: result.fileCount,
306
- files: result.files,
307
- },
308
- }
309
- }
310
-
311
- /**
312
- * Scan local project directory for test files.
313
- * This avoids requiring test files in the tarball to earn quality points.
314
- * Uses a limited-depth scan, skipping known non-source directories.
315
- */
316
- function scanProjectForTestFiles() {
317
- const skipDirs = new Set([
318
- "node_modules",
319
- ".git",
320
- "dist",
321
- "build",
322
- ".next",
323
- ".nuxt",
324
- "coverage",
325
- ".lpm",
326
- ])
327
-
328
- const result = []
329
-
330
- function walk(dir, depth = 0) {
331
- if (depth > 4) return
332
- let entries
333
- try {
334
- entries = fs.readdirSync(dir, { withFileTypes: true })
335
- } catch {
336
- return
337
- }
338
- for (const entry of entries) {
339
- if (entry.isDirectory()) {
340
- if (!skipDirs.has(entry.name)) {
341
- walk(path.join(dir, entry.name), depth + 1)
342
- }
343
- } else if (entry.isFile()) {
344
- result.push({ path: path.join(dir, entry.name) })
345
- }
346
- }
347
- }
348
-
349
- walk(process.cwd())
350
- return result
351
- }
352
-
353
- export async function publish(options = {}) {
354
- const checkOnly = !!options.check
355
- const dryRun = !!options.dryRun
356
- const minScore = options.minScore ? parseInt(options.minScore, 10) : null
357
- const forceProvenance = !!options.provenance
358
-
359
- printHeader()
360
-
361
- // 1. Detect ecosystem
362
- const { ecosystem, manifestFile } = detectEcosystem()
363
-
364
- if (!ecosystem) {
365
- log.error(
366
- "No recognized project manifest found (package.json, Package.swift, Cargo.toml, pyproject.toml).",
367
- )
368
- process.exit(1)
369
- }
370
-
371
- if (ecosystem !== "js") {
372
- log.info(`Detected ${ecosystem} project (${manifestFile})`)
373
- }
374
-
375
- // 2. Read or generate package.json
376
- const packageJsonPath = path.resolve(process.cwd(), "package.json")
377
- let pkg
378
- let _isNewPackage = false
379
-
380
- if (ecosystem !== "js" && !fs.existsSync(packageJsonPath)) {
381
- // Non-JS project without package.json — run init flow
382
- pkg = await initNonJsPackage(ecosystem)
383
- _isNewPackage = true
384
- } else if (!fs.existsSync(packageJsonPath)) {
385
- log.error("No package.json found in current directory.")
386
- process.exit(1)
387
- } else {
388
- try {
389
- pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
390
- } catch (err) {
391
- log.error(`Invalid JSON in package.json: ${err.message}`)
392
- process.exit(1)
393
- }
394
- }
395
-
396
- const { name, version } = pkg
397
-
398
- // Parse package name to extract owner
399
- const parsed = parsePackageName(name)
400
- if (parsed.error) {
401
- log.error(parsed.error)
402
- log.info("LPM packages must use format: @lpm.dev/owner.package-name")
403
- log.info(`Your current name: ${name}`)
404
-
405
- // Suggest fix for legacy format
406
- const oldMatch = name.match(/^@([^/]+)\/(.+)$/)
407
- if (oldMatch) {
408
- const suggested = `@lpm.dev/${oldMatch[1]}.${oldMatch[2]}`
409
- log.info(`Suggested: ${suggested}`)
410
- }
411
- process.exit(1)
412
- }
413
-
414
- const { owner, pkgName: packageName, isLegacy } = parsed
415
-
416
- // Warn about legacy format
417
- if (isLegacy) {
418
- log.warn(`Legacy format detected: ${name}`)
419
- log.warn(`Please migrate to: @lpm.dev/${owner}.${packageName}`)
420
- console.log("")
421
- }
422
-
423
- const spinner = createSpinner("Preparing to publish...").start()
424
-
425
- // Track tarball path for cleanup in finally block
426
- let tarballPath = null
427
- // Hoist whoami for success message
428
- let whoami = null
429
-
430
- try {
431
- // 3. Read ecosystem-specific manifest data
432
- let swiftManifest = null
433
- let xcFramework = null
434
-
435
- if (ecosystem === "swift") {
436
- // Read Swift Package manifest
437
- spinner.text = "Reading Package.swift..."
438
- try {
439
- const rawManifest = await readSwiftManifest()
440
- swiftManifest = extractSwiftMetadata(rawManifest)
441
- } catch (err) {
442
- spinner.stop()
443
- log.warn(`Could not read Package.swift: ${err.message}`)
444
- log.info("Publishing without Swift manifest data.")
445
- spinner.start()
446
- }
447
-
448
- // Detect XCFramework
449
- xcFramework = detectXCFramework()
450
- if (xcFramework.found) {
451
- if (!xcFramework.hasInfoPlist) {
452
- spinner.stop()
453
- log.warn(
454
- `XCFramework "${xcFramework.name}" found but missing Info.plist. Treating as source package.`,
455
- )
456
- spinner.start()
457
- xcFramework = { found: false }
458
- } else {
459
- spinner.stop()
460
- log.info(`XCFramework detected: ${xcFramework.name}`)
461
- spinner.start()
462
- }
463
- }
464
- }
465
-
466
- // 4. Create tarball
467
- spinner.text = "Packing tarball..."
468
- let npmPackMeta
469
-
470
- if (ecosystem === "js") {
471
- const packResult = await packWithNpm()
472
- tarballPath = packResult.tarballPath
473
- npmPackMeta = packResult.npmPackMeta
474
- } else {
475
- const packResult = await packWithTar(ecosystem, name, version)
476
- tarballPath = packResult.tarballPath
477
- npmPackMeta = packResult.npmPackMeta
478
- }
479
-
480
- // 4b. Check if local skills exist but are missing from tarball
481
- const localSkillsDir = path.join(process.cwd(), ".lpm", "skills")
482
- if (fs.existsSync(localSkillsDir)) {
483
- const fileList = npmPackMeta.files || []
484
- const hasSkillsInTarball = fileList.some(f => {
485
- const filePath = f.path || f
486
- return (
487
- filePath.includes(".lpm/skills/") ||
488
- filePath.includes(".lpm\\skills\\")
489
- )
490
- })
491
- if (!hasSkillsInTarball) {
492
- spinner.stop()
493
- log.warn(
494
- "Found .lpm/skills/ directory but no skill files in the tarball.",
495
- )
496
- log.info(
497
- 'If using "files" in package.json, add ".lpm" to include skills.',
498
- )
499
- console.log("")
500
- spinner.start()
501
- }
502
- }
503
-
504
- // 5. Read README
505
- spinner.text = "Reading README..."
506
- const readme = readReadme()
507
-
508
- // 6. Read lpm.config.json if present
509
- const lpmConfig = readLpmConfig()
510
-
511
- // 7. Run quality checks and display report
512
- spinner.text = "Running quality checks..."
513
- const projectFiles = scanProjectForTestFiles()
514
- const qualityResult = runQualityChecks({
515
- packageJson: pkg,
516
- readme,
517
- lpmConfig,
518
- files: npmPackMeta.files || [],
519
- projectFiles,
520
- unpackedSize: npmPackMeta.unpackedSize,
521
- ecosystem,
522
- swiftManifest,
523
- })
524
-
525
- // 7b. Check for stale skills (compare local with published)
526
- spinner.text = "Checking skills..."
527
- const prePublishWarnings = []
528
- try {
529
- const localSkillsDir = path.join(process.cwd(), ".lpm", "skills")
530
- if (fs.existsSync(localSkillsDir)) {
531
- const skillFiles = fs
532
- .readdirSync(localSkillsDir)
533
- .filter(f => f.endsWith(".md"))
534
- .sort()
535
-
536
- if (skillFiles.length > 0) {
537
- // Read local skills content
538
- const localSkills = skillFiles.map(f => ({
539
- name: f.replace(/\.md$/, ""),
540
- rawContent: fs.readFileSync(path.join(localSkillsDir, f), "utf8"),
541
- }))
542
-
543
- // Fetch published skills for comparison
544
- const cleanedName = name.startsWith("@lpm.dev/")
545
- ? name.replace("@lpm.dev/", "")
546
- : `${owner}.${packageName}`
547
- const skillsResponse = await request(
548
- `/skills?name=${encodeURIComponent(cleanedName)}`,
549
- { method: "GET" },
550
- ).catch(() => null)
551
-
552
- if (skillsResponse?.ok) {
553
- const published = await skillsResponse.json()
554
- if (published.available && published.skills?.length > 0) {
555
- const pubSorted = [...published.skills]
556
- .map(s => ({ name: s.name, rawContent: s.rawContent }))
557
- .sort((a, b) => a.name.localeCompare(b.name))
558
-
559
- const isIdentical =
560
- localSkills.length === pubSorted.length &&
561
- localSkills.every(
562
- (local, i) =>
563
- local.name === pubSorted[i].name &&
564
- local.rawContent === pubSorted[i].rawContent,
565
- )
566
-
567
- if (isIdentical) {
568
- prePublishWarnings.push(
569
- `Agent Skills haven't changed since v${published.version} - consider reviewing them for accuracy`,
570
- )
571
- }
572
- }
573
- }
574
- }
575
- }
576
- } catch {
577
- // Non-critical — skip skills staleness check
578
- }
579
-
580
- spinner.stop()
581
- displayQualityReport({ ...qualityResult, warnings: prePublishWarnings })
582
-
583
- // --dry-run: print summary and exit without uploading
584
- if (dryRun) {
585
- const unpackedSize = npmPackMeta.unpackedSize || 0
586
- const fileCount =
587
- npmPackMeta.fileCount || (npmPackMeta.files?.length ?? 0)
588
- const sizeStr =
589
- unpackedSize >= 1024 * 1024
590
- ? `${(unpackedSize / 1024 / 1024).toFixed(1)} MB unpacked`
591
- : `${(unpackedSize / 1024).toFixed(1)} kB unpacked`
592
-
593
- const githubOidc = isGitHubActionsWithOidc()
594
- const gitlabOidc = isGitLabCiWithOidc()
595
- const oidcStr = githubOidc
596
- ? "GitHub Actions detected ✓"
597
- : gitlabOidc
598
- ? "GitLab CI detected ✓"
599
- : "Not running in CI"
600
- const authStr =
601
- githubOidc || gitlabOidc
602
- ? "Token will be exchanged at publish time"
603
- : "Run lpm login or configure Trusted Publishers"
604
-
605
- console.log("")
606
- console.log(` Package ${name}`)
607
- console.log(` Version ${version}`)
608
- console.log(` Files ${fileCount} files`)
609
- console.log(` Size ${sizeStr}`)
610
- console.log("")
611
- console.log(` OIDC ${oidcStr}`)
612
- console.log(` Auth ${authStr}`)
613
- console.log("")
614
- log.info("Dry run complete — run without --dry-run to publish.")
615
- process.exit(0)
616
- }
617
-
618
- // --check mode: display report and exit
619
- if (checkOnly) {
620
- if (minScore && qualityResult.score < minScore) {
621
- log.error(
622
- `Quality score ${qualityResult.score} is below minimum ${minScore}.`,
623
- )
624
- process.exit(1)
625
- }
626
- process.exit(0)
627
- }
628
-
629
- // --min-score gate: block publish if score is too low
630
- if (minScore && qualityResult.score < minScore) {
631
- log.error(
632
- `Quality score ${qualityResult.score} is below minimum ${minScore}. Publish blocked.`,
633
- )
634
- log.info('Run "lpm publish --check" to see improvement suggestions.')
635
- process.exit(1)
636
- }
637
-
638
- // 8. Confirmation prompt (after quality report so user can decide)
639
- const shouldPublish = await p.confirm({
640
- message: `Publish ${name}@${version}?`,
641
- initialValue: true,
642
- })
643
-
644
- if (p.isCancel(shouldPublish) || !shouldPublish) {
645
- p.cancel("Publish cancelled.")
646
- process.exit(0)
647
- }
648
-
649
- // 8.5. OIDC auto-detection: exchange a GitHub Actions or GitLab CI OIDC
650
- // token for a short-lived LPM publish token. Runs before auth verification
651
- // so that verifyTokenScope() picks up the OIDC token via getToken().
652
- const githubOidcAvailable = isGitHubActionsWithOidc()
653
- const gitlabOidcAvailable = isGitLabCiWithOidc()
654
- const oidcAvailable = githubOidcAvailable || gitlabOidcAvailable
655
-
656
- if (forceProvenance && !oidcAvailable) {
657
- p.cancel(
658
- "--provenance requires OIDC to be available. " +
659
- "For GitHub Actions, ensure `id-token: write` permission. " +
660
- "For GitLab CI, configure id_tokens in your job.",
661
- )
662
- process.exit(1)
663
- }
664
- if (oidcAvailable) {
665
- spinner.start()
666
- if (githubOidcAvailable) {
667
- spinner.text = "Requesting OIDC token from GitHub Actions..."
668
- } else {
669
- spinner.text = "Exchanging GitLab CI OIDC token..."
670
- }
671
- try {
672
- const oidcToken = githubOidcAvailable
673
- ? await exchangeOidcToken(name)
674
- : await exchangeGitLabOidcToken(name)
675
- setTransientToken(oidcToken)
676
- spinner.stop()
677
- const provider = githubOidcAvailable ? "GitHub Actions" : "GitLab CI"
678
- log.success(
679
- `OIDC authentication successful (${provider} — no secrets needed)`,
680
- )
681
- } catch (err) {
682
- spinner.stop()
683
- if (forceProvenance) {
684
- // When --provenance is explicit, a failure is fatal
685
- throw new Error(`OIDC exchange failed: ${err.message}`)
686
- }
687
- // Auto-detection: non-fatal, fall back to configured credentials
688
- log.warn(`OIDC exchange failed: ${err.message}`)
689
- log.warn("Falling back to stored credentials...")
690
- }
691
- }
692
-
693
- // 9. Verify authentication and owner permissions
694
- spinner.start()
695
- spinner.text = "Verifying authentication..."
696
- const scopeResult = await verifyTokenScope("publish")
697
-
698
- if (!scopeResult.valid) {
699
- throw new Error(scopeResult.error)
700
- }
701
-
702
- spinner.text = "Checking owner permissions..."
703
-
704
- const whoamiResponse = await request("/-/whoami")
705
- if (whoamiResponse.ok) {
706
- whoami = await whoamiResponse.json()
707
-
708
- const availableOwners = []
709
- if (whoami.profile_username) {
710
- availableOwners.push(whoami.profile_username)
711
- }
712
- whoami.organizations?.forEach(org => {
713
- availableOwners.push(org.slug)
714
- })
715
-
716
- if (!availableOwners.includes(owner)) {
717
- spinner.stop()
718
- const registryUrl = getRegistryUrl()
719
-
720
- log.error(
721
- `You don't have permission to publish under "@lpm.dev/${owner}".`,
722
- )
723
- console.log("")
724
-
725
- if (!whoami.profile_username) {
726
- log.warn(WARNING_MESSAGES.usernameNotSet)
727
- log.warn(` Set it at: ${registryUrl}/dashboard/settings`)
728
- console.log("")
729
- }
730
-
731
- if (whoami.organizations?.length > 0) {
732
- log.info("Your available owners:")
733
- if (whoami.profile_username) {
734
- log.info(` @lpm.dev/${whoami.profile_username}.* (personal)`)
735
- }
736
- for (const org of whoami.organizations) {
737
- log.info(` @lpm.dev/${org.slug}.* (organization)`)
738
- }
739
- } else {
740
- log.warn(WARNING_MESSAGES.noOrganizations)
741
- log.warn(WARNING_MESSAGES.createOrgHint(registryUrl))
742
- }
743
-
744
- console.log("")
745
- log.info(WARNING_MESSAGES.ownerFixHint)
746
- process.exit(1)
747
- }
748
- }
749
-
750
- // 9.5. Pre-upload 2FA check — prompt for OTP BEFORE uploading the tarball
751
- // This avoids uploading the entire tarball twice for large packages.
752
- let otpCode = null
753
- if (!oidcAvailable && whoami) {
754
- const userHas2fa = whoami.mfa_enabled
755
- const publishingOrg = whoami.organizations?.find(
756
- org => org.slug === owner,
757
- )
758
- const orgRequires2fa = publishingOrg?.require_2fa
759
-
760
- if (orgRequires2fa && !userHas2fa) {
761
- spinner.stop()
762
- log.error(
763
- "This organization requires two-factor authentication. Enable 2FA in your account settings.",
764
- )
765
- process.exit(1)
766
- }
767
-
768
- if (userHas2fa || orgRequires2fa) {
769
- spinner.stop()
770
- otpCode = await p.text({
771
- message: "Enter 2FA code",
772
- placeholder: "123456",
773
- validate: value => {
774
- if (!/^\d{6}$/.test(value))
775
- return "Enter a 6-digit code from your authenticator app"
776
- },
777
- })
778
-
779
- if (p.isCancel(otpCode)) {
780
- p.cancel("Publish cancelled.")
781
- process.exit(0)
782
- }
783
- spinner.start()
784
- }
785
- }
786
-
787
- // 10. Read tarball and generate integrity hashes
788
- spinner.text = "Reading tarball..."
789
- const tarballData = await readFileAsync(tarballPath)
790
- const tarballBase64 = tarballData.toString("base64")
791
- const shasum = createHash("sha1").update(tarballData).digest("hex")
792
- const integrity = generateIntegrity(tarballData, "sha512")
793
-
794
- // 11. Build version metadata
795
- const versionData = {
796
- ...pkg,
797
- _id: `${name}@${version}`,
798
- name: name,
799
- version: version,
800
- readme: readme,
801
- dist: {
802
- shasum: shasum,
803
- integrity: integrity,
804
- tarball: `${getRegistryUrl()}/api/registry/${name}/-/${name}-${version}.tgz`,
805
- },
806
- _npmPackMeta: npmPackMeta,
807
- ...(lpmConfig && { _lpmConfig: lpmConfig }),
808
- _qualityChecks: qualityResult.checks,
809
- _qualityMeta: qualityResult.meta,
810
- }
811
-
812
- // Add ecosystem-specific metadata
813
- if (ecosystem !== "js") {
814
- versionData._ecosystem = ecosystem
815
- }
816
-
817
- if (swiftManifest) {
818
- versionData._swiftManifest = swiftManifest
819
- }
820
-
821
- if (xcFramework?.found) {
822
- versionData._packageType = "xcframework"
823
- versionData._xcframeworkMeta = {
824
- name: xcFramework.name,
825
- slices: xcFramework.slices,
826
- formatVersion: xcFramework.formatVersion,
827
- }
828
- }
829
-
830
- // 12. Upload to registry
831
- spinner.text = `Uploading ${name}@${version}...`
832
- const payload = {
833
- _id: name,
834
- name: name,
835
- description: pkg.description,
836
- "dist-tags": {
837
- latest: version,
838
- },
839
- versions: {
840
- [version]: versionData,
841
- },
842
- _attachments: {
843
- [`${name}-${version}.tgz`]: {
844
- content_type: "application/octet-stream",
845
- data: tarballBase64,
846
- length: tarballData.length,
847
- },
848
- },
849
- }
850
-
851
- // Include ecosystem in top-level payload for API to store in packageSettings
852
- if (ecosystem !== "js") {
853
- payload._ecosystem = ecosystem
854
- }
855
-
856
- const publishHeaders = {
857
- "Content-Type": "application/json",
858
- }
859
-
860
- // Include OTP header if 2FA code was collected pre-upload
861
- if (otpCode) {
862
- publishHeaders["x-otp"] = otpCode
863
- }
864
-
865
- const response = await request(`/${encodeURIComponent(name)}`, {
866
- method: "PUT",
867
- body: JSON.stringify(payload),
868
- headers: publishHeaders,
869
- onRetry: (attempt, max) => {
870
- spinner.text = `Uploading to registry (retry ${attempt}/${max})...`
871
- },
872
- })
873
-
874
- if (!response.ok) {
875
- const errorText = await response.text()
876
- throw new Error(`Publish failed: ${response.status} ${errorText}`)
877
- }
878
-
879
- // Parse response for warnings (e.g., skills staleness)
880
- let publishResult = {}
881
- try {
882
- publishResult = await response.json()
883
- } catch {
884
- // Response may not be JSON - that's fine
885
- }
886
-
887
- // Success message with dashboard link
888
- const registryUrl = getRegistryUrl()
889
- const isOrgOwner = whoami?.organizations?.some(org => org.slug === owner)
890
-
891
- if (isOrgOwner) {
892
- spinner.succeed(
893
- SUCCESS_MESSAGES.publishOrg(registryUrl, owner, packageName, version),
894
- )
895
- } else {
896
- spinner.succeed(
897
- SUCCESS_MESSAGES.publishPersonal(
898
- registryUrl,
899
- owner,
900
- packageName,
901
- version,
902
- ),
903
- )
904
- }
905
-
906
- // Server may still return warnings for edge cases not caught client-side
907
- if (publishResult.warnings?.length > 0) {
908
- const newWarnings = publishResult.warnings.filter(
909
- w => !prePublishWarnings.includes(w),
910
- )
911
- if (newWarnings.length > 0) {
912
- console.log("")
913
- for (const warning of newWarnings) {
914
- log.warn(warning)
915
- }
916
- }
917
- }
918
- } catch (error) {
919
- spinner.fail(`Publish error: ${error.message}`)
920
-
921
- // Show upgrade link for personal account limit errors
922
- const registryUrl = getRegistryUrl()
923
- const isLimitError =
924
- error.message.includes("limit exceeded") ||
925
- error.message.includes("Upgrade to Pro")
926
-
927
- if (isLimitError) {
928
- const isOrgOwner = whoami?.organizations?.some(org => org.slug === owner)
929
- if (!isOrgOwner) {
930
- console.log("")
931
- log.info(`Upgrade plan: ${registryUrl}/dashboard/settings/billing`)
932
- }
933
- }
934
-
935
- process.exit(1)
936
- } finally {
937
- // Clear OIDC transient token so it doesn't leak into subsequent operations
938
- clearTransientToken()
939
-
940
- // Cleanup tarball (even on error)
941
- if (tarballPath && fs.existsSync(tarballPath)) {
942
- try {
943
- fs.unlinkSync(tarballPath)
944
- } catch {
945
- // Ignore cleanup errors
946
- }
947
- }
948
- }
949
- }