@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,707 @@
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 { getRegistryUrl } from "../config.js"
9
+ import { SUCCESS_MESSAGES, WARNING_MESSAGES } from "../constants.js"
10
+ import {
11
+ createEcosystemTarball,
12
+ detectEcosystem,
13
+ detectXCFramework,
14
+ extractSwiftMetadata,
15
+ readSwiftManifest,
16
+ } from "../ecosystem.js"
17
+ import { generateIntegrity } from "../integrity.js"
18
+ import { displayQualityReport } from "../quality/display.js"
19
+ import { runQualityChecks } from "../quality/score.js"
20
+ import { createSpinner, log, printHeader } from "../ui.js"
21
+
22
+ const execAsync = promisify(exec)
23
+ const readFileAsync = promisify(fs.readFile)
24
+
25
+ /**
26
+ * Parse package name in the @lpm.dev/owner.package format
27
+ * @returns {{ owner: string, pkgName: string } | { error: string }}
28
+ */
29
+ function parsePackageName(name) {
30
+ // New format: @lpm.dev/owner.package-name
31
+ if (name.startsWith("@lpm.dev/")) {
32
+ const nameWithOwner = name.replace("@lpm.dev/", "")
33
+ const dotIndex = nameWithOwner.indexOf(".")
34
+ if (dotIndex === -1) {
35
+ return { error: "Invalid format. Expected @lpm.dev/owner.package-name" }
36
+ }
37
+ return {
38
+ owner: nameWithOwner.substring(0, dotIndex),
39
+ pkgName: nameWithOwner.substring(dotIndex + 1),
40
+ }
41
+ }
42
+
43
+ // Legacy format: @scope/package-name
44
+ if (name.startsWith("@")) {
45
+ const match = name.match(/^@([^/]+)\/(.+)$/)
46
+ if (match) {
47
+ return {
48
+ owner: match[1],
49
+ pkgName: match[2],
50
+ isLegacy: true,
51
+ }
52
+ }
53
+ }
54
+
55
+ return {
56
+ error: "Invalid package name. Use @lpm.dev/owner.package-name format",
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Run the interactive init flow for non-JS packages.
62
+ * Generates a minimal package.json with name, version, and description.
63
+ *
64
+ * @param {string} ecosystem - Detected ecosystem
65
+ * @returns {Promise<object>} Generated package.json contents
66
+ */
67
+ async function initNonJsPackage(ecosystem) {
68
+ log.info(`Detected ${ecosystem} project without package.json.`)
69
+ log.info("Creating package metadata for LPM registry...")
70
+ console.log("")
71
+
72
+ // Get username from auth to auto-prefix owner
73
+ const whoamiResponse = await request("/-/whoami")
74
+ if (!whoamiResponse.ok) {
75
+ throw new Error("Could not determine your username. Run `lpm login` first.")
76
+ }
77
+ const whoami = await whoamiResponse.json()
78
+
79
+ // Build available owners list
80
+ const availableOwners = []
81
+ if (whoami.profile_username) {
82
+ availableOwners.push({
83
+ value: whoami.profile_username,
84
+ label: `${whoami.profile_username} (personal)`,
85
+ })
86
+ }
87
+ for (const org of whoami.organizations || []) {
88
+ availableOwners.push({
89
+ value: org.slug,
90
+ label: `${org.slug} (organization)`,
91
+ })
92
+ }
93
+
94
+ if (availableOwners.length === 0) {
95
+ throw new Error(
96
+ "No available owners. Set your username at the dashboard or create an organization.",
97
+ )
98
+ }
99
+
100
+ // Select owner
101
+ let owner
102
+ if (availableOwners.length === 1) {
103
+ owner = availableOwners[0].value
104
+ } else {
105
+ const selected = await p.select({
106
+ message: "Publish under which owner?",
107
+ options: availableOwners,
108
+ })
109
+ if (p.isCancel(selected)) {
110
+ p.cancel("Cancelled.")
111
+ process.exit(0)
112
+ }
113
+ owner = selected
114
+ }
115
+
116
+ // Suggest package name from directory name
117
+ const dirName = path
118
+ .basename(process.cwd())
119
+ .toLowerCase()
120
+ .replace(/[^a-z0-9-]/g, "-")
121
+ const packageNameInput = await p.text({
122
+ message: "Package name",
123
+ placeholder: dirName,
124
+ defaultValue: dirName,
125
+ validate: value => {
126
+ if (!value) return "Package name is required"
127
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(value))
128
+ return "Must start with a letter/number and contain only lowercase letters, numbers, and hyphens"
129
+ },
130
+ })
131
+ if (p.isCancel(packageNameInput)) {
132
+ p.cancel("Cancelled.")
133
+ process.exit(0)
134
+ }
135
+ const packageName = packageNameInput || dirName
136
+
137
+ const versionInput = await p.text({
138
+ message: "Version",
139
+ placeholder: "1.0.0",
140
+ defaultValue: "1.0.0",
141
+ validate: value => {
142
+ if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(value))
143
+ return "Must be valid semver (e.g. 1.0.0)"
144
+ },
145
+ })
146
+ if (p.isCancel(versionInput)) {
147
+ p.cancel("Cancelled.")
148
+ process.exit(0)
149
+ }
150
+
151
+ const description = await p.text({
152
+ message: "Description (optional)",
153
+ placeholder: "",
154
+ })
155
+ if (p.isCancel(description)) {
156
+ p.cancel("Cancelled.")
157
+ process.exit(0)
158
+ }
159
+
160
+ const fullName = `@lpm.dev/${owner}.${packageName}`
161
+ const pkg = {
162
+ name: fullName,
163
+ version: versionInput || "1.0.0",
164
+ }
165
+ if (description) {
166
+ pkg.description = description
167
+ }
168
+
169
+ // Write package.json
170
+ const packageJsonPath = path.resolve(process.cwd(), "package.json")
171
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`)
172
+ log.success(`Created package.json for ${fullName}`)
173
+ console.log("")
174
+
175
+ return pkg
176
+ }
177
+
178
+ /**
179
+ * Read README from the current directory.
180
+ * Searches for common README filenames.
181
+ *
182
+ * @returns {string|null}
183
+ */
184
+ function readReadme() {
185
+ const readmeFilenames = [
186
+ "README.md",
187
+ "readme.md",
188
+ "README",
189
+ "Readme.md",
190
+ "README.txt",
191
+ ]
192
+
193
+ for (const filename of readmeFilenames) {
194
+ const readmePath = path.resolve(process.cwd(), filename)
195
+
196
+ if (!readmePath.startsWith(process.cwd())) {
197
+ continue
198
+ }
199
+
200
+ if (fs.existsSync(readmePath)) {
201
+ try {
202
+ const stats = fs.statSync(readmePath)
203
+
204
+ const MAX_README_SIZE = 1024 * 1024
205
+ if (stats.size > MAX_README_SIZE) {
206
+ log.warn(
207
+ `README file is too large (${(stats.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB. Skipping README.`,
208
+ )
209
+ return null
210
+ }
211
+
212
+ const readmeBuffer = fs.readFileSync(readmePath)
213
+
214
+ const isBinary = readmeBuffer.some(
215
+ byte =>
216
+ byte === 0 ||
217
+ (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13),
218
+ )
219
+ if (isBinary) {
220
+ log.warn("README appears to be binary. Skipping.")
221
+ return null
222
+ }
223
+
224
+ let readme = readmeBuffer.toString("utf8").trim()
225
+
226
+ if (readme.length > MAX_README_SIZE) {
227
+ readme = readme.substring(0, MAX_README_SIZE)
228
+ log.warn("README truncated to 1MB.")
229
+ }
230
+
231
+ return readme
232
+ } catch (_err) {
233
+ // Continue to next filename
234
+ }
235
+ }
236
+ }
237
+
238
+ return null
239
+ }
240
+
241
+ /**
242
+ * Read lpm.config.json from the current directory.
243
+ *
244
+ * @returns {object|null}
245
+ */
246
+ function readLpmConfig() {
247
+ const lpmConfigPath = path.resolve(process.cwd(), "lpm.config.json")
248
+ if (fs.existsSync(lpmConfigPath)) {
249
+ try {
250
+ const lpmConfigRaw = fs.readFileSync(lpmConfigPath, "utf-8")
251
+ return JSON.parse(lpmConfigRaw)
252
+ } catch (_err) {
253
+ log.warn("Could not parse lpm.config.json. Skipping.")
254
+ }
255
+ }
256
+ return null
257
+ }
258
+
259
+ /**
260
+ * Pack using npm pack (JS ecosystem).
261
+ *
262
+ * @returns {Promise<{ tarballPath: string, npmPackMeta: object }>}
263
+ */
264
+ async function packWithNpm() {
265
+ const { stdout } = await execAsync("npm pack --json")
266
+ const packResult = JSON.parse(stdout)
267
+ const packInfo = packResult[0]
268
+ const tarballFilename = packInfo.filename
269
+ const tarballPath = path.resolve(process.cwd(), tarballFilename)
270
+
271
+ return {
272
+ tarballPath,
273
+ npmPackMeta: {
274
+ unpackedSize: packInfo.unpackedSize,
275
+ fileCount: packInfo.files?.length || 0,
276
+ files: packInfo.files || [],
277
+ },
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Pack using tar (non-JS ecosystems).
283
+ *
284
+ * @param {string} ecosystem - Ecosystem identifier
285
+ * @param {string} name - Package name
286
+ * @param {string} version - Package version
287
+ * @returns {Promise<{ tarballPath: string, npmPackMeta: object }>}
288
+ */
289
+ async function packWithTar(ecosystem, name, version) {
290
+ const result = await createEcosystemTarball(ecosystem, name, version)
291
+ return {
292
+ tarballPath: result.tarballPath,
293
+ npmPackMeta: {
294
+ unpackedSize: result.unpackedSize,
295
+ fileCount: result.fileCount,
296
+ files: result.files,
297
+ },
298
+ }
299
+ }
300
+
301
+ export async function publish(options = {}) {
302
+ const checkOnly = !!options.check
303
+ const minScore = options.minScore ? parseInt(options.minScore, 10) : null
304
+
305
+ printHeader()
306
+
307
+ // 1. Detect ecosystem
308
+ const { ecosystem, manifestFile } = detectEcosystem()
309
+
310
+ if (!ecosystem) {
311
+ log.error(
312
+ "No recognized project manifest found (package.json, Package.swift, Cargo.toml, pyproject.toml).",
313
+ )
314
+ process.exit(1)
315
+ }
316
+
317
+ if (ecosystem !== "js") {
318
+ log.info(`Detected ${ecosystem} project (${manifestFile})`)
319
+ }
320
+
321
+ // 2. Read or generate package.json
322
+ const packageJsonPath = path.resolve(process.cwd(), "package.json")
323
+ let pkg
324
+ let _isNewPackage = false
325
+
326
+ if (ecosystem !== "js" && !fs.existsSync(packageJsonPath)) {
327
+ // Non-JS project without package.json — run init flow
328
+ pkg = await initNonJsPackage(ecosystem)
329
+ _isNewPackage = true
330
+ } else if (!fs.existsSync(packageJsonPath)) {
331
+ log.error("No package.json found in current directory.")
332
+ process.exit(1)
333
+ } else {
334
+ try {
335
+ pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
336
+ } catch (err) {
337
+ log.error(`Invalid JSON in package.json: ${err.message}`)
338
+ process.exit(1)
339
+ }
340
+ }
341
+
342
+ const { name, version } = pkg
343
+
344
+ // Parse package name to extract owner
345
+ const parsed = parsePackageName(name)
346
+ if (parsed.error) {
347
+ log.error(parsed.error)
348
+ log.info("LPM packages must use format: @lpm.dev/owner.package-name")
349
+ log.info(`Your current name: ${name}`)
350
+
351
+ // Suggest fix for legacy format
352
+ const oldMatch = name.match(/^@([^/]+)\/(.+)$/)
353
+ if (oldMatch) {
354
+ const suggested = `@lpm.dev/${oldMatch[1]}.${oldMatch[2]}`
355
+ log.info(`Suggested: ${suggested}`)
356
+ }
357
+ process.exit(1)
358
+ }
359
+
360
+ const { owner, pkgName: packageName, isLegacy } = parsed
361
+
362
+ // Warn about legacy format
363
+ if (isLegacy) {
364
+ log.warn(`Legacy format detected: ${name}`)
365
+ log.warn(`Please migrate to: @lpm.dev/${owner}.${packageName}`)
366
+ console.log("")
367
+ }
368
+
369
+ const spinner = createSpinner("Preparing to publish...").start()
370
+
371
+ // Track tarball path for cleanup in finally block
372
+ let tarballPath = null
373
+ // Hoist whoami for success message
374
+ let whoami = null
375
+
376
+ try {
377
+ // 3. Read ecosystem-specific manifest data
378
+ let swiftManifest = null
379
+ let xcFramework = null
380
+
381
+ if (ecosystem === "swift") {
382
+ // Read Swift Package manifest
383
+ spinner.text = "Reading Package.swift..."
384
+ try {
385
+ const rawManifest = await readSwiftManifest()
386
+ swiftManifest = extractSwiftMetadata(rawManifest)
387
+ } catch (err) {
388
+ spinner.stop()
389
+ log.warn(`Could not read Package.swift: ${err.message}`)
390
+ log.info("Publishing without Swift manifest data.")
391
+ spinner.start()
392
+ }
393
+
394
+ // Detect XCFramework
395
+ xcFramework = detectXCFramework()
396
+ if (xcFramework.found) {
397
+ if (!xcFramework.hasInfoPlist) {
398
+ spinner.stop()
399
+ log.warn(
400
+ `XCFramework "${xcFramework.name}" found but missing Info.plist. Treating as source package.`,
401
+ )
402
+ spinner.start()
403
+ xcFramework = { found: false }
404
+ } else {
405
+ spinner.stop()
406
+ log.info(`XCFramework detected: ${xcFramework.name}`)
407
+ spinner.start()
408
+ }
409
+ }
410
+ }
411
+
412
+ // 4. Create tarball
413
+ spinner.text = "Packing tarball..."
414
+ let npmPackMeta
415
+
416
+ if (ecosystem === "js") {
417
+ const packResult = await packWithNpm()
418
+ tarballPath = packResult.tarballPath
419
+ npmPackMeta = packResult.npmPackMeta
420
+ } else {
421
+ const packResult = await packWithTar(ecosystem, name, version)
422
+ tarballPath = packResult.tarballPath
423
+ npmPackMeta = packResult.npmPackMeta
424
+ }
425
+
426
+ // 4b. Check if local skills exist but are missing from tarball
427
+ const localSkillsDir = path.join(process.cwd(), ".lpm", "skills")
428
+ if (fs.existsSync(localSkillsDir)) {
429
+ const fileList = npmPackMeta.files || []
430
+ const hasSkillsInTarball = fileList.some(f => {
431
+ const filePath = f.path || f
432
+ return (
433
+ filePath.includes(".lpm/skills/") ||
434
+ filePath.includes(".lpm\\skills\\")
435
+ )
436
+ })
437
+ if (!hasSkillsInTarball) {
438
+ spinner.stop()
439
+ log.warn(
440
+ "Found .lpm/skills/ directory but no skill files in the tarball.",
441
+ )
442
+ log.info(
443
+ 'If using "files" in package.json, add ".lpm" to include skills.',
444
+ )
445
+ console.log("")
446
+ spinner.start()
447
+ }
448
+ }
449
+
450
+ // 5. Read README
451
+ spinner.text = "Reading README..."
452
+ const readme = readReadme()
453
+
454
+ // 6. Read lpm.config.json if present
455
+ const lpmConfig = readLpmConfig()
456
+
457
+ // 7. Run quality checks and display report
458
+ spinner.text = "Running quality checks..."
459
+ const qualityResult = runQualityChecks({
460
+ packageJson: pkg,
461
+ readme,
462
+ lpmConfig,
463
+ files: npmPackMeta.files || [],
464
+ unpackedSize: npmPackMeta.unpackedSize,
465
+ ecosystem,
466
+ swiftManifest,
467
+ })
468
+
469
+ spinner.stop()
470
+ displayQualityReport(qualityResult)
471
+
472
+ // --check mode: display report and exit
473
+ if (checkOnly) {
474
+ if (minScore && qualityResult.score < minScore) {
475
+ log.error(
476
+ `Quality score ${qualityResult.score} is below minimum ${minScore}.`,
477
+ )
478
+ process.exit(1)
479
+ }
480
+ process.exit(0)
481
+ }
482
+
483
+ // --min-score gate: block publish if score is too low
484
+ if (minScore && qualityResult.score < minScore) {
485
+ log.error(
486
+ `Quality score ${qualityResult.score} is below minimum ${minScore}. Publish blocked.`,
487
+ )
488
+ log.info('Run "lpm publish --check" to see improvement suggestions.')
489
+ process.exit(1)
490
+ }
491
+
492
+ // 8. Confirmation prompt (after quality report so user can decide)
493
+ const shouldPublish = await p.confirm({
494
+ message: `Publish ${name}@${version}?`,
495
+ initialValue: true,
496
+ })
497
+
498
+ if (p.isCancel(shouldPublish) || !shouldPublish) {
499
+ p.cancel("Publish cancelled.")
500
+ process.exit(0)
501
+ }
502
+
503
+ // 9. Verify authentication and owner permissions
504
+ spinner.start()
505
+ spinner.text = "Verifying authentication..."
506
+ const scopeResult = await verifyTokenScope("publish")
507
+
508
+ if (!scopeResult.valid) {
509
+ throw new Error(scopeResult.error)
510
+ }
511
+
512
+ spinner.text = "Checking owner permissions..."
513
+
514
+ const whoamiResponse = await request("/-/whoami")
515
+ if (whoamiResponse.ok) {
516
+ whoami = await whoamiResponse.json()
517
+
518
+ const availableOwners = []
519
+ if (whoami.profile_username) {
520
+ availableOwners.push(whoami.profile_username)
521
+ }
522
+ whoami.organizations?.forEach(org => {
523
+ availableOwners.push(org.slug)
524
+ })
525
+
526
+ if (!availableOwners.includes(owner)) {
527
+ spinner.stop()
528
+ const registryUrl = getRegistryUrl()
529
+
530
+ log.error(
531
+ `You don't have permission to publish under "@lpm.dev/${owner}".`,
532
+ )
533
+ console.log("")
534
+
535
+ if (!whoami.profile_username) {
536
+ log.warn(WARNING_MESSAGES.usernameNotSet)
537
+ log.warn(` Set it at: ${registryUrl}/dashboard/settings`)
538
+ console.log("")
539
+ }
540
+
541
+ if (whoami.organizations?.length > 0) {
542
+ log.info("Your available owners:")
543
+ if (whoami.profile_username) {
544
+ log.info(` @lpm.dev/${whoami.profile_username}.* (personal)`)
545
+ }
546
+ for (const org of whoami.organizations) {
547
+ log.info(` @lpm.dev/${org.slug}.* (organization)`)
548
+ }
549
+ } else {
550
+ log.warn(WARNING_MESSAGES.noOrganizations)
551
+ log.warn(WARNING_MESSAGES.createOrgHint(registryUrl))
552
+ }
553
+
554
+ console.log("")
555
+ log.info(WARNING_MESSAGES.ownerFixHint)
556
+ process.exit(1)
557
+ }
558
+ }
559
+
560
+ // 10. Read tarball and generate integrity hashes
561
+ spinner.text = "Reading tarball..."
562
+ const tarballData = await readFileAsync(tarballPath)
563
+ const tarballBase64 = tarballData.toString("base64")
564
+ const shasum = createHash("sha1").update(tarballData).digest("hex")
565
+ const integrity = generateIntegrity(tarballData, "sha512")
566
+
567
+ // 11. Build version metadata
568
+ const versionData = {
569
+ ...pkg,
570
+ _id: `${name}@${version}`,
571
+ name: name,
572
+ version: version,
573
+ readme: readme,
574
+ dist: {
575
+ shasum: shasum,
576
+ integrity: integrity,
577
+ tarball: `${getRegistryUrl()}/api/registry/${name}/-/${name}-${version}.tgz`,
578
+ },
579
+ _npmPackMeta: npmPackMeta,
580
+ ...(lpmConfig && { _lpmConfig: lpmConfig }),
581
+ _qualityChecks: qualityResult.checks,
582
+ _qualityMeta: qualityResult.meta,
583
+ }
584
+
585
+ // Add ecosystem-specific metadata
586
+ if (ecosystem !== "js") {
587
+ versionData._ecosystem = ecosystem
588
+ }
589
+
590
+ if (swiftManifest) {
591
+ versionData._swiftManifest = swiftManifest
592
+ }
593
+
594
+ if (xcFramework?.found) {
595
+ versionData._packageType = "xcframework"
596
+ versionData._xcframeworkMeta = {
597
+ name: xcFramework.name,
598
+ slices: xcFramework.slices,
599
+ formatVersion: xcFramework.formatVersion,
600
+ }
601
+ }
602
+
603
+ // 12. Upload to registry
604
+ spinner.text = `Uploading ${name}@${version}...`
605
+ const payload = {
606
+ _id: name,
607
+ name: name,
608
+ description: pkg.description,
609
+ "dist-tags": {
610
+ latest: version,
611
+ },
612
+ versions: {
613
+ [version]: versionData,
614
+ },
615
+ _attachments: {
616
+ [`${name}-${version}.tgz`]: {
617
+ content_type: "application/octet-stream",
618
+ data: tarballBase64,
619
+ length: tarballData.length,
620
+ },
621
+ },
622
+ }
623
+
624
+ // Include ecosystem in top-level payload for API to store in packageSettings
625
+ if (ecosystem !== "js") {
626
+ payload._ecosystem = ecosystem
627
+ }
628
+
629
+ const response = await request(`/${encodeURIComponent(name)}`, {
630
+ method: "PUT",
631
+ body: JSON.stringify(payload),
632
+ headers: {
633
+ "Content-Type": "application/json",
634
+ },
635
+ onRetry: (attempt, max) => {
636
+ spinner.text = `Uploading to registry (retry ${attempt}/${max})...`
637
+ },
638
+ })
639
+
640
+ if (!response.ok) {
641
+ const errorText = await response.text()
642
+ throw new Error(`Publish failed: ${response.status} ${errorText}`)
643
+ }
644
+
645
+ // Parse response for warnings (e.g., skills staleness)
646
+ let publishResult = {}
647
+ try {
648
+ publishResult = await response.json()
649
+ } catch {
650
+ // Response may not be JSON - that's fine
651
+ }
652
+
653
+ // Success message with dashboard link
654
+ const registryUrl = getRegistryUrl()
655
+ const isOrgOwner = whoami?.organizations?.some(org => org.slug === owner)
656
+
657
+ if (isOrgOwner) {
658
+ spinner.succeed(
659
+ SUCCESS_MESSAGES.publishOrg(registryUrl, owner, packageName, version),
660
+ )
661
+ } else {
662
+ spinner.succeed(
663
+ SUCCESS_MESSAGES.publishPersonal(
664
+ registryUrl,
665
+ owner,
666
+ packageName,
667
+ version,
668
+ ),
669
+ )
670
+ }
671
+
672
+ // Show any server warnings (e.g., stale skills)
673
+ if (publishResult.warnings?.length > 0) {
674
+ console.log("")
675
+ for (const warning of publishResult.warnings) {
676
+ log.warn(warning)
677
+ }
678
+ }
679
+ } catch (error) {
680
+ spinner.fail(`Publish error: ${error.message}`)
681
+
682
+ // Show upgrade link for personal account limit errors
683
+ const registryUrl = getRegistryUrl()
684
+ const isLimitError =
685
+ error.message.includes("limit exceeded") ||
686
+ error.message.includes("Upgrade to Pro")
687
+
688
+ if (isLimitError) {
689
+ const isOrgOwner = whoami?.organizations?.some(org => org.slug === owner)
690
+ if (!isOrgOwner) {
691
+ console.log("")
692
+ log.info(`Upgrade plan: ${registryUrl}/dashboard/settings/billing`)
693
+ }
694
+ }
695
+
696
+ process.exit(1)
697
+ } finally {
698
+ // Cleanup tarball (even on error)
699
+ if (tarballPath && fs.existsSync(tarballPath)) {
700
+ try {
701
+ fs.unlinkSync(tarballPath)
702
+ } catch {
703
+ // Ignore cleanup errors
704
+ }
705
+ }
706
+ }
707
+ }