@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 +46 -2
- package/README.md +45 -40
- package/bin/lpm.js +8 -0
- package/lib/commands/publish.js +139 -1
- package/lib/commands/skills.js +10 -0
- package/lib/config.js +42 -11
- package/lib/oidc.js +138 -0
- package/lib/quality/checks.js +5 -2
- package/lib/quality/display.js +8 -0
- package/lib/quality/score.js +3 -0
- package/lib/secure-store.js +43 -10
- package/package.json +1 -1
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
|
|
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
|
|
16
|
-
|
|
17
|
-
| `lpm i`
|
|
18
|
-
| `lpm p`
|
|
19
|
-
| `lpm l`
|
|
20
|
-
| `lpm lo`
|
|
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
|
|
107
|
-
|
|
108
|
-
| `--check`
|
|
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
|
|
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
|
|
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
|
|
344
|
-
|
|
345
|
-
| `generateIntegrity(buffer, algorithm?)`
|
|
346
|
-
| `verifyIntegrity(buffer, expected)`
|
|
347
|
-
| `verifyIntegrityMultiple(buffer, integrities)` | Verify against multiple integrity strings
|
|
348
|
-
| `parseIntegrity(integrity)`
|
|
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
|
|
353
|
-
|
|
354
|
-
| `validateComponentPath(root, path)`
|
|
355
|
-
| `validateTarballPaths(extractDir, paths)` | Check tarball entries for path traversal attacks
|
|
356
|
-
| `resolveSafePath(base, user)`
|
|
357
|
-
| `sanitizeFilename(name)`
|
|
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
|
|
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
|
|
368
|
-
|
|
369
|
-
| `parseLpmPackageReference(ref)`
|
|
370
|
-
| `readLpmConfig(dir)`
|
|
371
|
-
| `validateLpmConfig(config)`
|
|
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
|
|
377
|
-
|
|
378
|
-
| `detectFramework()`
|
|
379
|
-
| `getDefaultPath(framework, name)` | Get the default component install path for a framework
|
|
380
|
-
| `getUserImportPrefix()`
|
|
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
|
package/lib/commands/publish.js
CHANGED
|
@@ -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 {
|
|
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 {
|
package/lib/commands/skills.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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
|
+
}
|
package/lib/quality/checks.js
CHANGED
|
@@ -336,8 +336,11 @@ const hasTestFiles = {
|
|
|
336
336
|
category: "testing",
|
|
337
337
|
label: "Has test files",
|
|
338
338
|
maxPoints: 7,
|
|
339
|
-
run: ({ files }) => {
|
|
340
|
-
|
|
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.") ||
|
package/lib/quality/display.js
CHANGED
|
@@ -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(
|
package/lib/quality/score.js
CHANGED
|
@@ -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,
|
package/lib/secure-store.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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(
|
|
319
|
+
export async function clearToken(registryUrl) {
|
|
320
|
+
return deleteSecret(tokenKey(registryUrl))
|
|
288
321
|
}
|