Skip to content

Commit 09cef7a

Browse files
committed
fix: harden npm registry installs
1 parent ebbaf48 commit 09cef7a

File tree

17 files changed

+834
-244
lines changed

17 files changed

+834
-244
lines changed

packages/skills-package-manager/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,14 @@ const skills = await listRepoSkills('vercel-labs', 'skills')
125125

126126
## Specifier Format
127127

128-
```
129-
<source>#[ref&]path:<skill-path>
128+
```text
129+
git/file/npm: <source>#[ref&]path:<skill-path>
130+
link: link:<path-to-skill-dir>
130131
```
131132

132133
| Part | Description | Example |
133134
|------|-------------|---------|
134-
| `source` | Git URL, direct `link:` skill path, `file:` tarball, or `npm:` package | `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `file:./skills.tgz`, `npm:@scope/pkg` |
135+
| `source` | Git URL, direct `link:` skill path, `file:` tarball, or `npm:` package name | `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `file:./skills.tgz`, `npm:@scope/pkg` |
135136
| `ref` | Optional git ref | `main`, `v1.0.0`, `HEAD`, `6cb0992`, `6cb0992a176f2ca142e19f64dca8ac12025b035e` |
136137
| `path` | Path to skill directory within source | `/skills/my-skill` |
137138

@@ -142,7 +143,9 @@ const skills = await listRepoSkills('vercel-labs', 'skills')
142143
- **`git`** — Clones the repo, resolves commit hash, copies skill files
143144
- **`link`** — Reads from a local directory and copies the selected skill
144145
- **`file`** — Extracts a local `tgz` package and copies the selected skill
145-
- **`npm`** — Packs an npm package source, locks the resolved version, and installs from the package contents
146+
- **`npm`** — Resolves a package from the configured npm registry, locks the tarball URL/version/integrity, and installs from the downloaded tarball
147+
148+
`npm:` reads `registry` and scoped `@scope:registry` values from `.npmrc`. Matching `:_authToken`, `:_auth`, or `username` + `:_password` entries are also used for private registry requests.
146149

147150
## Architecture
148151

packages/skills-package-manager/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@clack/prompts": "^1.1.0",
2525
"cac": "^7.0.0",
2626
"picocolors": "^1.1.1",
27+
"semver": "^7.7.2",
2728
"tar": "^7.4.3",
2829
"yaml": "^2.8.1"
2930
},

packages/skills-package-manager/src/commands/update.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<Upda
9191
previous.resolution.packageName === entry.resolution.packageName &&
9292
previous.resolution.version === entry.resolution.version &&
9393
previous.resolution.path === entry.resolution.path &&
94+
previous.resolution.tarball === entry.resolution.tarball &&
9495
previous.resolution.integrity === entry.resolution.integrity
9596
) {
9697
result.unchanged.push(skillName)

packages/skills-package-manager/src/config/syncSkillsLock.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ export async function resolveLockEntry(
157157
integrity: resolved.integrity,
158158
registry: resolved.registry,
159159
},
160-
digest: sha256(`${resolved.name}:${resolved.version}:${resolved.tarballUrl}:${normalized.path}`),
160+
digest: sha256(
161+
`${resolved.name}:${resolved.version}:${resolved.tarballUrl}:${normalized.path}`,
162+
),
161163
},
162164
}
163165
}

packages/skills-package-manager/src/config/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ export type SkillsLockEntry = {
2020
| { type: 'link'; path: string }
2121
| { type: 'file'; tarball: string; path: string }
2222
| { type: 'git'; url: string; commit: string; path: string }
23-
| { type: 'npm'; packageName: string; version: string; path: string; integrity?: string }
23+
| {
24+
type: 'npm'
25+
packageName: string
26+
version: string
27+
path: string
28+
tarball: string
29+
integrity?: string
30+
registry?: string
31+
}
2432
digest: string
2533
}
2634

packages/skills-package-manager/src/install/installSkills.ts

Lines changed: 96 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1+
import { access } from 'node:fs/promises'
12
import path from 'node:path'
23
import { isLockInSync } from '../config/compareSkillsLock'
34
import { readSkillsLock } from '../config/readSkillsLock'
45
import { readSkillsManifest } from '../config/readSkillsManifest'
56
import { syncSkillsLock } from '../config/syncSkillsLock'
67
import type { InstallProgressListener, SkillsLock, SkillsManifest } from '../config/types'
78
import { writeSkillsLock } from '../config/writeSkillsLock'
8-
import { cleanupPackedNpmPackage, packNpmPackage } from '../npm/packPackage'
9-
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
9+
import { cleanupPackedNpmPackage, downloadNpmPackageTarball } from '../npm/packPackage'
1010
import { sha256 } from '../utils/hash'
1111
import { readInstallState, writeInstallState } from './installState'
1212
import { linkSkill } from './links'
@@ -19,19 +19,20 @@ export const installStageHooks = {
1919
beforeFetch: async (_rootDir: string, _manifest: SkillsManifest, _lockfile: SkillsLock) => {},
2020
}
2121

22-
function resolveNpmPackSource(specifier: string, packageName: string, version: string): string {
23-
const normalized = normalizeSpecifier(specifier)
24-
const source = normalized.source.slice('npm:'.length)
25-
26-
if (source.startsWith('.') || source.startsWith('/') || source.startsWith('~')) {
27-
return source
28-
}
29-
30-
if (source.includes(':')) {
31-
return source
22+
async function areManagedSkillsInstalled(
23+
rootDir: string,
24+
installDir: string,
25+
skillNames: string[],
26+
): Promise<boolean> {
27+
for (const skillName of skillNames) {
28+
try {
29+
await access(path.join(rootDir, installDir, skillName, 'SKILL.md'))
30+
} catch {
31+
return false
32+
}
3233
}
3334

34-
return `${packageName}@${version}`
35+
return true
3536
}
3637

3738
export async function fetchSkillsFromLock(
@@ -44,87 +45,107 @@ export async function fetchSkillsFromLock(
4445
) {
4546
await installStageHooks.beforeFetch(rootDir, manifest, lockfile)
4647

47-
const lockDigest = sha256(JSON.stringify(lockfile))
48-
const state = await readInstallState(rootDir)
49-
if (state?.lockDigest === lockDigest) {
50-
return { status: 'skipped', reason: 'up-to-date' } as const
51-
}
52-
5348
const installDir = manifest.installDir ?? '.agents/skills'
5449
const linkTargets = manifest.linkTargets ?? []
5550

5651
await pruneManagedSkills(rootDir, installDir, linkTargets, Object.keys(lockfile.skills))
5752

58-
for (const [skillName, entry] of Object.entries(lockfile.skills)) {
59-
if (entry.resolution.type === 'link') {
60-
await materializeLocalSkill(
61-
rootDir,
62-
skillName,
63-
path.resolve(rootDir, entry.resolution.path),
64-
'/',
65-
installDir,
66-
)
67-
continue
68-
}
53+
const lockDigest = sha256(JSON.stringify(lockfile))
54+
const state = await readInstallState(rootDir, installDir)
55+
if (
56+
state?.lockDigest === lockDigest &&
57+
(await areManagedSkillsInstalled(rootDir, installDir, Object.keys(lockfile.skills)))
58+
) {
59+
return { status: 'skipped', reason: 'up-to-date' } as const
60+
}
6961

70-
if (entry.resolution.type === 'file') {
71-
await materializePackedSkill(
72-
rootDir,
73-
skillName,
74-
path.resolve(rootDir, entry.resolution.tarball),
75-
entry.resolution.path,
76-
installDir,
77-
)
78-
options?.onProgress?.({ type: 'added', skillName })
79-
continue
80-
}
62+
const downloadedTarballs = new Map<string, Promise<string>>()
8163

82-
if (entry.resolution.type === 'git') {
83-
await materializeGitSkill(
84-
rootDir,
85-
skillName,
86-
entry.resolution.url,
87-
entry.resolution.commit,
88-
entry.resolution.path,
89-
installDir,
90-
)
91-
options?.onProgress?.({ type: 'added', skillName })
92-
continue
93-
}
64+
try {
65+
for (const [skillName, entry] of Object.entries(lockfile.skills)) {
66+
if (entry.resolution.type === 'link') {
67+
await materializeLocalSkill(
68+
rootDir,
69+
skillName,
70+
path.resolve(rootDir, entry.resolution.path),
71+
'/',
72+
installDir,
73+
)
74+
options?.onProgress?.({ type: 'added', skillName })
75+
continue
76+
}
9477

95-
if (entry.resolution.type === 'npm') {
96-
const packed = await packNpmPackage(
97-
resolveNpmPackSource(
98-
entry.specifier,
99-
entry.resolution.packageName,
100-
entry.resolution.version,
101-
),
102-
)
78+
if (entry.resolution.type === 'file') {
79+
await materializePackedSkill(
80+
rootDir,
81+
skillName,
82+
path.resolve(rootDir, entry.resolution.tarball),
83+
entry.resolution.path,
84+
installDir,
85+
)
86+
options?.onProgress?.({ type: 'added', skillName })
87+
continue
88+
}
10389

104-
try {
90+
if (entry.resolution.type === 'git') {
91+
await materializeGitSkill(
92+
rootDir,
93+
skillName,
94+
entry.resolution.url,
95+
entry.resolution.commit,
96+
entry.resolution.path,
97+
installDir,
98+
)
99+
options?.onProgress?.({ type: 'added', skillName })
100+
continue
101+
}
102+
103+
if (entry.resolution.type === 'npm') {
104+
const cacheKey = `${entry.resolution.tarball}\0${entry.resolution.integrity ?? ''}`
105+
let tarballPathPromise = downloadedTarballs.get(cacheKey)
106+
if (!tarballPathPromise) {
107+
tarballPathPromise = downloadNpmPackageTarball(
108+
rootDir,
109+
entry.resolution.tarball,
110+
entry.resolution.integrity,
111+
)
112+
downloadedTarballs.set(cacheKey, tarballPathPromise)
113+
}
114+
115+
const tarballPath = await tarballPathPromise
105116
await materializePackedSkill(
106117
rootDir,
107118
skillName,
108-
packed.tarballPath,
119+
tarballPath,
109120
entry.resolution.path,
110121
installDir,
111122
)
112-
} finally {
113-
await cleanupPackedNpmPackage(packed.tarballPath)
123+
options?.onProgress?.({ type: 'added', skillName })
124+
continue
114125
}
115-
continue
126+
127+
throw new Error(`Unsupported resolution type in 0.1.0 core flow: ${entry.resolution.type}`)
116128
}
117129

118-
throw new Error(`Unsupported resolution type in 0.1.0 core flow: ${entry.resolution.type}`)
119-
}
130+
await writeInstallState(rootDir, installDir, {
131+
lockDigest,
132+
installDir,
133+
linkTargets,
134+
installerVersion: '0.1.0',
135+
installedAt: new Date().toISOString(),
136+
})
137+
} finally {
138+
const settledTarballs = await Promise.allSettled(downloadedTarballs.values())
139+
const downloadedPaths = new Set(
140+
settledTarballs
141+
.filter(
142+
(result): result is PromiseFulfilledResult<string> => result.status === 'fulfilled',
143+
)
144+
.map((result) => result.value),
145+
)
120146

121-
await writeInstallState(rootDir, {
122-
lockDigest,
123-
installDir,
124-
linkTargets,
125-
installerVersion: '0.1.0',
126-
installedAt: new Date().toISOString(),
127-
})
147+
await Promise.all([...downloadedPaths].map((tarballPath) => cleanupPackedNpmPackage(tarballPath)))
148+
}
128149

129150
return { status: 'fetched', fetched: Object.keys(lockfile.skills) } as const
130151
}
@@ -164,7 +185,6 @@ export async function installSkills(
164185
let lockfile: SkillsLock
165186

166187
if (options?.frozenLockfile) {
167-
// Frozen mode: lock must exist and be in sync
168188
if (!currentLock) {
169189
throw new Error('Lockfile is required in frozen mode but none was found')
170190
}
@@ -178,7 +198,6 @@ export async function installSkills(
178198
options?.onProgress?.({ type: 'resolved', skillName })
179199
}
180200
} else {
181-
// Normal mode: sync lock with manifest (may trigger network requests)
182201
lockfile = await syncSkillsLock(rootDir, manifest, currentLock, {
183202
onProgress: options?.onProgress,
184203
})
@@ -187,7 +206,6 @@ export async function installSkills(
187206
await fetchSkillsFromLock(rootDir, manifest, lockfile, { onProgress: options?.onProgress })
188207
await linkSkillsFromLock(rootDir, manifest, lockfile, { onProgress: options?.onProgress })
189208

190-
// Write lockfile only after all operations succeed (atomicity)
191209
if (!options?.frozenLockfile) {
192210
await writeSkillsLock(rootDir, lockfile)
193211
}

packages/skills-package-manager/src/install/installState.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { readFile } from 'node:fs/promises'
22
import path from 'node:path'
33
import { ensureDir, writeJson } from '../utils/fs'
44

5-
export async function readInstallState(rootDir: string) {
6-
const filePath = path.join(rootDir, '.agents/skills/.skills-pm-install-state.json')
5+
const INSTALL_STATE_FILE = '.skills-pm-install-state.json'
6+
7+
export async function readInstallState(rootDir: string, installDir: string) {
8+
const filePath = path.join(rootDir, installDir, INSTALL_STATE_FILE)
79

810
try {
911
return JSON.parse(await readFile(filePath, 'utf8'))
@@ -12,9 +14,9 @@ export async function readInstallState(rootDir: string) {
1214
}
1315
}
1416

15-
export async function writeInstallState(rootDir: string, value: unknown) {
16-
const dirPath = path.join(rootDir, '.agents/skills')
17+
export async function writeInstallState(rootDir: string, installDir: string, value: unknown) {
18+
const dirPath = path.join(rootDir, installDir)
1719
await ensureDir(dirPath)
18-
const filePath = path.join(dirPath, '.skills-pm-install-state.json')
20+
const filePath = path.join(dirPath, INSTALL_STATE_FILE)
1921
await writeJson(filePath, value)
2022
}

packages/skills-package-manager/src/install/materializeLocalSkill.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { cp, readFile } from 'node:fs/promises'
1+
import { readFile } from 'node:fs/promises'
22
import path from 'node:path'
3-
import { ensureDir, writeJson } from '../utils/fs'
3+
import { ensureDir, replaceDir, writeJson } from '../utils/fs'
44

55
export async function materializeLocalSkill(
66
rootDir: string,
@@ -26,7 +26,7 @@ export async function materializeLocalSkill(
2626

2727
const targetDir = path.join(rootDir, installDir, skillName)
2828
await ensureDir(path.dirname(targetDir))
29-
await cp(absoluteSkillPath, targetDir, { recursive: true, force: true })
29+
await replaceDir(absoluteSkillPath, targetDir)
3030
await writeJson(path.join(targetDir, '.skills-pm.json'), {
3131
name: skillName,
3232
installedBy: 'skills-package-manager',

0 commit comments

Comments
 (0)