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.
146 lines
6.2 KiB
TypeScript
146 lines
6.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
|
import { downloadTemplate } from '@bluwy/giget-core'
|
|
import {
|
|
createGigetFetcher,
|
|
defaultResolveRef,
|
|
GigetFetcher,
|
|
GITHUB_TEMP_PREFIX,
|
|
type GigetFetcherDeps,
|
|
} from '../src/giget-fetcher.ts'
|
|
import type { CommitSha } from '../src/ids.ts'
|
|
import type { GithubSource } from '../src/source.ts'
|
|
|
|
vi.mock('@bluwy/giget-core', () => ({ downloadTemplate: vi.fn(async (_input: string, options: { dir: string }) => ({ dir: options.dir, source: '', info: { name: '', tar: '' } })) }))
|
|
|
|
const SHA = 'a'.repeat(40)
|
|
|
|
/** A `fetch` mock typed with the call signature the assertions destructure. */
|
|
function fetchReturning(response: Response): Mock<(url: string, init?: RequestInit) => Promise<Response>> {
|
|
return vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(response))
|
|
}
|
|
|
|
function fakeDeps(overrides: Partial<GigetFetcherDeps> = {}): {
|
|
deps: GigetFetcherDeps
|
|
download: ReturnType<typeof vi.fn>
|
|
resolveRef: ReturnType<typeof vi.fn>
|
|
createTempDir: ReturnType<typeof vi.fn>
|
|
} {
|
|
const download = vi.fn(async () => ({ dir: '/tmp/x' }))
|
|
const resolveRef = vi.fn(async () => SHA as CommitSha)
|
|
const createTempDir = vi.fn(async () => '/tmp/dsh-plugin-github-abc')
|
|
return { deps: { download, resolveRef, createTempDir, ...overrides }, download, resolveRef, createTempDir }
|
|
}
|
|
|
|
describe('GigetFetcher.fetch', () => {
|
|
it('pins the ref to a SHA, downloads that SHA, and reports provenance', async () => {
|
|
const { deps, download, resolveRef, createTempDir } = fakeDeps()
|
|
const source: GithubSource = { kind: 'github', owner: 'unjs', repo: 'template', ref: 'main' }
|
|
const result = await new GigetFetcher(deps).fetch(source)
|
|
|
|
expect(resolveRef).toHaveBeenCalledWith(source)
|
|
expect(createTempDir).toHaveBeenCalledWith(GITHUB_TEMP_PREFIX)
|
|
expect(download).toHaveBeenCalledWith(`unjs/template#${SHA}`, { dir: '/tmp/dsh-plugin-github-abc', force: 'clean' })
|
|
expect(result).toEqual({
|
|
dir: '/tmp/dsh-plugin-github-abc',
|
|
source,
|
|
provenance: { kind: 'github', sha: SHA },
|
|
})
|
|
})
|
|
|
|
it('includes the subdir in the download input', async () => {
|
|
const { deps, download } = fakeDeps()
|
|
const source: GithubSource = { kind: 'github', owner: 'o', repo: 'r', subdir: 'packages/plugin' }
|
|
await new GigetFetcher(deps).fetch(source)
|
|
expect(download).toHaveBeenCalledWith(`o/r/packages/plugin#${SHA}`, expect.anything())
|
|
})
|
|
|
|
it('exposes its source kind', () => {
|
|
expect(new GigetFetcher(fakeDeps().deps).kind).toBe('github')
|
|
})
|
|
})
|
|
|
|
describe('defaultResolveRef', () => {
|
|
afterEach(() => vi.unstubAllGlobals())
|
|
|
|
it('resolves the default branch (HEAD) with no auth header', async () => {
|
|
const fetchMock = fetchReturning(new Response(`${SHA}\n`, { status: 200 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' })
|
|
expect(sha).toBe(SHA)
|
|
const [url, init] = fetchMock.mock.calls[0]!
|
|
expect(url).toBe('https://api.github.com/repos/o/r/commits/HEAD')
|
|
expect((init as RequestInit).headers).toEqual({ Accept: 'application/vnd.github.sha' })
|
|
})
|
|
|
|
it('resolves an explicit ref and sends a bearer token', async () => {
|
|
const fetchMock = fetchReturning(new Response(SHA, { status: 200 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'v1.2.3' }, 'secret')
|
|
expect(sha).toBe(SHA)
|
|
const [url, init] = fetchMock.mock.calls[0]!
|
|
expect(url).toBe('https://api.github.com/repos/o/r/commits/v1.2.3')
|
|
expect((init as RequestInit).headers).toEqual({
|
|
Accept: 'application/vnd.github.sha',
|
|
Authorization: 'Bearer secret',
|
|
})
|
|
})
|
|
|
|
it('throws with the HEAD label when the API rejects an unref-ed source', async () => {
|
|
vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 404 })))
|
|
await expect(defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' })).rejects.toThrow(
|
|
/cannot resolve github ref o\/r#HEAD: HTTP 404/,
|
|
)
|
|
})
|
|
|
|
it('throws with the explicit-ref label when the API rejects', async () => {
|
|
vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 403 })))
|
|
await expect(
|
|
defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' }),
|
|
).rejects.toThrow(/cannot resolve github ref o\/r#main: HTTP 403/)
|
|
})
|
|
})
|
|
|
|
describe('createGigetFetcher', () => {
|
|
const downloadMock = vi.mocked(downloadTemplate)
|
|
let savedToken: string | undefined
|
|
|
|
beforeEach(() => {
|
|
downloadMock.mockClear()
|
|
savedToken = process.env.GITHUB_TOKEN
|
|
delete process.env.GITHUB_TOKEN
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
if (savedToken === undefined) delete process.env.GITHUB_TOKEN
|
|
else process.env.GITHUB_TOKEN = savedToken
|
|
})
|
|
|
|
it('wires the real download without provider auth when no token is present', async () => {
|
|
vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 })))
|
|
await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' })
|
|
const [input, options] = downloadMock.mock.calls[0]!
|
|
expect(input).toBe(`o/r#${SHA}`)
|
|
expect(options?.dir).toContain(GITHUB_TEMP_PREFIX)
|
|
expect(options?.force).toBe('clean')
|
|
expect(options?.providerOptions).toBeUndefined()
|
|
})
|
|
|
|
it('passes an explicit token to both ref resolution and provider auth', async () => {
|
|
const fetchMock = fetchReturning(new Response(SHA, { status: 200 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
await createGigetFetcher({ token: 'tok' }).fetch({ kind: 'github', owner: 'o', repo: 'r' })
|
|
expect((fetchMock.mock.calls[0]![1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer tok' })
|
|
const [, options] = downloadMock.mock.calls[0]!
|
|
expect(options).toMatchObject({ providerOptions: { auth: 'tok' } })
|
|
})
|
|
|
|
it('reads GITHUB_TOKEN from the environment', async () => {
|
|
process.env.GITHUB_TOKEN = 'from-env'
|
|
vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 })))
|
|
await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r' })
|
|
const [, options] = downloadMock.mock.calls[0]!
|
|
expect(options).toMatchObject({ providerOptions: { auth: 'from-env' } })
|
|
})
|
|
})
|