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