@lpm-registry/cli 0.2.3 → 0.2.5
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 +25 -0
- package/bin/lpm.js +11 -0
- package/lib/api.js +20 -1
- package/lib/commands/npmrc.js +140 -0
- package/lib/commands/publish.js +47 -3
- package/lib/commands/setup.js +80 -26
- package/lib/oidc.js +79 -51
- package/lib/quality/checks.js +36 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.5] - 2026-03-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`lpm npmrc` command** — Generates a 30-day read-only token and writes it to `.npmrc` for local development. Auto-adds `.npmrc` to `.gitignore`. Makes `npm install` work without env vars or manual token setup.
|
|
10
|
+
- **`lpm setup --oidc` flag** — Exchanges CI OIDC token (GitHub Actions, GitLab CI) for a short-lived read-only install token. Eliminates static `LPM_TOKEN` secrets in CI. Falls back to `${LPM_TOKEN}` placeholder if OIDC is unavailable.
|
|
11
|
+
- **OIDC read scope support** — Server-side OIDC token exchange now accepts `?scope=read` for install-only tokens (30 min TTL). Uses Supabase OAuth identity linking instead of trusted publisher config.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- OIDC module refactored for reuse — `exchangeOidcInstallToken()` auto-detects CI environment and exchanges for read-only tokens
|
|
16
|
+
- `lpm setup` default mode now mentions `lpm npmrc` and `--oidc` in help output
|
|
17
|
+
|
|
18
|
+
## [0.2.4] - 2026-03-18
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **2FA support for publish** — CLI prompts for TOTP code before uploading when user or org has 2FA enabled. OIDC (CI/CD) tokens are exempt.
|
|
23
|
+
- **Lifecycle scripts quality check** — New `no-lifecycle-scripts` check (2pts) flags packages with `preinstall`, `install`, `postinstall`, `preuninstall`, `uninstall`, or `postuninstall` scripts
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Quality scoring rebalanced: `small-deps` 4pts→3pts, `has-exports-map` 4pts→3pts to accommodate the new lifecycle scripts check (total remains 100)
|
|
28
|
+
- API client now passes through OTP-related 401/403 responses to callers instead of throwing generic auth errors
|
|
29
|
+
|
|
5
30
|
## [0.2.2] - 2026-03-16
|
|
6
31
|
|
|
7
32
|
### Added
|
package/bin/lpm.js
CHANGED
|
@@ -19,6 +19,7 @@ import { logout } from "../lib/commands/logout.js"
|
|
|
19
19
|
import { marketplaceCompare } from "../lib/commands/marketplace-compare.js"
|
|
20
20
|
import { marketplaceEarnings } from "../lib/commands/marketplace-earnings.js"
|
|
21
21
|
import { mcpRemove, mcpSetup, mcpStatus } from "../lib/commands/mcp-setup.js"
|
|
22
|
+
import { npmrc } from "../lib/commands/npmrc.js"
|
|
22
23
|
import { openDashboard } from "../lib/commands/open.js"
|
|
23
24
|
import { outdated } from "../lib/commands/outdated.js"
|
|
24
25
|
import { poolStats } from "../lib/commands/pool-stats.js"
|
|
@@ -202,8 +203,18 @@ program
|
|
|
202
203
|
.command("setup")
|
|
203
204
|
.description("Configure .npmrc for LPM packages (@lpm.dev scope)")
|
|
204
205
|
.option("-r, --registry <url>", "Custom registry URL")
|
|
206
|
+
.option(
|
|
207
|
+
"--oidc",
|
|
208
|
+
"Exchange CI OIDC token for install access (no secrets needed)",
|
|
209
|
+
)
|
|
205
210
|
.action(setup)
|
|
206
211
|
|
|
212
|
+
program
|
|
213
|
+
.command("npmrc")
|
|
214
|
+
.description("Generate a read-only .npmrc token for local development")
|
|
215
|
+
.option("-d, --days <number>", "Token expiry in days (default: 30)")
|
|
216
|
+
.action(npmrc)
|
|
217
|
+
|
|
207
218
|
program
|
|
208
219
|
.command("config [action] [key] [value]")
|
|
209
220
|
.description("Manage CLI configuration (list, get, set, delete)")
|
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
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { request } from "../api.js"
|
|
4
|
+
import { getRegistryUrl, getToken } from "../config.js"
|
|
5
|
+
import { createSpinner, log, printHeader } from "../ui.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a read-only .npmrc token for local development.
|
|
9
|
+
* Creates a 30-day scoped token and writes it to the project's .npmrc.
|
|
10
|
+
* Also ensures .npmrc is in .gitignore to prevent accidental commits.
|
|
11
|
+
*/
|
|
12
|
+
export async function npmrc(options) {
|
|
13
|
+
printHeader()
|
|
14
|
+
|
|
15
|
+
const token = await getToken()
|
|
16
|
+
if (!token) {
|
|
17
|
+
log.error(
|
|
18
|
+
'You must be logged in to generate an .npmrc token. Run "lpm login" first.',
|
|
19
|
+
)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const projectRoot = process.cwd()
|
|
24
|
+
const npmrcPath = path.join(projectRoot, ".npmrc")
|
|
25
|
+
const gitignorePath = path.join(projectRoot, ".gitignore")
|
|
26
|
+
|
|
27
|
+
// Check for package.json
|
|
28
|
+
const pkgJsonPath = path.join(projectRoot, "package.json")
|
|
29
|
+
if (!fs.existsSync(pkgJsonPath)) {
|
|
30
|
+
log.warn("No package.json found. Run this command in your project root.")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Derive project name for the token label
|
|
34
|
+
let projectName = "project"
|
|
35
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"))
|
|
38
|
+
if (pkg.name) {
|
|
39
|
+
projectName = pkg.name.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Invalid package.json — use default name
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const expiryDays = parseInt(options?.days, 10) || 30
|
|
47
|
+
const spinner = createSpinner("Creating read-only token...").start()
|
|
48
|
+
|
|
49
|
+
// Call server to create a scoped read-only token
|
|
50
|
+
let readToken
|
|
51
|
+
let expiresAt
|
|
52
|
+
try {
|
|
53
|
+
const tokenName = `npmrc-${projectName}-${new Date().toISOString().slice(0, 10)}`
|
|
54
|
+
const response = await request("/-/token/create", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
scope: "read",
|
|
58
|
+
name: tokenName,
|
|
59
|
+
expiryDays,
|
|
60
|
+
}),
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const text = await response.text()
|
|
66
|
+
throw new Error(`Token creation failed: ${response.status} ${text}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = await response.json()
|
|
70
|
+
readToken = data.token
|
|
71
|
+
expiresAt = data.expiresAt
|
|
72
|
+
spinner.succeed("Read-only token created.")
|
|
73
|
+
} catch (error) {
|
|
74
|
+
spinner.fail(`Failed to create token: ${error.message}`)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Build .npmrc content
|
|
79
|
+
const registryUrl = getRegistryUrl()
|
|
80
|
+
const fullRegistryUrl = registryUrl.endsWith("/api/registry")
|
|
81
|
+
? registryUrl
|
|
82
|
+
: `${registryUrl}/api/registry`
|
|
83
|
+
const registryHost = fullRegistryUrl.replace(/^https?:/, "")
|
|
84
|
+
|
|
85
|
+
// Read existing .npmrc and remove old LPM config lines
|
|
86
|
+
let npmrcContent = ""
|
|
87
|
+
if (fs.existsSync(npmrcPath)) {
|
|
88
|
+
npmrcContent = fs
|
|
89
|
+
.readFileSync(npmrcPath, "utf8")
|
|
90
|
+
.split("\n")
|
|
91
|
+
.filter(line => {
|
|
92
|
+
return (
|
|
93
|
+
!line.includes("@lpm.dev:registry") &&
|
|
94
|
+
!line.includes("lpm.dev/api/registry/:_authToken") &&
|
|
95
|
+
!line.includes("_authToken=lpm_") &&
|
|
96
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: matching literal npm token placeholder
|
|
97
|
+
!line.includes("_authToken=${LPM_TOKEN}") &&
|
|
98
|
+
!line.includes("# LPM Registry")
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
.join("\n")
|
|
102
|
+
.trim()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lpmConfig = `# LPM Registry (generated by lpm npmrc — do not commit)
|
|
106
|
+
@lpm.dev:registry=${fullRegistryUrl}
|
|
107
|
+
${registryHost}/:_authToken=${readToken}`
|
|
108
|
+
|
|
109
|
+
npmrcContent = npmrcContent ? `${npmrcContent}\n\n${lpmConfig}` : lpmConfig
|
|
110
|
+
fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
|
|
111
|
+
|
|
112
|
+
// Ensure .npmrc is in .gitignore
|
|
113
|
+
let gitignoreUpdated = false
|
|
114
|
+
if (fs.existsSync(gitignorePath)) {
|
|
115
|
+
const gitignore = fs.readFileSync(gitignorePath, "utf8")
|
|
116
|
+
if (!gitignore.split("\n").some(line => line.trim() === ".npmrc")) {
|
|
117
|
+
fs.writeFileSync(gitignorePath, `${gitignore.trimEnd()}\n.npmrc\n`)
|
|
118
|
+
gitignoreUpdated = true
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
fs.writeFileSync(gitignorePath, ".npmrc\n")
|
|
122
|
+
gitignoreUpdated = true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Output
|
|
126
|
+
console.log("")
|
|
127
|
+
log.success(".npmrc configured with read-only LPM token.")
|
|
128
|
+
if (gitignoreUpdated) {
|
|
129
|
+
log.info(".npmrc added to .gitignore to prevent token leaks.")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const expiryDate = new Date(expiresAt).toLocaleDateString("en-US", {
|
|
133
|
+
year: "numeric",
|
|
134
|
+
month: "long",
|
|
135
|
+
day: "numeric",
|
|
136
|
+
})
|
|
137
|
+
log.info(`Token expires: ${expiryDate}`)
|
|
138
|
+
log.info("Run `lpm npmrc` again to refresh when expired.")
|
|
139
|
+
console.log("")
|
|
140
|
+
}
|
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/commands/setup.js
CHANGED
|
@@ -3,11 +3,36 @@ import path from "node:path"
|
|
|
3
3
|
import * as p from "@clack/prompts"
|
|
4
4
|
import chalk from "chalk"
|
|
5
5
|
import { getRegistryUrl } from "../config.js"
|
|
6
|
+
import { exchangeOidcInstallToken } from "../oidc.js"
|
|
6
7
|
import { log, printHeader } from "../ui.js"
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Remove existing LPM config lines from .npmrc content.
|
|
11
|
+
* @param {string} content
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function removeLpmLines(content) {
|
|
15
|
+
return content
|
|
16
|
+
.split("\n")
|
|
17
|
+
.filter(line => {
|
|
18
|
+
return (
|
|
19
|
+
!line.includes("@lpm.dev:registry") &&
|
|
20
|
+
!line.includes("lpm.dev/api/registry/:_authToken") &&
|
|
21
|
+
!line.includes("_authToken=lpm_") &&
|
|
22
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: matching literal npm token placeholder
|
|
23
|
+
!line.includes("_authToken=${LPM_TOKEN}") &&
|
|
24
|
+
!line.includes("# LPM Registry")
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
.join("\n")
|
|
28
|
+
.trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
/**
|
|
9
32
|
* Configure .npmrc for LPM packages.
|
|
10
|
-
*
|
|
33
|
+
*
|
|
34
|
+
* Default mode: writes ${LPM_TOKEN} placeholder (for CI with env var).
|
|
35
|
+
* --oidc mode: exchanges CI OIDC token for a real short-lived token (no secrets needed).
|
|
11
36
|
*/
|
|
12
37
|
export async function setup(options) {
|
|
13
38
|
printHeader()
|
|
@@ -24,6 +49,56 @@ export async function setup(options) {
|
|
|
24
49
|
log.warn("No package.json found. Run this command in your project root.")
|
|
25
50
|
}
|
|
26
51
|
|
|
52
|
+
// Build registry URL
|
|
53
|
+
const fullRegistryUrl = registryUrl.endsWith("/api/registry")
|
|
54
|
+
? registryUrl
|
|
55
|
+
: `${registryUrl}/api/registry`
|
|
56
|
+
const registryHost = fullRegistryUrl.replace(/^https?:/, "")
|
|
57
|
+
|
|
58
|
+
// ── OIDC mode: exchange CI token for a real short-lived install token ──
|
|
59
|
+
if (options?.oidc) {
|
|
60
|
+
let oidcToken
|
|
61
|
+
try {
|
|
62
|
+
oidcToken = await exchangeOidcInstallToken()
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log.warn(`OIDC exchange failed: ${err.message}`)
|
|
65
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal placeholder name in user-facing message
|
|
66
|
+
log.warn("Falling back to ${LPM_TOKEN} environment variable placeholder.")
|
|
67
|
+
// Fall through to default mode
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (oidcToken) {
|
|
71
|
+
let npmrcContent = ""
|
|
72
|
+
if (fs.existsSync(npmrcPath)) {
|
|
73
|
+
npmrcContent = removeLpmLines(fs.readFileSync(npmrcPath, "utf8"))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lpmConfig = `# LPM Registry (OIDC — expires in 30 minutes)
|
|
77
|
+
@lpm.dev:registry=${fullRegistryUrl}
|
|
78
|
+
${registryHost}/:_authToken=${oidcToken}`
|
|
79
|
+
|
|
80
|
+
npmrcContent = npmrcContent
|
|
81
|
+
? `${npmrcContent}\n\n${lpmConfig}`
|
|
82
|
+
: lpmConfig
|
|
83
|
+
fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
|
|
84
|
+
|
|
85
|
+
p.note(
|
|
86
|
+
`@lpm.dev:registry=${fullRegistryUrl}\nToken: OIDC read-only (30 min)`,
|
|
87
|
+
".npmrc configuration",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
console.log("")
|
|
91
|
+
log.success(".npmrc configured with OIDC read-only token.")
|
|
92
|
+
log.info("Token expires in 30 minutes. No LPM_TOKEN secret needed.")
|
|
93
|
+
console.log("")
|
|
94
|
+
|
|
95
|
+
p.outro("Setup complete!")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Default mode: ${LPM_TOKEN} placeholder ──
|
|
101
|
+
|
|
27
102
|
// Check for existing .npmrc
|
|
28
103
|
let npmrcContent = ""
|
|
29
104
|
if (fs.existsSync(npmrcPath)) {
|
|
@@ -41,40 +116,18 @@ export async function setup(options) {
|
|
|
41
116
|
return
|
|
42
117
|
}
|
|
43
118
|
|
|
44
|
-
|
|
45
|
-
npmrcContent = npmrcContent
|
|
46
|
-
.split("\n")
|
|
47
|
-
.filter(line => {
|
|
48
|
-
return (
|
|
49
|
-
!line.includes("@lpm.dev:registry") &&
|
|
50
|
-
!line.includes("lpm.dev/api/registry/:_authToken") &&
|
|
51
|
-
!line.includes("# LPM Registry")
|
|
52
|
-
)
|
|
53
|
-
})
|
|
54
|
-
.join("\n")
|
|
55
|
-
.trim()
|
|
119
|
+
npmrcContent = removeLpmLines(npmrcContent)
|
|
56
120
|
}
|
|
57
121
|
}
|
|
58
122
|
|
|
59
|
-
//
|
|
60
|
-
const fullRegistryUrl = registryUrl.endsWith("/api/registry")
|
|
61
|
-
? registryUrl
|
|
62
|
-
: `${registryUrl}/api/registry`
|
|
63
|
-
|
|
64
|
-
// Remove protocol for auth token config
|
|
65
|
-
const registryHost = fullRegistryUrl.replace(/^https?:/, "")
|
|
66
|
-
|
|
67
|
-
// Add LPM registry config
|
|
123
|
+
// Add LPM registry config with placeholder
|
|
68
124
|
const lpmConfig = `
|
|
69
125
|
# LPM Registry
|
|
70
126
|
@lpm.dev:registry=${fullRegistryUrl}
|
|
71
127
|
${registryHost}/:_authToken=\${LPM_TOKEN}
|
|
72
128
|
`.trim()
|
|
73
129
|
|
|
74
|
-
// Combine with existing content
|
|
75
130
|
npmrcContent = npmrcContent ? `${npmrcContent}\n\n${lpmConfig}` : lpmConfig
|
|
76
|
-
|
|
77
|
-
// Write .npmrc
|
|
78
131
|
fs.writeFileSync(npmrcPath, `${npmrcContent}\n`)
|
|
79
132
|
|
|
80
133
|
p.note(
|
|
@@ -84,8 +137,9 @@ ${registryHost}/:_authToken=\${LPM_TOKEN}
|
|
|
84
137
|
|
|
85
138
|
console.log("")
|
|
86
139
|
log.success(".npmrc configured for LPM packages.")
|
|
87
|
-
log.info("For local development: Run `lpm
|
|
140
|
+
log.info("For local development: Run `lpm npmrc` to generate a token.")
|
|
88
141
|
log.info("For CI/CD: Set the LPM_TOKEN environment variable.")
|
|
142
|
+
log.info("For CI/CD with OIDC: Run `lpm setup --oidc` (no secrets needed).")
|
|
89
143
|
console.log("")
|
|
90
144
|
|
|
91
145
|
p.outro("Setup complete!")
|
package/lib/oidc.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OIDC (OpenID Connect) token exchange for secret-free
|
|
2
|
+
* OIDC (OpenID Connect) token exchange for secret-free CI operations.
|
|
3
3
|
*
|
|
4
4
|
* When running in GitHub Actions with `id-token: write` permission,
|
|
5
5
|
* this module requests an OIDC JWT from GitHub and exchanges it for
|
|
6
|
-
* a short-lived LPM
|
|
6
|
+
* a short-lived LPM token — no LPM_TOKEN secret required.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Two modes:
|
|
9
|
+
* - **Publish**: Requires trusted publisher config in LPM dashboard.
|
|
10
|
+
* - **Read (install)**: Requires GitHub/GitLab account linked to LPM user.
|
|
10
11
|
*
|
|
11
12
|
* @module cli/lib/oidc
|
|
12
13
|
*/
|
|
@@ -39,20 +40,10 @@ export function isGitLabCiWithOidc() {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* Steps:
|
|
45
|
-
* 1. Request an OIDC JWT from GitHub (audience: https://lpm.dev)
|
|
46
|
-
* 2. POST the JWT to the LPM OIDC token exchange endpoint
|
|
47
|
-
* 3. Return the resulting `lpm_xxx` token (valid for 15 minutes)
|
|
48
|
-
*
|
|
49
|
-
* @param {string} packageName - Full package name, e.g. "@lpm.dev/owner.pkg-name"
|
|
50
|
-
* @returns {Promise<string>} Short-lived LPM publish token
|
|
51
|
-
* @throws {Error} If GitHub OIDC request fails or LPM exchange is rejected
|
|
43
|
+
* Request a GitHub Actions OIDC JWT.
|
|
44
|
+
* @returns {Promise<string>} The raw JWT string
|
|
52
45
|
*/
|
|
53
|
-
|
|
54
|
-
// 1. Request OIDC JWT from GitHub Actions
|
|
55
|
-
// The audience must match EXPECTED_AUDIENCE in lib/registry/oidc.js on the server
|
|
46
|
+
async function requestGitHubOidcJwt() {
|
|
56
47
|
const githubTokenUrl = `${process.env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://lpm.dev`
|
|
57
48
|
|
|
58
49
|
const githubRes = await fetch(githubTokenUrl, {
|
|
@@ -73,40 +64,72 @@ export async function exchangeOidcToken(packageName) {
|
|
|
73
64
|
throw new Error("GitHub OIDC response did not contain a token value")
|
|
74
65
|
}
|
|
75
66
|
|
|
76
|
-
|
|
67
|
+
return oidcJwt
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Exchange an OIDC JWT with the LPM server for a short-lived token.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} oidcJwt - The raw OIDC JWT
|
|
74
|
+
* @param {{ scope?: "read"|"publish", packageName?: string }} [options]
|
|
75
|
+
* @returns {Promise<{ token: string, expiresAt: string }>}
|
|
76
|
+
*/
|
|
77
|
+
async function exchangeJwtWithServer(oidcJwt, options = {}) {
|
|
78
|
+
const { scope = "publish", packageName } = options
|
|
77
79
|
const registryUrl = getRegistryUrl()
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const scopeParam = scope === "read" ? "?scope=read" : ""
|
|
81
|
+
|
|
82
|
+
const body = { token: oidcJwt }
|
|
83
|
+
if (packageName) body.package = packageName
|
|
84
|
+
|
|
85
|
+
const exchangeRes = await fetch(
|
|
86
|
+
`${registryUrl}/api/registry/-/token/oidc${scopeParam}`,
|
|
87
|
+
{
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
83
93
|
|
|
84
94
|
if (!exchangeRes.ok) {
|
|
85
|
-
const
|
|
95
|
+
const text = await exchangeRes.text().catch(() => "")
|
|
86
96
|
throw new Error(
|
|
87
|
-
`OIDC token exchange failed (${exchangeRes.status})${
|
|
97
|
+
`OIDC token exchange failed (${exchangeRes.status})${text ? `: ${text}` : ""}`,
|
|
88
98
|
)
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
const
|
|
92
|
-
if (!token) {
|
|
101
|
+
const data = await exchangeRes.json()
|
|
102
|
+
if (!data.token) {
|
|
93
103
|
throw new Error("OIDC exchange response did not contain a token")
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
return
|
|
106
|
+
return data
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
/**
|
|
100
|
-
* Exchange a
|
|
110
|
+
* Exchange a GitHub Actions OIDC JWT for a short-lived LPM token.
|
|
101
111
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
112
|
+
* @param {string} [packageName] - Required for publish scope, optional for read
|
|
113
|
+
* @param {{ scope?: "read"|"publish" }} [options]
|
|
114
|
+
* @returns {Promise<string>} Short-lived LPM token
|
|
115
|
+
*/
|
|
116
|
+
export async function exchangeOidcToken(packageName, options = {}) {
|
|
117
|
+
const oidcJwt = await requestGitHubOidcJwt()
|
|
118
|
+
const data = await exchangeJwtWithServer(oidcJwt, {
|
|
119
|
+
scope: options.scope || "publish",
|
|
120
|
+
packageName,
|
|
121
|
+
})
|
|
122
|
+
return data.token
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Exchange a GitLab CI OIDC JWT for a short-lived LPM token.
|
|
104
127
|
*
|
|
105
|
-
* @param {string} packageName -
|
|
106
|
-
* @
|
|
107
|
-
* @
|
|
128
|
+
* @param {string} [packageName] - Required for publish scope, optional for read
|
|
129
|
+
* @param {{ scope?: "read"|"publish" }} [options]
|
|
130
|
+
* @returns {Promise<string>} Short-lived LPM token
|
|
108
131
|
*/
|
|
109
|
-
export async function exchangeGitLabOidcToken(packageName) {
|
|
132
|
+
export async function exchangeGitLabOidcToken(packageName, options = {}) {
|
|
110
133
|
const oidcJwt = process.env.LPM_GITLAB_OIDC_TOKEN
|
|
111
134
|
if (!oidcJwt) {
|
|
112
135
|
throw new Error(
|
|
@@ -115,24 +138,29 @@ export async function exchangeGitLabOidcToken(packageName) {
|
|
|
115
138
|
)
|
|
116
139
|
}
|
|
117
140
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
headers: { "Content-Type": "application/json" },
|
|
122
|
-
body: JSON.stringify({ token: oidcJwt, package: packageName }),
|
|
141
|
+
const data = await exchangeJwtWithServer(oidcJwt, {
|
|
142
|
+
scope: options.scope || "publish",
|
|
143
|
+
packageName,
|
|
123
144
|
})
|
|
145
|
+
return data.token
|
|
146
|
+
}
|
|
124
147
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Detect CI environment and exchange OIDC token for a read-only install token.
|
|
150
|
+
* Works with both GitHub Actions and GitLab CI.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Promise<string>} Short-lived read-only LPM token
|
|
153
|
+
* @throws {Error} If not in a supported CI environment or exchange fails
|
|
154
|
+
*/
|
|
155
|
+
export async function exchangeOidcInstallToken() {
|
|
156
|
+
if (isGitHubActionsWithOidc()) {
|
|
157
|
+
return exchangeOidcToken(null, { scope: "read" })
|
|
130
158
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!token) {
|
|
134
|
-
throw new Error("OIDC exchange response did not contain a token")
|
|
159
|
+
if (isGitLabCiWithOidc()) {
|
|
160
|
+
return exchangeGitLabOidcToken(null, { scope: "read" })
|
|
135
161
|
}
|
|
136
|
-
|
|
137
|
-
|
|
162
|
+
throw new Error(
|
|
163
|
+
"OIDC is not available. This command requires a CI environment with OIDC support " +
|
|
164
|
+
"(GitHub Actions with `id-token: write` or GitLab CI with `id_tokens` configured).",
|
|
165
|
+
)
|
|
138
166
|
}
|
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,
|