@lpm-registry/cli 0.2.2 → 0.2.4
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 +12 -0
- package/lib/api.js +20 -1
- package/lib/commands/config.js +14 -2
- package/lib/commands/publish.js +47 -3
- package/lib/quality/checks.js +36 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.4] - 2026-03-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **2FA support for publish** — CLI prompts for TOTP code before uploading when user or org has 2FA enabled. OIDC (CI/CD) tokens are exempt.
|
|
10
|
+
- **Lifecycle scripts quality check** — New `no-lifecycle-scripts` check (2pts) flags packages with `preinstall`, `install`, `postinstall`, `preuninstall`, `uninstall`, or `postuninstall` scripts
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Quality scoring rebalanced: `small-deps` 4pts→3pts, `has-exports-map` 4pts→3pts to accommodate the new lifecycle scripts check (total remains 100)
|
|
15
|
+
- API client now passes through OTP-related 401/403 responses to callers instead of throwing generic auth errors
|
|
16
|
+
|
|
5
17
|
## [0.2.2] - 2026-03-16
|
|
6
18
|
|
|
7
19
|
### Added
|
package/lib/api.js
CHANGED
|
@@ -114,12 +114,31 @@ export async function request(path, options = {}) {
|
|
|
114
114
|
clearTimeout(timeoutId)
|
|
115
115
|
|
|
116
116
|
// Handle authentication errors immediately (no retry)
|
|
117
|
+
// Exception: 2FA OTP responses (401 with OTP_REQUIRED/OTP_INVALID code)
|
|
118
|
+
// must be returned to the caller so the publish handler can prompt for OTP
|
|
117
119
|
if (response.status === 401) {
|
|
120
|
+
const cloned = response.clone()
|
|
121
|
+
let data = {}
|
|
122
|
+
try {
|
|
123
|
+
data = await cloned.json()
|
|
124
|
+
} catch {
|
|
125
|
+
// Not JSON
|
|
126
|
+
}
|
|
127
|
+
if (data.code === "OTP_REQUIRED" || data.code === "OTP_INVALID") {
|
|
128
|
+
return response
|
|
129
|
+
}
|
|
118
130
|
throw new Error(ERROR_MESSAGES.notAuthenticated)
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
if (response.status === 403) {
|
|
122
|
-
const
|
|
134
|
+
const cloned403 = response.clone()
|
|
135
|
+
const data = await cloned403.json().catch(() => ({}))
|
|
136
|
+
|
|
137
|
+
// 2FA enforcement responses must be returned to the caller
|
|
138
|
+
if (data.code === "ORG_2FA_REQUIRED") {
|
|
139
|
+
return response
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
const errorMessage = data.error || "Access denied."
|
|
124
143
|
|
|
125
144
|
// Check for license/purchase required error
|
package/lib/commands/config.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
deleteConfigValue,
|
|
23
23
|
getAllConfig,
|
|
24
24
|
getConfigValue,
|
|
25
|
+
getToken,
|
|
25
26
|
setConfigValue,
|
|
26
27
|
} from "../config.js"
|
|
27
28
|
|
|
@@ -59,7 +60,18 @@ async function listConfig() {
|
|
|
59
60
|
* Get a specific configuration value.
|
|
60
61
|
* @param {string} key - The config key to get
|
|
61
62
|
*/
|
|
62
|
-
function getConfig(key) {
|
|
63
|
+
async function getConfig(key) {
|
|
64
|
+
// Token lives in secure storage, not the general config store
|
|
65
|
+
if (key === "token") {
|
|
66
|
+
const token = await getToken()
|
|
67
|
+
if (!token) {
|
|
68
|
+
console.log(chalk.yellow("No token found. Run `lpm login` first."))
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
console.log(token)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
const value = getConfigValue(key)
|
|
64
76
|
|
|
65
77
|
if (value === undefined) {
|
|
@@ -141,7 +153,7 @@ export async function config(action, key, value) {
|
|
|
141
153
|
console.log(chalk.dim("Usage: lpm config get <key>"))
|
|
142
154
|
process.exit(1)
|
|
143
155
|
}
|
|
144
|
-
getConfig(key)
|
|
156
|
+
await getConfig(key)
|
|
145
157
|
break
|
|
146
158
|
|
|
147
159
|
case "set":
|
package/lib/commands/publish.js
CHANGED
|
@@ -692,6 +692,43 @@ export async function publish(options = {}) {
|
|
|
692
692
|
}
|
|
693
693
|
}
|
|
694
694
|
|
|
695
|
+
// 9.5. Pre-upload 2FA check — prompt for OTP BEFORE uploading the tarball
|
|
696
|
+
// This avoids uploading the entire tarball twice for large packages.
|
|
697
|
+
let otpCode = null
|
|
698
|
+
if (!oidcAvailable && whoami) {
|
|
699
|
+
const userHas2fa = whoami.mfa_enabled
|
|
700
|
+
const publishingOrg = whoami.organizations?.find(
|
|
701
|
+
org => org.slug === owner,
|
|
702
|
+
)
|
|
703
|
+
const orgRequires2fa = publishingOrg?.require_2fa
|
|
704
|
+
|
|
705
|
+
if (orgRequires2fa && !userHas2fa) {
|
|
706
|
+
spinner.stop()
|
|
707
|
+
log.error(
|
|
708
|
+
"This organization requires two-factor authentication. Enable 2FA in your account settings.",
|
|
709
|
+
)
|
|
710
|
+
process.exit(1)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (userHas2fa || orgRequires2fa) {
|
|
714
|
+
spinner.stop()
|
|
715
|
+
otpCode = await p.text({
|
|
716
|
+
message: "Enter 2FA code",
|
|
717
|
+
placeholder: "123456",
|
|
718
|
+
validate: value => {
|
|
719
|
+
if (!/^\d{6}$/.test(value))
|
|
720
|
+
return "Enter a 6-digit code from your authenticator app"
|
|
721
|
+
},
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
if (p.isCancel(otpCode)) {
|
|
725
|
+
p.cancel("Publish cancelled.")
|
|
726
|
+
process.exit(0)
|
|
727
|
+
}
|
|
728
|
+
spinner.start()
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
695
732
|
// 10. Read tarball and generate integrity hashes
|
|
696
733
|
spinner.text = "Reading tarball..."
|
|
697
734
|
const tarballData = await readFileAsync(tarballPath)
|
|
@@ -761,12 +798,19 @@ export async function publish(options = {}) {
|
|
|
761
798
|
payload._ecosystem = ecosystem
|
|
762
799
|
}
|
|
763
800
|
|
|
801
|
+
const publishHeaders = {
|
|
802
|
+
"Content-Type": "application/json",
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Include OTP header if 2FA code was collected pre-upload
|
|
806
|
+
if (otpCode) {
|
|
807
|
+
publishHeaders["x-otp"] = otpCode
|
|
808
|
+
}
|
|
809
|
+
|
|
764
810
|
const response = await request(`/${encodeURIComponent(name)}`, {
|
|
765
811
|
method: "PUT",
|
|
766
812
|
body: JSON.stringify(payload),
|
|
767
|
-
headers:
|
|
768
|
-
"Content-Type": "application/json",
|
|
769
|
-
},
|
|
813
|
+
headers: publishHeaders,
|
|
770
814
|
onRetry: (attempt, max) => {
|
|
771
815
|
spinner.text = `Uploading to registry (retry ${attempt}/${max})...`
|
|
772
816
|
},
|
package/lib/quality/checks.js
CHANGED
|
@@ -254,6 +254,35 @@ const noEval = {
|
|
|
254
254
|
},
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
const noLifecycleScripts = {
|
|
258
|
+
id: "no-lifecycle-scripts",
|
|
259
|
+
category: "health",
|
|
260
|
+
label: "No lifecycle scripts",
|
|
261
|
+
maxPoints: 2,
|
|
262
|
+
run: ({ packageJson }) => {
|
|
263
|
+
const lifecycleNames = [
|
|
264
|
+
"preinstall",
|
|
265
|
+
"install",
|
|
266
|
+
"postinstall",
|
|
267
|
+
"preuninstall",
|
|
268
|
+
"uninstall",
|
|
269
|
+
"postuninstall",
|
|
270
|
+
]
|
|
271
|
+
const scripts = packageJson.scripts || {}
|
|
272
|
+
const found = lifecycleNames.filter(name => scripts[name])
|
|
273
|
+
if (found.length === 0) {
|
|
274
|
+
return {
|
|
275
|
+
passed: true,
|
|
276
|
+
detail: "No lifecycle scripts detected",
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
passed: false,
|
|
281
|
+
detail: `Lifecycle scripts found: ${found.join(", ")}`,
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
|
|
257
286
|
const hasEngines = {
|
|
258
287
|
id: "has-engines",
|
|
259
288
|
category: "code",
|
|
@@ -274,7 +303,7 @@ const hasExportsMap = {
|
|
|
274
303
|
id: "has-exports-map",
|
|
275
304
|
category: "code",
|
|
276
305
|
label: 'Has "exports" map',
|
|
277
|
-
maxPoints:
|
|
306
|
+
maxPoints: 3,
|
|
278
307
|
run: ({ packageJson }) => {
|
|
279
308
|
const hasField = !!packageJson.exports
|
|
280
309
|
return {
|
|
@@ -290,16 +319,15 @@ const smallDeps = {
|
|
|
290
319
|
id: "small-deps",
|
|
291
320
|
category: "code",
|
|
292
321
|
label: "Small dependency footprint",
|
|
293
|
-
maxPoints:
|
|
322
|
+
maxPoints: 3,
|
|
294
323
|
run: ({ packageJson }) => {
|
|
295
324
|
const deps = packageJson.dependencies
|
|
296
325
|
? Object.keys(packageJson.dependencies).length
|
|
297
326
|
: 0
|
|
298
327
|
let points
|
|
299
|
-
if (deps === 0) points =
|
|
300
|
-
else if (deps <= 3) points =
|
|
301
|
-
else if (deps <= 7) points =
|
|
302
|
-
else if (deps <= 15) points = 1
|
|
328
|
+
if (deps === 0) points = 3
|
|
329
|
+
else if (deps <= 3) points = 2
|
|
330
|
+
else if (deps <= 7) points = 1
|
|
303
331
|
else points = 0
|
|
304
332
|
return {
|
|
305
333
|
passed: points > 0,
|
|
@@ -610,6 +638,7 @@ export const checks = [
|
|
|
610
638
|
hasHomepage,
|
|
611
639
|
reasonableSize,
|
|
612
640
|
noVulnerabilities,
|
|
641
|
+
noLifecycleScripts,
|
|
613
642
|
maintenanceHealth,
|
|
614
643
|
semverConsistency,
|
|
615
644
|
authorVerified,
|
|
@@ -630,6 +659,7 @@ export {
|
|
|
630
659
|
hasHomepage,
|
|
631
660
|
reasonableSize,
|
|
632
661
|
noVulnerabilities,
|
|
662
|
+
noLifecycleScripts,
|
|
633
663
|
maintenanceHealth,
|
|
634
664
|
semverConsistency,
|
|
635
665
|
authorVerified,
|