Greenfield #2 "建插件" modules for the forthcoming `dsh-sdk create <source>` command, in a new foundation-independent package so it touches none of the dsh-scripts / dsh-helper / create-sdk hotspots the foundation refactor edits. - `resolvePluginSource(spec)` parses `owner/repo[/subdir]#ref` (github) or `pkg@version` (npm) into a `PluginSource` discriminated union, failing loud on an ambiguous or malformed spec. - `PluginFetcher<S>` seam + `fetchPlugin` tag dispatch returning a common `FetchedPlugin` (temp dir + immutable provenance). - `GigetFetcher` (github) over @bluwy/giget-core: resolve `#ref` to a commit SHA first, download that SHA; provenance pins the SHA. Chosen over unjs/giget for its single runtime dep and absent install/action surface. - `PacoteFetcher` (npm) over pacote: resolve the manifest, then extract the tarball verified against its registry integrity. Registry-only is enforced by the source resolver; extract runs no lifecycle scripts. - Branded `CommitSha`/`Integrity`; network + temp-dir boundaries are injected so the logic is unit-tested at 100% per-file coverage without network. Wiring (package.json pin, cordis.yml via ProjectEditSession with a confirmed diff, install --ignore-scripts) and the launcher command registration land later with the foundation.
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { extract as pacoteExtract, manifest as pacoteManifest } from 'pacote'
|
|
import {
|
|
createPacoteFetcher,
|
|
NPM_TEMP_PREFIX,
|
|
PacoteFetcher,
|
|
type PacoteApi,
|
|
type PacoteFetcherDeps,
|
|
} from '../src/pacote-fetcher.ts'
|
|
import type { NpmSource } from '../src/source.ts'
|
|
|
|
vi.mock('pacote', () => ({ manifest: vi.fn(), extract: vi.fn() }))
|
|
|
|
const INTEGRITY = 'sha512-abcABC123+/=='
|
|
const RESOLVED = 'https://registry.npmjs.org/plugin/-/plugin-1.2.3.tgz'
|
|
|
|
function fakePacote(): PacoteApi {
|
|
return {
|
|
manifest: vi.fn(async () => ({ _resolved: RESOLVED, _integrity: INTEGRITY, version: '1.2.3' })),
|
|
extract: vi.fn(async () => ({ resolved: RESOLVED, integrity: INTEGRITY })),
|
|
}
|
|
}
|
|
|
|
function deps(overrides: Partial<PacoteFetcherDeps> = {}): PacoteFetcherDeps {
|
|
return {
|
|
pacote: fakePacote(),
|
|
createTempDir: vi.fn(async () => '/tmp/dsh-plugin-npm-abc'),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const SOURCE: NpmSource = { kind: 'npm', name: 'plugin', version: '^1.0.0' }
|
|
|
|
describe('PacoteFetcher.fetch', () => {
|
|
it('resolves the manifest, extracts with integrity, and reports provenance', async () => {
|
|
const d = deps()
|
|
const result = await new PacoteFetcher(d).fetch(SOURCE)
|
|
|
|
expect(d.pacote.manifest).toHaveBeenCalledWith('plugin@^1.0.0', {})
|
|
expect(d.createTempDir).toHaveBeenCalledWith(NPM_TEMP_PREFIX)
|
|
expect(d.pacote.extract).toHaveBeenCalledWith('plugin@^1.0.0', '/tmp/dsh-plugin-npm-abc', {
|
|
resolved: RESOLVED,
|
|
integrity: INTEGRITY,
|
|
})
|
|
expect(result).toEqual({
|
|
dir: '/tmp/dsh-plugin-npm-abc',
|
|
source: SOURCE,
|
|
provenance: { kind: 'npm', version: '1.2.3', resolved: RESOLVED, integrity: INTEGRITY },
|
|
})
|
|
})
|
|
|
|
it('forwards a configured registry to both manifest and extract', async () => {
|
|
const d = deps({ registry: 'https://npm.internal/' })
|
|
await new PacoteFetcher(d).fetch(SOURCE)
|
|
expect(d.pacote.manifest).toHaveBeenCalledWith('plugin@^1.0.0', { registry: 'https://npm.internal/' })
|
|
expect(d.pacote.extract).toHaveBeenCalledWith('plugin@^1.0.0', '/tmp/dsh-plugin-npm-abc', {
|
|
registry: 'https://npm.internal/',
|
|
resolved: RESOLVED,
|
|
integrity: INTEGRITY,
|
|
})
|
|
})
|
|
|
|
it('rejects a registry integrity that is not a valid SRI', async () => {
|
|
const pacote = fakePacote()
|
|
pacote.extract = vi.fn(async () => ({ resolved: RESOLVED, integrity: 'not-sri' }))
|
|
await expect(new PacoteFetcher(deps({ pacote })).fetch(SOURCE)).rejects.toThrow(
|
|
/invalid subresource integrity/,
|
|
)
|
|
})
|
|
|
|
it('exposes its source kind', () => {
|
|
expect(new PacoteFetcher(deps()).kind).toBe('npm')
|
|
})
|
|
})
|
|
|
|
describe('createPacoteFetcher', () => {
|
|
const manifestMock = vi.mocked(pacoteManifest)
|
|
const extractMock = vi.mocked(pacoteExtract)
|
|
|
|
beforeEach(() => {
|
|
manifestMock.mockReset()
|
|
extractMock.mockReset()
|
|
// The real overloaded pacote manifest returns a much wider shape; the fetcher reads only these fields.
|
|
manifestMock.mockResolvedValue(
|
|
{ _resolved: RESOLVED, _integrity: INTEGRITY, version: '1.2.3' } as unknown as Awaited<
|
|
ReturnType<typeof pacoteManifest>
|
|
>,
|
|
)
|
|
extractMock.mockResolvedValue({ from: 'plugin@1.2.3', resolved: RESOLVED, integrity: INTEGRITY })
|
|
})
|
|
|
|
afterEach(() => vi.clearAllMocks())
|
|
|
|
it('wires the real pacote resolve/extract surface', async () => {
|
|
const result = await createPacoteFetcher().fetch(SOURCE)
|
|
expect(manifestMock).toHaveBeenCalledWith('plugin@^1.0.0', {})
|
|
expect(extractMock).toHaveBeenCalledWith('plugin@^1.0.0', expect.stringContaining(NPM_TEMP_PREFIX), {
|
|
resolved: RESOLVED,
|
|
integrity: INTEGRITY,
|
|
})
|
|
expect(result.provenance).toEqual({ kind: 'npm', version: '1.2.3', resolved: RESOLVED, integrity: INTEGRITY })
|
|
})
|
|
|
|
it('forwards a configured registry through the real surface', async () => {
|
|
await createPacoteFetcher({ registry: 'https://npm.internal/' }).fetch(SOURCE)
|
|
expect(manifestMock).toHaveBeenCalledWith('plugin@^1.0.0', { registry: 'https://npm.internal/' })
|
|
})
|
|
})
|