mirror of
https://github.com/pnpm/pnpm.git
synced 2026-03-30 04:52:04 -04:00
feat: add sub folder support for git url (#7487)
close #4765 --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
17
.changeset/tender-dolphins-explode.md
Normal file
17
.changeset/tender-dolphins-explode.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
"@pnpm/resolver-base": minor
|
||||
"@pnpm/git-resolver": minor
|
||||
"@pnpm/prepare-package": minor
|
||||
"@pnpm/git-fetcher": minor
|
||||
"@pnpm/tarball-fetcher": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
It is now possible to install only a subdirectory from a Git repository.
|
||||
|
||||
For example, `pnpm add github:user/repo#path:packages/foo` will add a dependency from the `packages/foo` subdirectory.
|
||||
|
||||
This new parameter may be combined with other supported parameters separated by `&`. For instance, the next command will install the same package from the `dev` branch: `pnpm add github:user/repo#dev&path:packages/bar`.
|
||||
|
||||
Related issue: [#4765](https://github.com/pnpm/pnpm/issues/4765).
|
||||
Related PR: [#7487](https://github.com/pnpm/pnpm/pull/7487).
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/pnpm/pnpm/blob/main/exec/prepare-package#readme",
|
||||
"dependencies": {
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/lifecycle": "workspace:*",
|
||||
"@pnpm/read-package-json": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { runLifecycleHook, type RunLifecycleHookOptions } from '@pnpm/lifecycle'
|
||||
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
|
||||
import { type PackageManifest } from '@pnpm/types'
|
||||
@@ -22,11 +23,12 @@ export interface PreparePackageOptions {
|
||||
unsafePerm?: boolean
|
||||
}
|
||||
|
||||
export async function preparePackage (opts: PreparePackageOptions, pkgDir: string): Promise<boolean> {
|
||||
export async function preparePackage (opts: PreparePackageOptions, gitRootDir: string, subDir: string): Promise<{ shouldBeBuilt: boolean, pkgDir: string }> {
|
||||
const pkgDir = safeJoinPath(gitRootDir, subDir)
|
||||
const manifest = await safeReadPackageJsonFromDir(pkgDir)
|
||||
if (manifest?.scripts == null || !packageShouldBeBuilt(manifest, pkgDir)) return false
|
||||
if (opts.ignoreScripts) return true
|
||||
const pm = (await preferredPM(pkgDir))?.name ?? 'npm'
|
||||
if (manifest?.scripts == null || !packageShouldBeBuilt(manifest, pkgDir)) return { shouldBeBuilt: false, pkgDir }
|
||||
if (opts.ignoreScripts) return { shouldBeBuilt: true, pkgDir }
|
||||
const pm = (await preferredPM(gitRootDir))?.name ?? 'npm'
|
||||
const execOpts: RunLifecycleHookOptions = {
|
||||
depPath: `${manifest.name}@${manifest.version}`,
|
||||
pkgRoot: pkgDir,
|
||||
@@ -50,7 +52,7 @@ export async function preparePackage (opts: PreparePackageOptions, pkgDir: strin
|
||||
throw err
|
||||
}
|
||||
await rimraf(path.join(pkgDir, 'node_modules'))
|
||||
return true
|
||||
return { shouldBeBuilt: true, pkgDir }
|
||||
}
|
||||
|
||||
function packageShouldBeBuilt (manifest: PackageManifest, pkgDir: string): boolean {
|
||||
@@ -62,3 +64,16 @@ function packageShouldBeBuilt (manifest: PackageManifest, pkgDir: string): boole
|
||||
const mainFile = manifest.main ?? 'index.js'
|
||||
return !fs.existsSync(path.join(pkgDir, mainFile))
|
||||
}
|
||||
|
||||
function safeJoinPath (root: string, sub: string) {
|
||||
const joined = path.join(root, sub)
|
||||
// prevent the dir traversal attack
|
||||
const relative = path.relative(root, joined)
|
||||
if (relative.startsWith('..')) {
|
||||
throw new PnpmError('INVALID_PATH', `Path "${sub}" should be a sub directory`)
|
||||
}
|
||||
if (!fs.existsSync(joined) || !fs.lstatSync(joined).isDirectory()) {
|
||||
throw new PnpmError('INVALID_PATH', `Path "${sub}" is not a directory`)
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "has-prepublish-script-in-workspace",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "foo",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"prepublish": "node -e \"console.log('prepublish')\" | test-ipc-server-client ../../test.sock"
|
||||
}
|
||||
}
|
||||
0
exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/pnpm-lock.yaml
generated
Normal file
0
exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
@@ -10,7 +10,7 @@ test('prepare package runs the prepublish script', async () => {
|
||||
const tmp = tempDir()
|
||||
await using server = await createTestIpcServer(path.join(tmp, 'test.sock'))
|
||||
f.copy('has-prepublish-script', tmp)
|
||||
await preparePackage({ rawConfig: {} }, tmp)
|
||||
await preparePackage({ rawConfig: {} }, tmp, '')
|
||||
expect(server.getLines()).toStrictEqual([
|
||||
'prepublish',
|
||||
])
|
||||
@@ -20,7 +20,17 @@ test('prepare package does not run the prepublish script if the main file is pre
|
||||
const tmp = tempDir()
|
||||
await using server = await createTestIpcServer(path.join(tmp, 'test.sock'))
|
||||
f.copy('has-prepublish-script-and-main-file', tmp)
|
||||
await preparePackage({ rawConfig: {} }, tmp)
|
||||
await preparePackage({ rawConfig: {} }, tmp, '')
|
||||
expect(server.getLines()).toStrictEqual([
|
||||
'prepublish',
|
||||
])
|
||||
})
|
||||
|
||||
test('prepare package runs the prepublish script in the sub folder if pkgDir is present', async () => {
|
||||
const tmp = tempDir()
|
||||
await using server = await createTestIpcServer(path.join(tmp, 'test.sock'))
|
||||
f.copy('has-prepublish-script-in-workspace', tmp)
|
||||
await preparePackage({ rawConfig: {} }, tmp, 'packages/foo')
|
||||
expect(server.getLines()).toStrictEqual([
|
||||
'prepublish',
|
||||
])
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
{
|
||||
"path": "../../__utils__/test-ipc-server"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/error"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/types"
|
||||
},
|
||||
|
||||
@@ -33,9 +33,11 @@ export function createGitFetcher (createOpts: CreateGitFetcherOptions) {
|
||||
await execGit(['clone', resolution.repo, tempLocation])
|
||||
}
|
||||
await execGit(['checkout', resolution.commit], { cwd: tempLocation })
|
||||
let pkgDir: string
|
||||
try {
|
||||
const shouldBeBuilt = await preparePkg(tempLocation)
|
||||
if (ignoreScripts && shouldBeBuilt) {
|
||||
const prepareResult = await preparePkg(tempLocation, resolution.path ?? '')
|
||||
pkgDir = prepareResult.pkgDir
|
||||
if (ignoreScripts && prepareResult.shouldBeBuilt) {
|
||||
globalWarn(`The git-hosted package fetched from "${resolution.repo}" has to be built but the build scripts were ignored.`)
|
||||
}
|
||||
} catch (err: any) { // eslint-disable-line
|
||||
@@ -49,7 +51,7 @@ export function createGitFetcher (createOpts: CreateGitFetcherOptions) {
|
||||
// the linking of files to the store is in progress.
|
||||
return addFilesFromDir({
|
||||
cafsDir: cafs.cafsDir,
|
||||
dir: tempLocation,
|
||||
dir: pkgDir,
|
||||
filesIndexFile: opts.filesIndexFile,
|
||||
readManifest: opts.readManifest,
|
||||
pkg: opts.pkg,
|
||||
|
||||
@@ -47,6 +47,66 @@ test('fetch', async () => {
|
||||
expect(manifest?.name).toEqual('is-positive')
|
||||
})
|
||||
|
||||
test('fetch a package from Git sub folder', async () => {
|
||||
const cafsDir = tempy.directory()
|
||||
const fetch = createGitFetcher({ rawConfig: {} }).git
|
||||
const { filesIndex } = await fetch(
|
||||
createCafsStore(cafsDir),
|
||||
{
|
||||
commit: '2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22',
|
||||
repo: 'https://github.com/RexSkz/test-git-subfolder-fetch.git',
|
||||
path: '/packages/simple-react-app',
|
||||
type: 'git',
|
||||
},
|
||||
{
|
||||
filesIndexFile: path.join(cafsDir, 'index.json'),
|
||||
}
|
||||
)
|
||||
expect(filesIndex[`public${path.sep}index.html`]).toBeTruthy()
|
||||
})
|
||||
|
||||
test('prevent directory traversal attack when using Git sub folder', async () => {
|
||||
const cafsDir = tempy.directory()
|
||||
const fetch = createGitFetcher({ rawConfig: {} }).git
|
||||
const repo = 'https://github.com/RexSkz/test-git-subfolder-fetch.git'
|
||||
const pkgDir = '../../etc'
|
||||
await expect(
|
||||
fetch(
|
||||
createCafsStore(cafsDir),
|
||||
{
|
||||
commit: '2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22',
|
||||
repo,
|
||||
path: pkgDir,
|
||||
type: 'git',
|
||||
},
|
||||
{
|
||||
filesIndexFile: path.join(cafsDir, 'index.json'),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${repo}": Path "${pkgDir}" should be a sub directory`)
|
||||
})
|
||||
|
||||
test('prevent directory traversal attack when using Git sub folder', async () => {
|
||||
const cafsDir = tempy.directory()
|
||||
const fetch = createGitFetcher({ rawConfig: {} }).git
|
||||
const repo = 'https://github.com/RexSkz/test-git-subfolder-fetch.git'
|
||||
const pkgDir = 'not/exists'
|
||||
await expect(
|
||||
fetch(
|
||||
createCafsStore(cafsDir),
|
||||
{
|
||||
commit: '2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22',
|
||||
repo,
|
||||
path: pkgDir,
|
||||
type: 'git',
|
||||
},
|
||||
{
|
||||
filesIndexFile: path.join(cafsDir, 'index.json'),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${repo}": Path "${pkgDir}" is not a directory`)
|
||||
})
|
||||
|
||||
test('fetch a package from Git that has a prepare script', async () => {
|
||||
const cafsDir = tempy.directory()
|
||||
const fetch = createGitFetcher({ rawConfig: {} }).git
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type FetchFunction, type FetchOptions } from '@pnpm/fetcher-base'
|
||||
import type { Cafs } from '@pnpm/cafs-types'
|
||||
import { globalWarn } from '@pnpm/logger'
|
||||
import { preparePackage } from '@pnpm/prepare-package'
|
||||
import { type DependencyManifest } from '@pnpm/types'
|
||||
import { addFilesFromDir } from '@pnpm/worker'
|
||||
import renameOverwrite from 'rename-overwrite'
|
||||
import { fastPathTemp as pathTemp } from 'path-temp'
|
||||
@@ -11,6 +12,7 @@ interface Resolution {
|
||||
integrity?: string
|
||||
registry?: string
|
||||
tarball: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface CreateGitHostedTarballFetcher {
|
||||
@@ -21,19 +23,20 @@ export interface CreateGitHostedTarballFetcher {
|
||||
|
||||
export function createGitHostedTarballFetcher (fetchRemoteTarball: FetchFunction, fetcherOpts: CreateGitHostedTarballFetcher): FetchFunction {
|
||||
const fetch = async (cafs: Cafs, resolution: Resolution, opts: FetchOptions) => {
|
||||
// This solution is not perfect but inside the fetcher we don't currently know the location
|
||||
// of the built and non-built index files.
|
||||
const nonBuiltIndexFile = fetcherOpts.ignoreScripts ? opts.filesIndexFile : pathTemp(opts.filesIndexFile)
|
||||
const tempIndexFile = pathTemp(opts.filesIndexFile)
|
||||
const { filesIndex, manifest } = await fetchRemoteTarball(cafs, resolution, {
|
||||
...opts,
|
||||
filesIndexFile: nonBuiltIndexFile,
|
||||
filesIndexFile: tempIndexFile,
|
||||
})
|
||||
try {
|
||||
const prepareResult = await prepareGitHostedPkg(filesIndex as Record<string, string>, cafs, nonBuiltIndexFile, opts.filesIndexFile, fetcherOpts, opts)
|
||||
const prepareResult = await prepareGitHostedPkg(filesIndex as Record<string, string>, cafs, tempIndexFile, opts.filesIndexFile, fetcherOpts, opts, resolution)
|
||||
if (prepareResult.ignoredBuild) {
|
||||
globalWarn(`The git-hosted package fetched from "${resolution.tarball}" has to be built but the build scripts were ignored.`)
|
||||
}
|
||||
return { filesIndex: prepareResult.filesIndex, manifest }
|
||||
return {
|
||||
filesIndex: prepareResult.filesIndex,
|
||||
manifest: prepareResult.manifest ?? manifest,
|
||||
}
|
||||
} catch (err: any) { // eslint-disable-line
|
||||
err.message = `Failed to prepare git-hosted package fetched from "${resolution.tarball}": ${err.message}`
|
||||
throw err
|
||||
@@ -43,14 +46,21 @@ export function createGitHostedTarballFetcher (fetchRemoteTarball: FetchFunction
|
||||
return fetch as FetchFunction
|
||||
}
|
||||
|
||||
interface PrepareGitHostedPkgResult {
|
||||
filesIndex: Record<string, string>
|
||||
manifest?: DependencyManifest
|
||||
ignoredBuild: boolean
|
||||
}
|
||||
|
||||
async function prepareGitHostedPkg (
|
||||
filesIndex: Record<string, string>,
|
||||
cafs: Cafs,
|
||||
filesIndexFileNonBuilt: string,
|
||||
filesIndexFile: string,
|
||||
opts: CreateGitHostedTarballFetcher,
|
||||
fetcherOpts: FetchOptions
|
||||
) {
|
||||
fetcherOpts: FetchOptions,
|
||||
resolution: Resolution
|
||||
): Promise<PrepareGitHostedPkgResult> {
|
||||
const tempLocation = await cafs.tempDir()
|
||||
cafs.importPackage(tempLocation, {
|
||||
filesResponse: {
|
||||
@@ -59,20 +69,22 @@ async function prepareGitHostedPkg (
|
||||
},
|
||||
force: true,
|
||||
})
|
||||
const shouldBeBuilt = await preparePackage(opts, tempLocation)
|
||||
if (!shouldBeBuilt) {
|
||||
if (filesIndexFileNonBuilt !== filesIndexFile) {
|
||||
await renameOverwrite(filesIndexFileNonBuilt, filesIndexFile)
|
||||
const { shouldBeBuilt, pkgDir } = await preparePackage(opts, tempLocation, resolution.path ?? '')
|
||||
if (!resolution.path) {
|
||||
if (!shouldBeBuilt) {
|
||||
if (filesIndexFileNonBuilt !== filesIndexFile) {
|
||||
await renameOverwrite(filesIndexFileNonBuilt, filesIndexFile)
|
||||
}
|
||||
return {
|
||||
filesIndex,
|
||||
ignoredBuild: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
filesIndex,
|
||||
ignoredBuild: false,
|
||||
}
|
||||
}
|
||||
if (opts.ignoreScripts) {
|
||||
return {
|
||||
filesIndex,
|
||||
ignoredBuild: true,
|
||||
if (opts.ignoreScripts) {
|
||||
return {
|
||||
filesIndex,
|
||||
ignoredBuild: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -85,9 +97,10 @@ async function prepareGitHostedPkg (
|
||||
return {
|
||||
...await addFilesFromDir({
|
||||
cafsDir: cafs.cafsDir,
|
||||
dir: tempLocation,
|
||||
dir: pkgDir,
|
||||
filesIndexFile,
|
||||
pkg: fetcherOpts.pkg,
|
||||
readManifest: fetcherOpts.readManifest,
|
||||
}),
|
||||
ignoredBuild: false,
|
||||
}
|
||||
|
||||
@@ -513,3 +513,78 @@ test('when extracting files with the same name, pick the last ones', async () =>
|
||||
expect(pkgJson.name).toBe('pkg2')
|
||||
expect(manifest?.name).toBe('pkg2')
|
||||
})
|
||||
|
||||
test('use the subfolder when path is present', async () => {
|
||||
process.chdir(tempy.directory())
|
||||
|
||||
const resolution = {
|
||||
tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22',
|
||||
path: '/packages/simple-react-app',
|
||||
}
|
||||
|
||||
const fetch = createTarballFetcher(fetchFromRegistry, getAuthHeader, {
|
||||
ignoreScripts: true,
|
||||
rawConfig: {},
|
||||
retry: {
|
||||
maxTimeout: 100,
|
||||
minTimeout: 0,
|
||||
retries: 1,
|
||||
},
|
||||
})
|
||||
const { filesIndex } = await fetch.gitHostedTarball(cafs, resolution, {
|
||||
filesIndexFile,
|
||||
lockfileDir: process.cwd(),
|
||||
pkg: {},
|
||||
})
|
||||
|
||||
expect(filesIndex).toHaveProperty(['package.json'])
|
||||
expect(filesIndex).not.toHaveProperty(['lerna.json'])
|
||||
})
|
||||
|
||||
test('prevent directory traversal attack when path is present', async () => {
|
||||
process.chdir(tempy.directory())
|
||||
|
||||
const tarball = 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22'
|
||||
const path = '../../etc'
|
||||
const resolution = { tarball, path }
|
||||
|
||||
const fetch = createTarballFetcher(fetchFromRegistry, getAuthHeader, {
|
||||
ignoreScripts: true,
|
||||
rawConfig: {},
|
||||
retry: {
|
||||
maxTimeout: 100,
|
||||
minTimeout: 0,
|
||||
retries: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(() => fetch.gitHostedTarball(cafs, resolution, {
|
||||
filesIndexFile,
|
||||
lockfileDir: process.cwd(),
|
||||
pkg: {},
|
||||
})).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${tarball}": Path "${path}" should be a sub directory`)
|
||||
})
|
||||
|
||||
test('fail when path is not exists', async () => {
|
||||
process.chdir(tempy.directory())
|
||||
|
||||
const tarball = 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22'
|
||||
const path = '/not-exists'
|
||||
const resolution = { tarball, path }
|
||||
|
||||
const fetch = createTarballFetcher(fetchFromRegistry, getAuthHeader, {
|
||||
ignoreScripts: true,
|
||||
rawConfig: {},
|
||||
retry: {
|
||||
maxTimeout: 100,
|
||||
minTimeout: 0,
|
||||
retries: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(() => fetch.gitHostedTarball(cafs, resolution, {
|
||||
filesIndexFile,
|
||||
lockfileDir: process.cwd(),
|
||||
pkg: {},
|
||||
})).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${tarball}": Path "${path}" is not a directory`)
|
||||
})
|
||||
|
||||
@@ -326,3 +326,20 @@ test('git-hosted repository is not added to the store if it fails to be built',
|
||||
addDependenciesToPackage({}, ['pnpm-e2e/prepare-script-fails'], await testDefaults())
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('from subdirectories of a git repo', async () => {
|
||||
const project = prepareEmpty()
|
||||
|
||||
const manifest = await addDependenciesToPackage({}, [
|
||||
'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-react-app',
|
||||
'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-express-server',
|
||||
], await testDefaults())
|
||||
|
||||
await project.has('@my-namespace/simple-react-app')
|
||||
await project.has('@my-namespace/simple-express-server')
|
||||
|
||||
expect(manifest.dependencies).toStrictEqual({
|
||||
'@my-namespace/simple-express-server': 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-express-server',
|
||||
'@my-namespace/simple-react-app': 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-react-app',
|
||||
})
|
||||
})
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1416,6 +1416,9 @@ importers:
|
||||
|
||||
exec/prepare-package:
|
||||
dependencies:
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/lifecycle':
|
||||
specifier: workspace:*
|
||||
version: link:../lifecycle
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ResolveResult } from '@pnpm/resolver-base'
|
||||
import { type TarballResolution, type GitResolution, type ResolveResult } from '@pnpm/resolver-base'
|
||||
import git from 'graceful-git'
|
||||
import semver from 'semver'
|
||||
import { parsePref, type HostedPackageSpec } from './parsePref'
|
||||
@@ -29,7 +29,7 @@ export function createGitResolver (
|
||||
const tarball = hosted.tarball?.()
|
||||
|
||||
if (tarball) {
|
||||
resolution = { tarball }
|
||||
resolution = { tarball } as TarballResolution
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,18 @@ export function createGitResolver (
|
||||
commit,
|
||||
repo: parsedSpec.fetchSpec,
|
||||
type: 'git',
|
||||
} as ({ type: string } & object)
|
||||
} as GitResolution
|
||||
}
|
||||
|
||||
if (parsedSpec.path) {
|
||||
resolution.path = parsedSpec.path
|
||||
}
|
||||
|
||||
return {
|
||||
id: parsedSpec.fetchSpec
|
||||
.replace(/^.*:\/\/(git@)?/, '')
|
||||
.replace(/:/g, '+')
|
||||
.replace(/\.git$/, '') + '/' + commit,
|
||||
.replace(/\.git$/, '') + '/' + commit + (resolution.path ? `#path:${resolution.path}` : ''),
|
||||
normalizedPref: parsedSpec.normalizedPref,
|
||||
resolution,
|
||||
resolvedVia: 'git-repository',
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface HostedPackageSpec {
|
||||
normalizedPref: string
|
||||
gitCommittish: string | null
|
||||
gitRange?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
const gitProtocols = new Set([
|
||||
@@ -43,11 +44,11 @@ export async function parsePref (pref: string): Promise<HostedPackageSpec | null
|
||||
const url = new URL(correctPref)
|
||||
if (!url?.protocol) return null
|
||||
|
||||
const committish = (url.hash?.length > 1) ? decodeURIComponent(url.hash.slice(1)) : null
|
||||
const hash = (url.hash?.length > 1) ? decodeURIComponent(url.hash.slice(1)) : null
|
||||
return {
|
||||
fetchSpec: urlToFetchSpec(url),
|
||||
normalizedPref: pref,
|
||||
...setGitCommittish(committish),
|
||||
...parseGitParams(hash),
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -87,7 +88,7 @@ async function fromHostedGit (hosted: any): Promise<HostedPackageSpec> { // esli
|
||||
tarball: undefined,
|
||||
},
|
||||
normalizedPref: `git+${httpsUrl}`,
|
||||
...setGitCommittish(hosted.committish),
|
||||
...parseGitParams(hosted.committish),
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
@@ -121,7 +122,7 @@ async function fromHostedGit (hosted: any): Promise<HostedPackageSpec> { // esli
|
||||
tarball: hosted.tarball,
|
||||
},
|
||||
normalizedPref: hosted.shortcut(),
|
||||
...setGitCommittish(hosted.committish),
|
||||
...parseGitParams(hosted.committish),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,14 +144,25 @@ async function accessRepository (repository: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function setGitCommittish (committish: string | null) {
|
||||
if (committish !== null && committish.length >= 7 && committish.slice(0, 7) === 'semver:') {
|
||||
return {
|
||||
gitCommittish: null,
|
||||
gitRange: committish.slice(7),
|
||||
type GitParsedParams = Pick<HostedPackageSpec, 'gitCommittish' | 'gitRange' | 'path'>
|
||||
|
||||
function parseGitParams (committish: string | null): GitParsedParams {
|
||||
const result: GitParsedParams = { gitCommittish: null }
|
||||
if (!committish) {
|
||||
return result
|
||||
}
|
||||
|
||||
const params = committish.split('&')
|
||||
for (const param of params) {
|
||||
if (param.length >= 7 && param.slice(0, 7) === 'semver:') {
|
||||
result.gitRange = param.slice(7)
|
||||
} else if (param.slice(0, 5) === 'path:') {
|
||||
result.path = param.slice(5)
|
||||
} else {
|
||||
result.gitCommittish = param
|
||||
}
|
||||
}
|
||||
return { gitCommittish: committish }
|
||||
return result
|
||||
}
|
||||
|
||||
// handle SCP-like URLs
|
||||
|
||||
@@ -165,6 +165,32 @@ test.skip('resolveFromGit() with range semver (v-prefixed tag)', async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveFromGit() with sub folder', async () => {
|
||||
const resolveResult = await resolveFromGit({ pref: 'github:RexSkz/test-git-subfolder-fetch.git#path:/packages/simple-react-app' })
|
||||
expect(resolveResult).toStrictEqual({
|
||||
id: 'github.com/RexSkz/test-git-subfolder-fetch/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22#path:/packages/simple-react-app',
|
||||
normalizedPref: 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-react-app',
|
||||
resolution: {
|
||||
tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22',
|
||||
path: '/packages/simple-react-app',
|
||||
},
|
||||
resolvedVia: 'git-repository',
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveFromGit() with both sub folder and branch', async () => {
|
||||
const resolveResult = await resolveFromGit({ pref: 'github:RexSkz/test-git-subfolder-fetch.git#beta&path:/packages/simple-react-app' })
|
||||
expect(resolveResult).toStrictEqual({
|
||||
id: 'github.com/RexSkz/test-git-subfolder-fetch/777e8a3e78cc89bbf41fb3fd9f6cf922d5463313#path:/packages/simple-react-app',
|
||||
normalizedPref: 'github:RexSkz/test-git-subfolder-fetch#beta&path:/packages/simple-react-app',
|
||||
resolution: {
|
||||
tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/777e8a3e78cc89bbf41fb3fd9f6cf922d5463313',
|
||||
path: '/packages/simple-react-app',
|
||||
},
|
||||
resolvedVia: 'git-repository',
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveFromGit() fails when ref not found', async () => {
|
||||
await expect(
|
||||
resolveFromGit({ pref: 'zkochan/is-negative#bad-ref' })
|
||||
|
||||
@@ -5,10 +5,42 @@ test.each([
|
||||
['ssh://username:password@example.com:repo/@foo.git', 'ssh://username:password@example.com/repo/@foo.git'],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git', 'ssh://username:password@example.com:22/repo/@foo.git'],
|
||||
['ssh://username:password@example.com:22repo/@foo.git', 'ssh://username:password@example.com/22repo/@foo.git'],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', 'ssh://username:password@example.com:22/repo/@foo.git'],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', 'ssh://username:password@example.com:22/repo/@foo.git'],
|
||||
['git+ssh://username:password@example.com:repo.git', 'ssh://username:password@example.com/repo.git'],
|
||||
['git+ssh://username:password@example.com:repo/@foo.git', 'ssh://username:password@example.com/repo/@foo.git'],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git', 'ssh://username:password@example.com:22/repo/@foo.git'],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', 'ssh://username:password@example.com:22/repo/@foo.git'],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', 'ssh://username:password@example.com:22/repo/@foo.git'],
|
||||
])('the right colon is escaped in %s', async (input, output) => {
|
||||
const parsed = await parsePref(input)
|
||||
expect(parsed?.fetchSpec).toBe(output)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['ssh://username:password@example.com:repo.git#path:/a/@b', '/a/@b'],
|
||||
['ssh://username:password@example.com:repo/@foo.git#path:/a/@b', '/a/@b'],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'],
|
||||
['ssh://username:password@example.com:22repo/@foo.git#path:/a/@b', '/a/@b'],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', '/a/@b'],
|
||||
['git+ssh://username:password@example.com:repo.git#path:/a/@b', '/a/@b'],
|
||||
['git+ssh://username:password@example.com:repo/@foo.git#path:/a/@b', '/a/@b'],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', '/a/@b'],
|
||||
['ssh://username:password@example.com:repo.git', undefined],
|
||||
['ssh://username:password@example.com:repo/@foo.git', undefined],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git', undefined],
|
||||
['ssh://username:password@example.com:22repo/@foo.git', undefined],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git', undefined],
|
||||
['ssh://username:password@example.com:22/repo/@foo.git#dev', undefined],
|
||||
['git+ssh://username:password@example.com:repo.git', undefined],
|
||||
['git+ssh://username:password@example.com:repo/@foo.git', undefined],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git', undefined],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git', undefined],
|
||||
['git+ssh://username:password@example.com:22/repo/@foo.git#dev', undefined],
|
||||
])('the path of %s should be %s', async (input, output) => {
|
||||
const parsed = await parsePref(input)
|
||||
expect(parsed?.path).toBe(output)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface TarballResolution {
|
||||
type?: undefined
|
||||
tarball: string
|
||||
integrity?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export interface DirectoryResolution {
|
||||
export interface GitResolution {
|
||||
commit: string
|
||||
repo: string
|
||||
path?: string
|
||||
type: 'git'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user