@lpm-registry/cli 0.2.0 → 0.2.2

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,12 +2,54 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.2] - 2026-03-16
6
+
7
+ ### Added
8
+
9
+ - `publish.sh` script for automated linting, testing, and publishing to npm
10
+ - Local project scanning for test files during quality checks — authors no longer need to ship test files in the tarball to earn quality points
11
+ - Registry-scoped token storage — dev and production tokens no longer overwrite each other
12
+
13
+ ### Changed
14
+
15
+ - Token storage is now scoped per registry URL with automatic migration from legacy un-scoped keys
16
+ - Quality check "Has test files" now scans the local project directory instead of only the tarball contents
17
+
18
+ ## [0.2.1] - 2026-03-12
19
+
20
+ ### Added
21
+
22
+ - **Swift & XCFramework ecosystem support** — quality checks, packaging, framework detection, and local package management for Apple platforms
23
+ - **OIDC token exchange** for secret-free publishing from CI/CD (GitHub Actions, GitLab CI)
24
+ - **Agent Skills management** — `lpm skills` commands for creating and managing `.lpm/skills/` packages
25
+ - `lpm remove` command for uninstalling packages
26
+ - `lpm install --json` output mode for programmatic usage
27
+ - `lpm search` now supports semantic queries
28
+ - Intellisense coverage and XCFramework size checks in quality scoring
29
+ - Node.js type definitions check in quality scoring
30
+ - Graceful HTTP server shutdown after login callback
31
+
32
+ ### Changed
33
+
34
+ - Updated quality scoring metrics (28 checks for JS, 25 for Swift, 21 for XCFramework)
35
+ - Removed CI config references from quality checks
36
+ - Simplified `getDefaultPath` internals
37
+ - Removed `applyTemplateVariables` logic from config and commands
38
+ - Improved MCP setup command with server config resolution
39
+ - General code structure refactoring for readability
40
+
41
+ ### Fixed
42
+
43
+ - Error handling for invalid JSON in `package.json`
44
+ - API request handling improvements
45
+
5
46
  ## [0.2.0] - 2025-02-14
6
47
 
7
48
  ### Added
49
+
8
50
  - Quality scoring system with 27 checks across 4 categories
9
51
  - `lpm publish --check` and `--min-score` flags for quality gating
10
- - `lpm add` command for shadcn-style source code delivery
52
+ - `lpm add` command for source code delivery
11
53
  - `lpm.config.json` support for configurable source packages
12
54
  - Secure credential storage (OS keychain with encrypted file fallback)
13
55
  - SRI integrity verification for package tarballs
@@ -15,11 +57,12 @@ All notable changes to this project will be documented in this file.
15
57
  - Exponential backoff retry with rate-limit handling
16
58
 
17
59
  ### Changed
60
+
18
61
  - Migrated from `node-fetch` to native `fetch` (Node 18+)
19
62
  - Removed unused `figlet` and `form-data` dependencies
20
- - Updated default registry URL to `https://lpm.dev`
21
63
 
22
64
  ### Fixed
65
+
23
66
  - Missing `await` on `setToken()` in token-rotate command
24
67
  - URL construction for `@lpm.dev/owner.pkg` format in open command
25
68
  - Inconsistent limit field names in doctor command
@@ -27,6 +70,7 @@ All notable changes to this project will be documented in this file.
27
70
  ## [0.1.0] - 2024-12-01
28
71
 
29
72
  ### Added
73
+
30
74
  - Initial CLI release
31
75
  - `lpm login`, `lpm logout`, `lpm whoami` authentication commands
32
76
  - `lpm publish`, `lpm install` package management
package/README.md CHANGED
@@ -12,13 +12,13 @@ npm install -g @lpm-registry/cli
12
12
 
13
13
  LPM provides convenient aliases for common commands:
14
14
 
15
- | Shortcut | Full Command | Description |
16
- |----------|--------------|-------------|
17
- | `lpm i` | `lpm install` | Install packages |
18
- | `lpm p` | `lpm publish` | Publish package |
19
- | `lpm l` | `lpm login` | Log in |
20
- | `lpm lo` | `lpm logout` | Log out |
21
- | `lpm set <key> <value>` | `lpm config set` | Set config |
15
+ | Shortcut | Full Command | Description |
16
+ | ----------------------- | ---------------- | ---------------- |
17
+ | `lpm i` | `lpm install` | Install packages |
18
+ | `lpm p` | `lpm publish` | Publish package |
19
+ | `lpm l` | `lpm login` | Log in |
20
+ | `lpm lo` | `lpm logout` | Log out |
21
+ | `lpm set <key> <value>` | `lpm config set` | Set config |
22
22
 
23
23
  ## Package Name Format
24
24
 
@@ -29,6 +29,7 @@ LPM uses the `@lpm.dev` scope for all packages with dot notation:
29
29
  ```
30
30
 
31
31
  Examples:
32
+
32
33
  - `@lpm.dev/tolgaergin.my-utils` (personal package)
33
34
  - `@lpm.dev/acme-corp.design-system` (org package)
34
35
 
@@ -103,10 +104,10 @@ lpm publish --min-score 80 # Block publish if quality score < 80
103
104
  lpm publish --check --min-score 90 # Check only, fail if below 90 (useful in CI)
104
105
  ```
105
106
 
106
- | Option | Description |
107
- |--------|-------------|
108
- | `--check` | Run quality checks and display report, then exit without publishing |
109
- | `--min-score <n>` | Set minimum quality score (0-100) required to publish |
107
+ | Option | Description |
108
+ | ----------------- | ------------------------------------------------------------------- |
109
+ | `--check` | Run quality checks and display report, then exit without publishing |
110
+ | `--min-score <n>` | Set minimum quality score (0-100) required to publish |
110
111
 
111
112
  > **Note:** If `.lpm/skills/` exists in your package but skills aren't included in the tarball, the CLI warns you to add `.lpm` to the `files` field in package.json.
112
113
 
@@ -124,13 +125,13 @@ lpm i # Shortcut
124
125
 
125
126
  Agent Skills are fetched by default for packages that include them.
126
127
 
127
- | Option | Description |
128
- |--------|-------------|
128
+ | Option | Description |
129
+ | ------------- | ---------------------------------------- |
129
130
  | `--no-skills` | Skip fetching Agent Skills after install |
130
131
 
131
132
  #### Add (Source Code)
132
133
 
133
- Download and extract package source code directly into your project (shadcn-style).
134
+ Download and extract package source code directly into your project.
134
135
 
135
136
  ```bash
136
137
  # JavaScript
@@ -196,15 +197,19 @@ lpm quality @lpm.dev/owner.package --json
196
197
  Manage Agent Skills for AI coding assistants.
197
198
 
198
199
  #### `lpm skills validate`
200
+
199
201
  Validate `.lpm/skills/*.md` files in the current directory. Checks file format, frontmatter, content, size limits, and blocked patterns. Shows quality score impact.
200
202
 
201
203
  #### `lpm skills install [package]`
204
+
202
205
  Fetch and install skills from the registry. Without a package argument, installs skills for all `@lpm.dev/*` dependencies in package.json. Saves to `.lpm/skills/{package-name}/` and adds `.lpm/skills/` to `.gitignore`.
203
206
 
204
207
  #### `lpm skills list`
208
+
205
209
  List available skills for all installed `@lpm.dev/*` packages. Shows which packages have skills, how many, and whether they're installed locally.
206
210
 
207
211
  #### `lpm skills clean`
212
+
208
213
  Remove the `.lpm/skills/` directory and all locally installed skills.
209
214
 
210
215
  ### Security & Maintenance
@@ -335,49 +340,49 @@ import {
335
340
  runQualityChecks,
336
341
  parseLpmPackageReference,
337
342
  detectFramework,
338
- } from "@lpm-registry/cli"
343
+ } from "@lpm-registry/cli";
339
344
  ```
340
345
 
341
346
  ### Integrity
342
347
 
343
- | Function | Description |
344
- |----------|-------------|
345
- | `generateIntegrity(buffer, algorithm?)` | Generate an SRI integrity hash (default: `sha512`) |
346
- | `verifyIntegrity(buffer, expected)` | Verify a buffer against an SRI integrity string |
347
- | `verifyIntegrityMultiple(buffer, integrities)` | Verify against multiple integrity strings |
348
- | `parseIntegrity(integrity)` | Parse an SRI string into `{ algorithm, digest }` |
348
+ | Function | Description |
349
+ | ---------------------------------------------- | -------------------------------------------------- |
350
+ | `generateIntegrity(buffer, algorithm?)` | Generate an SRI integrity hash (default: `sha512`) |
351
+ | `verifyIntegrity(buffer, expected)` | Verify a buffer against an SRI integrity string |
352
+ | `verifyIntegrityMultiple(buffer, integrities)` | Verify against multiple integrity strings |
353
+ | `parseIntegrity(integrity)` | Parse an SRI string into `{ algorithm, digest }` |
349
354
 
350
355
  ### Path Safety
351
356
 
352
- | Function | Description |
353
- |----------|-------------|
354
- | `validateComponentPath(root, path)` | Validate a component install path stays within project |
355
- | `validateTarballPaths(extractDir, paths)` | Check tarball entries for path traversal attacks |
356
- | `resolveSafePath(base, user)` | Safely resolve a user-provided path |
357
- | `sanitizeFilename(name)` | Strip dangerous characters from a filename |
357
+ | Function | Description |
358
+ | ----------------------------------------- | ------------------------------------------------------ |
359
+ | `validateComponentPath(root, path)` | Validate a component install path stays within project |
360
+ | `validateTarballPaths(extractDir, paths)` | Check tarball entries for path traversal attacks |
361
+ | `resolveSafePath(base, user)` | Safely resolve a user-provided path |
362
+ | `sanitizeFilename(name)` | Strip dangerous characters from a filename |
358
363
 
359
364
  ### Quality
360
365
 
361
- | Function | Description |
362
- |----------|-------------|
366
+ | Function | Description |
367
+ | --------------------------------------------------------------------------- | ------------------------------------------------------------ |
363
368
  | `runQualityChecks({ packageJson, readme, lpmConfig, files, unpackedSize })` | Run all 28 quality checks and return score, checks, and tier |
364
369
 
365
370
  ### Package Config
366
371
 
367
- | Function | Description |
368
- |----------|-------------|
369
- | `parseLpmPackageReference(ref)` | Parse `@lpm.dev/owner.pkg@version?key=val` into components |
370
- | `readLpmConfig(dir)` | Read and validate `lpm.config.json` from a directory |
371
- | `validateLpmConfig(config)` | Validate a parsed config object |
372
- | `filterFiles(files, config, params)` | Filter file rules based on config conditions |
372
+ | Function | Description |
373
+ | ------------------------------------ | ---------------------------------------------------------- |
374
+ | `parseLpmPackageReference(ref)` | Parse `@lpm.dev/owner.pkg@version?key=val` into components |
375
+ | `readLpmConfig(dir)` | Read and validate `lpm.config.json` from a directory |
376
+ | `validateLpmConfig(config)` | Validate a parsed config object |
377
+ | `filterFiles(files, config, params)` | Filter file rules based on config conditions |
373
378
 
374
379
  ### Project Utils
375
380
 
376
- | Function | Description |
377
- |----------|-------------|
378
- | `detectFramework()` | Detect the project framework (nextjs, vite, remix, astro) |
379
- | `getDefaultPath(framework, name)` | Get the default component install path for a framework |
380
- | `getUserImportPrefix()` | Get the user's import alias prefix (e.g. `@/`, `~/`) |
381
+ | Function | Description |
382
+ | --------------------------------- | --------------------------------------------------------- |
383
+ | `detectFramework()` | Detect the project framework (nextjs, vite, remix, astro) |
384
+ | `getDefaultPath(framework, name)` | Get the default component install path for a framework |
385
+ | `getUserImportPrefix()` | Get the user's import alias prefix (e.g. `@/`, `~/`) |
381
386
 
382
387
  ## Security
383
388
 
package/bin/lpm.js CHANGED
@@ -103,6 +103,14 @@ program
103
103
  "--min-score <score>",
104
104
  "Minimum quality score required to publish (0-100)",
105
105
  )
106
+ .option(
107
+ "--provenance",
108
+ "Force OIDC token exchange for secret-free publishing (auto-detected in GitHub Actions with id-token: write)",
109
+ )
110
+ .option(
111
+ "--dry-run",
112
+ "Validate and preview the publish (files, size, OIDC status) without uploading",
113
+ )
106
114
  .action(publish)
107
115
 
108
116
  program
@@ -5,7 +5,11 @@ import path from "node:path"
5
5
  import { promisify } from "node:util"
6
6
  import * as p from "@clack/prompts"
7
7
  import { request, verifyTokenScope } from "../api.js"
8
- import { getRegistryUrl } from "../config.js"
8
+ import {
9
+ clearTransientToken,
10
+ getRegistryUrl,
11
+ setTransientToken,
12
+ } from "../config.js"
9
13
  import { SUCCESS_MESSAGES, WARNING_MESSAGES } from "../constants.js"
10
14
  import {
11
15
  createEcosystemTarball,
@@ -15,6 +19,12 @@ import {
15
19
  readSwiftManifest,
16
20
  } from "../ecosystem.js"
17
21
  import { generateIntegrity } from "../integrity.js"
22
+ import {
23
+ exchangeGitLabOidcToken,
24
+ exchangeOidcToken,
25
+ isGitHubActionsWithOidc,
26
+ isGitLabCiWithOidc,
27
+ } from "../oidc.js"
18
28
  import { displayQualityReport } from "../quality/display.js"
19
29
  import { runQualityChecks } from "../quality/score.js"
20
30
  import { createSpinner, log, printHeader } from "../ui.js"
@@ -298,9 +308,53 @@ async function packWithTar(ecosystem, name, version) {
298
308
  }
299
309
  }
300
310
 
311
+ /**
312
+ * Scan local project directory for test files.
313
+ * This avoids requiring test files in the tarball to earn quality points.
314
+ * Uses a limited-depth scan, skipping known non-source directories.
315
+ */
316
+ function scanProjectForTestFiles() {
317
+ const skipDirs = new Set([
318
+ "node_modules",
319
+ ".git",
320
+ "dist",
321
+ "build",
322
+ ".next",
323
+ ".nuxt",
324
+ "coverage",
325
+ ".lpm",
326
+ ])
327
+
328
+ const result = []
329
+
330
+ function walk(dir, depth = 0) {
331
+ if (depth > 4) return
332
+ let entries
333
+ try {
334
+ entries = fs.readdirSync(dir, { withFileTypes: true })
335
+ } catch {
336
+ return
337
+ }
338
+ for (const entry of entries) {
339
+ if (entry.isDirectory()) {
340
+ if (!skipDirs.has(entry.name)) {
341
+ walk(path.join(dir, entry.name), depth + 1)
342
+ }
343
+ } else if (entry.isFile()) {
344
+ result.push({ path: path.join(dir, entry.name) })
345
+ }
346
+ }
347
+ }
348
+
349
+ walk(process.cwd())
350
+ return result
351
+ }
352
+
301
353
  export async function publish(options = {}) {
302
354
  const checkOnly = !!options.check
355
+ const dryRun = !!options.dryRun
303
356
  const minScore = options.minScore ? parseInt(options.minScore, 10) : null
357
+ const forceProvenance = !!options.provenance
304
358
 
305
359
  printHeader()
306
360
 
@@ -456,11 +510,13 @@ export async function publish(options = {}) {
456
510
 
457
511
  // 7. Run quality checks and display report
458
512
  spinner.text = "Running quality checks..."
513
+ const projectFiles = scanProjectForTestFiles()
459
514
  const qualityResult = runQualityChecks({
460
515
  packageJson: pkg,
461
516
  readme,
462
517
  lpmConfig,
463
518
  files: npmPackMeta.files || [],
519
+ projectFiles,
464
520
  unpackedSize: npmPackMeta.unpackedSize,
465
521
  ecosystem,
466
522
  swiftManifest,
@@ -469,6 +525,41 @@ export async function publish(options = {}) {
469
525
  spinner.stop()
470
526
  displayQualityReport(qualityResult)
471
527
 
528
+ // --dry-run: print summary and exit without uploading
529
+ if (dryRun) {
530
+ const unpackedSize = npmPackMeta.unpackedSize || 0
531
+ const fileCount =
532
+ npmPackMeta.fileCount || (npmPackMeta.files?.length ?? 0)
533
+ const sizeStr =
534
+ unpackedSize >= 1024 * 1024
535
+ ? `${(unpackedSize / 1024 / 1024).toFixed(1)} MB unpacked`
536
+ : `${(unpackedSize / 1024).toFixed(1)} kB unpacked`
537
+
538
+ const githubOidc = isGitHubActionsWithOidc()
539
+ const gitlabOidc = isGitLabCiWithOidc()
540
+ const oidcStr = githubOidc
541
+ ? "GitHub Actions detected ✓"
542
+ : gitlabOidc
543
+ ? "GitLab CI detected ✓"
544
+ : "Not running in CI"
545
+ const authStr =
546
+ githubOidc || gitlabOidc
547
+ ? "Token will be exchanged at publish time"
548
+ : "Run lpm login or configure Trusted Publishers"
549
+
550
+ console.log("")
551
+ console.log(` Package ${name}`)
552
+ console.log(` Version ${version}`)
553
+ console.log(` Files ${fileCount} files`)
554
+ console.log(` Size ${sizeStr}`)
555
+ console.log("")
556
+ console.log(` OIDC ${oidcStr}`)
557
+ console.log(` Auth ${authStr}`)
558
+ console.log("")
559
+ log.info("Dry run complete — run without --dry-run to publish.")
560
+ process.exit(0)
561
+ }
562
+
472
563
  // --check mode: display report and exit
473
564
  if (checkOnly) {
474
565
  if (minScore && qualityResult.score < minScore) {
@@ -500,6 +591,50 @@ export async function publish(options = {}) {
500
591
  process.exit(0)
501
592
  }
502
593
 
594
+ // 8.5. OIDC auto-detection: exchange a GitHub Actions or GitLab CI OIDC
595
+ // token for a short-lived LPM publish token. Runs before auth verification
596
+ // so that verifyTokenScope() picks up the OIDC token via getToken().
597
+ const githubOidcAvailable = isGitHubActionsWithOidc()
598
+ const gitlabOidcAvailable = isGitLabCiWithOidc()
599
+ const oidcAvailable = githubOidcAvailable || gitlabOidcAvailable
600
+
601
+ if (forceProvenance && !oidcAvailable) {
602
+ p.cancel(
603
+ "--provenance requires OIDC to be available. " +
604
+ "For GitHub Actions, ensure `id-token: write` permission. " +
605
+ "For GitLab CI, configure id_tokens in your job.",
606
+ )
607
+ process.exit(1)
608
+ }
609
+ if (oidcAvailable) {
610
+ spinner.start()
611
+ if (githubOidcAvailable) {
612
+ spinner.text = "Requesting OIDC token from GitHub Actions..."
613
+ } else {
614
+ spinner.text = "Exchanging GitLab CI OIDC token..."
615
+ }
616
+ try {
617
+ const oidcToken = githubOidcAvailable
618
+ ? await exchangeOidcToken(name)
619
+ : await exchangeGitLabOidcToken(name)
620
+ setTransientToken(oidcToken)
621
+ spinner.stop()
622
+ const provider = githubOidcAvailable ? "GitHub Actions" : "GitLab CI"
623
+ log.success(
624
+ `OIDC authentication successful (${provider} — no secrets needed)`,
625
+ )
626
+ } catch (err) {
627
+ spinner.stop()
628
+ if (forceProvenance) {
629
+ // When --provenance is explicit, a failure is fatal
630
+ throw new Error(`OIDC exchange failed: ${err.message}`)
631
+ }
632
+ // Auto-detection: non-fatal, fall back to configured credentials
633
+ log.warn(`OIDC exchange failed: ${err.message}`)
634
+ log.warn("Falling back to stored credentials...")
635
+ }
636
+ }
637
+
503
638
  // 9. Verify authentication and owner permissions
504
639
  spinner.start()
505
640
  spinner.text = "Verifying authentication..."
@@ -695,6 +830,9 @@ export async function publish(options = {}) {
695
830
 
696
831
  process.exit(1)
697
832
  } finally {
833
+ // Clear OIDC transient token so it doesn't leak into subsequent operations
834
+ clearTransientToken()
835
+
698
836
  // Cleanup tarball (even on error)
699
837
  if (tarballPath && fs.existsSync(tarballPath)) {
700
838
  try {
@@ -161,6 +161,15 @@ function parseSkill(rawContent, filePath) {
161
161
  }
162
162
  }
163
163
 
164
+ // Optional version field (package version when skill was authored)
165
+ const version = frontmatter.version
166
+ if (version && typeof version !== "string") {
167
+ return {
168
+ skill: null,
169
+ error: `${filePath}: "version" field must be a string`,
170
+ }
171
+ }
172
+
164
173
  const content = markdownBody.trim()
165
174
 
166
175
  return {
@@ -168,6 +177,7 @@ function parseSkill(rawContent, filePath) {
168
177
  name: frontmatter.name,
169
178
  description: frontmatter.description,
170
179
  globs: globs && globs.length > 0 ? globs : null,
180
+ version: version || null,
171
181
  content,
172
182
  rawContent,
173
183
  sizeBytes,
package/lib/config.js CHANGED
@@ -37,25 +37,55 @@ const config = new Conf({
37
37
  // Token Management (Secure Storage)
38
38
  // ============================================================================
39
39
 
40
+ /**
41
+ * Transient token — set for the duration of an OIDC-authenticated publish.
42
+ * Takes priority over all other token sources so the short-lived OIDC token
43
+ * is used without writing to keychain or mutating process.env.
44
+ * @type {string | null}
45
+ */
46
+ let _transientToken = null
47
+
48
+ /**
49
+ * Set a transient token that overrides all other token sources.
50
+ * Used by OIDC token exchange to inject a short-lived publish token.
51
+ * @param {string} token
52
+ */
53
+ export function setTransientToken(token) {
54
+ _transientToken = token
55
+ }
56
+
57
+ /**
58
+ * Clear the transient token after use.
59
+ */
60
+ export function clearTransientToken() {
61
+ _transientToken = null
62
+ }
63
+
40
64
  /**
41
65
  * Get the stored auth token from secure storage.
42
- * Priority: LPM_TOKEN env var > secure storage > legacy storage
66
+ * Priority: transient (OIDC) > LPM_TOKEN env var > secure storage > legacy storage
67
+ * Tokens are scoped per registry URL so dev and production don't collide.
43
68
  * @returns {Promise<string | null>}
44
69
  */
45
70
  export async function getToken() {
71
+ // OIDC-exchanged short-lived token takes highest priority
72
+ if (_transientToken) return _transientToken
73
+
46
74
  // Check environment variable first (useful for CI/CD and testing)
47
75
  const envToken = process.env.LPM_TOKEN
48
76
  if (envToken) return envToken
49
77
 
50
- // Try secure store
51
- const secureToken = await secureGetToken()
78
+ const registryUrl = getRegistryUrl()
79
+
80
+ // Try secure store (registry-scoped)
81
+ const secureToken = await secureGetToken(registryUrl)
52
82
  if (secureToken) return secureToken
53
83
 
54
- // Migration: check if token exists in old storage
84
+ // Migration: check if token exists in old Conf storage
55
85
  const legacyToken = config.get("token")
56
86
  if (legacyToken) {
57
- // Migrate to secure storage
58
- await secureSetToken(legacyToken)
87
+ // Migrate to secure storage (scoped to current registry)
88
+ await secureSetToken(legacyToken, registryUrl)
59
89
  config.delete("token")
60
90
  return legacyToken
61
91
  }
@@ -64,26 +94,27 @@ export async function getToken() {
64
94
  }
65
95
 
66
96
  /**
67
- * Set the auth token in secure storage.
97
+ * Set the auth token in secure storage (scoped to current registry).
68
98
  * @param {string | null} token
69
99
  * @returns {Promise<void>}
70
100
  */
71
101
  export async function setToken(token) {
102
+ const registryUrl = getRegistryUrl()
72
103
  if (token === null) {
73
- await secureClearToken()
104
+ await secureClearToken(registryUrl)
74
105
  } else {
75
- await secureSetToken(token)
106
+ await secureSetToken(token, registryUrl)
76
107
  }
77
108
  // Ensure legacy token is cleared
78
109
  config.delete("token")
79
110
  }
80
111
 
81
112
  /**
82
- * Clear the auth token from secure storage.
113
+ * Clear the auth token from secure storage (scoped to current registry).
83
114
  * @returns {Promise<void>}
84
115
  */
85
116
  export async function clearToken() {
86
- await secureClearToken()
117
+ await secureClearToken(getRegistryUrl())
87
118
  config.delete("token")
88
119
  }
89
120
 
package/lib/oidc.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * OIDC (OpenID Connect) token exchange for secret-free publishing.
3
+ *
4
+ * When running in GitHub Actions with `id-token: write` permission,
5
+ * this module requests an OIDC JWT from GitHub and exchanges it for
6
+ * a short-lived LPM publish token — no LPM_TOKEN secret required.
7
+ *
8
+ * The package must have a matching trusted publisher configured in
9
+ * the LPM dashboard (Workflow tab) before this will succeed.
10
+ *
11
+ * @module cli/lib/oidc
12
+ */
13
+
14
+ import { getRegistryUrl } from "./config.js"
15
+
16
+ /**
17
+ * Detect whether we are running in GitHub Actions with OIDC available.
18
+ * Both env vars must be set for token request to work.
19
+ *
20
+ * @returns {boolean}
21
+ */
22
+ export function isGitHubActionsWithOidc() {
23
+ return !!(
24
+ process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
25
+ process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Detect whether we are running in GitLab CI with an OIDC token injected.
31
+ * GitLab injects the JWT directly as an env var via id_tokens — no HTTP fetch needed.
32
+ *
33
+ * @returns {boolean}
34
+ */
35
+ export function isGitLabCiWithOidc() {
36
+ return !!(
37
+ process.env.GITLAB_CI === "true" && process.env.LPM_GITLAB_OIDC_TOKEN
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Exchange a GitHub Actions OIDC JWT for a short-lived LPM publish token.
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
52
+ */
53
+ export async function exchangeOidcToken(packageName) {
54
+ // 1. Request OIDC JWT from GitHub Actions
55
+ // The audience must match EXPECTED_AUDIENCE in lib/registry/oidc.js on the server
56
+ const githubTokenUrl = `${process.env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://lpm.dev`
57
+
58
+ const githubRes = await fetch(githubTokenUrl, {
59
+ headers: {
60
+ Authorization: `bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
61
+ },
62
+ })
63
+
64
+ if (!githubRes.ok) {
65
+ const body = await githubRes.text().catch(() => "")
66
+ throw new Error(
67
+ `GitHub OIDC token request failed (${githubRes.status})${body ? `: ${body}` : ""}`,
68
+ )
69
+ }
70
+
71
+ const { value: oidcJwt } = await githubRes.json()
72
+ if (!oidcJwt) {
73
+ throw new Error("GitHub OIDC response did not contain a token value")
74
+ }
75
+
76
+ // 2. Exchange the OIDC JWT for a short-lived LPM publish token
77
+ const registryUrl = getRegistryUrl()
78
+ const exchangeRes = await fetch(`${registryUrl}/api/registry/-/token/oidc`, {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/json" },
81
+ body: JSON.stringify({ token: oidcJwt, package: packageName }),
82
+ })
83
+
84
+ if (!exchangeRes.ok) {
85
+ const body = await exchangeRes.text().catch(() => "")
86
+ throw new Error(
87
+ `OIDC token exchange failed (${exchangeRes.status})${body ? `: ${body}` : ""}`,
88
+ )
89
+ }
90
+
91
+ const { token } = await exchangeRes.json()
92
+ if (!token) {
93
+ throw new Error("OIDC exchange response did not contain a token")
94
+ }
95
+
96
+ return token
97
+ }
98
+
99
+ /**
100
+ * Exchange a GitLab CI OIDC JWT for a short-lived LPM publish token.
101
+ *
102
+ * GitLab injects the JWT directly into LPM_GITLAB_OIDC_TOKEN via id_tokens — no
103
+ * intermediate HTTP fetch is needed (unlike GitHub Actions).
104
+ *
105
+ * @param {string} packageName - Full package name, e.g. "@lpm.dev/owner.pkg-name"
106
+ * @returns {Promise<string>} Short-lived LPM publish token
107
+ * @throws {Error} If the OIDC token is missing or the LPM exchange is rejected
108
+ */
109
+ export async function exchangeGitLabOidcToken(packageName) {
110
+ const oidcJwt = process.env.LPM_GITLAB_OIDC_TOKEN
111
+ if (!oidcJwt) {
112
+ throw new Error(
113
+ "LPM_GITLAB_OIDC_TOKEN is not set. " +
114
+ "Add id_tokens: LPM_GITLAB_OIDC_TOKEN: { aud: https://lpm.dev } to your .gitlab-ci.yml",
115
+ )
116
+ }
117
+
118
+ const registryUrl = getRegistryUrl()
119
+ const exchangeRes = await fetch(`${registryUrl}/api/registry/-/token/oidc`, {
120
+ method: "POST",
121
+ headers: { "Content-Type": "application/json" },
122
+ body: JSON.stringify({ token: oidcJwt, package: packageName }),
123
+ })
124
+
125
+ if (!exchangeRes.ok) {
126
+ const body = await exchangeRes.text().catch(() => "")
127
+ throw new Error(
128
+ `OIDC token exchange failed (${exchangeRes.status})${body ? `: ${body}` : ""}`,
129
+ )
130
+ }
131
+
132
+ const { token } = await exchangeRes.json()
133
+ if (!token) {
134
+ throw new Error("OIDC exchange response did not contain a token")
135
+ }
136
+
137
+ return token
138
+ }
@@ -336,8 +336,11 @@ const hasTestFiles = {
336
336
  category: "testing",
337
337
  label: "Has test files",
338
338
  maxPoints: 7,
339
- run: ({ files }) => {
340
- const testFiles = files.filter(f => {
339
+ run: ({ projectFiles, files }) => {
340
+ // Prefer projectFiles (local project scan) over files (tarball)
341
+ // so authors don't need to ship test files in the package
342
+ const fileList = projectFiles || files
343
+ const testFiles = fileList.filter(f => {
341
344
  const name = (f.path || f).toLowerCase()
342
345
  return (
343
346
  name.includes(".test.") ||
@@ -76,6 +76,14 @@ export function displayQualityReport({ score, checks, meta }) {
76
76
  }
77
77
  }
78
78
 
79
+ if (failed.length > 0 && score < 90) {
80
+ console.log(
81
+ chalk.dim(
82
+ " Use the lpm-guide skill in your AI editor to improve quality and prepare for publish",
83
+ ),
84
+ )
85
+ }
86
+
79
87
  // Score summary at the end
80
88
  console.log("")
81
89
  console.log(
@@ -37,6 +37,7 @@ function getChecksForEcosystem(ecosystem) {
37
37
  * @param {string|null} params.readme - README content
38
38
  * @param {object|null} params.lpmConfig - Parsed lpm.config.json
39
39
  * @param {Array} params.files - File list (each has .path)
40
+ * @param {Array} [params.projectFiles] - Local project file list for test detection
40
41
  * @param {number} [params.unpackedSize] - Unpacked size in bytes
41
42
  * @param {string} [params.ecosystem] - Ecosystem identifier (default: "js")
42
43
  * @param {object} [params.swiftManifest] - Parsed Swift manifest metadata (for Swift packages)
@@ -47,6 +48,7 @@ export function runQualityChecks({
47
48
  readme,
48
49
  lpmConfig,
49
50
  files,
51
+ projectFiles,
50
52
  unpackedSize,
51
53
  ecosystem = "js",
52
54
  swiftManifest,
@@ -57,6 +59,7 @@ export function runQualityChecks({
57
59
  readme,
58
60
  lpmConfig,
59
61
  files,
62
+ projectFiles,
60
63
  unpackedSize,
61
64
  swiftManifest,
62
65
  xcframeworkMeta,
@@ -259,30 +259,63 @@ export async function isUsingKeychain() {
259
259
  }
260
260
 
261
261
  // ============================================================================
262
- // Token-Specific Helpers
262
+ // Token-Specific Helpers (Registry-Aware)
263
263
  // ============================================================================
264
264
 
265
265
  /**
266
- * Get the stored auth token.
266
+ * Build a registry-scoped key for token storage.
267
+ * Each registry URL gets its own token slot so dev and production
268
+ * tokens don't overwrite each other.
269
+ * @param {string} [registryUrl]
270
+ * @returns {string}
271
+ */
272
+ function tokenKey(registryUrl) {
273
+ if (!registryUrl) return KEYTAR_ACCOUNT_NAME
274
+ return `${KEYTAR_ACCOUNT_NAME}:${registryUrl}`
275
+ }
276
+
277
+ /**
278
+ * Get the stored auth token for a specific registry.
279
+ * Falls back to the legacy un-scoped key and migrates it forward.
280
+ * @param {string} [registryUrl] - Registry URL to scope the token to
267
281
  * @returns {Promise<string | null>}
268
282
  */
269
- export async function getToken() {
270
- return getSecret(KEYTAR_ACCOUNT_NAME)
283
+ export async function getToken(registryUrl) {
284
+ const key = tokenKey(registryUrl)
285
+
286
+ // Try registry-scoped key first
287
+ const scoped = await getSecret(key)
288
+ if (scoped) return scoped
289
+
290
+ // Migration: if legacy un-scoped token exists, migrate it to the
291
+ // default registry key (production) so existing logins keep working
292
+ if (registryUrl) {
293
+ const legacy = await getSecret(KEYTAR_ACCOUNT_NAME)
294
+ if (legacy) {
295
+ await setSecret(key, legacy)
296
+ await deleteSecret(KEYTAR_ACCOUNT_NAME)
297
+ return legacy
298
+ }
299
+ }
300
+
301
+ return null
271
302
  }
272
303
 
273
304
  /**
274
- * Set the auth token.
305
+ * Set the auth token for a specific registry.
275
306
  * @param {string} token
307
+ * @param {string} [registryUrl] - Registry URL to scope the token to
276
308
  * @returns {Promise<void>}
277
309
  */
278
- export async function setToken(token) {
279
- return setSecret(KEYTAR_ACCOUNT_NAME, token)
310
+ export async function setToken(token, registryUrl) {
311
+ return setSecret(tokenKey(registryUrl), token)
280
312
  }
281
313
 
282
314
  /**
283
- * Clear the auth token.
315
+ * Clear the auth token for a specific registry.
316
+ * @param {string} [registryUrl] - Registry URL to scope the token to
284
317
  * @returns {Promise<boolean>}
285
318
  */
286
- export async function clearToken() {
287
- return deleteSecret(KEYTAR_ACCOUNT_NAME)
319
+ export async function clearToken(registryUrl) {
320
+ return deleteSecret(tokenKey(registryUrl))
288
321
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpm-registry/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "CLI for Licensed Package Manager",
5
5
  "author": "LPM <hello@lpm.dev> (https://lpm.dev)",
6
6
  "repository": {