@lpm-registry/cli 0.2.3 → 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 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 data = await response.json().catch(() => ({}))
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
@@ -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
  },
@@ -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: 4,
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: 4,
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 = 4
300
- else if (deps <= 3) points = 3
301
- else if (deps <= 7) points = 2
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpm-registry/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "CLI for Licensed Package Manager",
5
5
  "author": "LPM <hello@lpm.dev> (https://lpm.dev)",
6
6
  "repository": {