Skip to content

Commit

Permalink
fix(download): custom repository download implementation
Browse files Browse the repository at this point in the history
Avoids old strip-dirs dependency. release-npm
  • Loading branch information
tobua committed May 5, 2024
1 parent 1aceb2c commit 79a148f
Show file tree
Hide file tree
Showing 21 changed files with 364 additions and 92 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun lint
- run: bun check
- run: bun types
- run: bun test
- name: 📢 Release
Expand Down
18 changes: 9 additions & 9 deletions cli.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#!/usr/bin/env bun
import { collectVariables } from './collect-variables'
import { cachePath } from './config'
import { collectVariables } from './utility/collect-variables'
import { downloadTemplate } from './utility/download-template'
import { getConfig } from './utility/get-config'
import { cleanup, getDestinationPath, validatePackageName } from './utility/helper'
import { installDependencies } from './utility/install-dependencies'
import { loadPackage } from './utility/load-package'
import { log } from './utility/log'
import { getTemplateDirectory } from './utility/template-directory'
import { writeFiles } from './utility/write-files'
import { downloadTemplate } from './download-template'
import { getConfig } from './get-config'
import { cleanup, getDestinationPath, validatePackageName } from './helper'
import { installDependencies } from './install-dependencies'
import { loadPackage } from './load-package'
import { log } from './log'
import { getTemplateDirectory } from './template-directory'
import { writeFiles } from './write-files'

// Remove additional parameter when used with flag like --yes in older node versions.
if (process.argv[2] === 'now') {
Expand Down
2 changes: 1 addition & 1 deletion utility/collect-variables.ts → collect-variables.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Config } from '../types'
import { promptVariables } from './prompt'
import type { Config } from './types'

const removePropertyFromPrompts = (config: Config, property: string) => {
if (config.prompts && Array.isArray(config.prompts)) {
Expand Down
158 changes: 158 additions & 0 deletions decompress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { mkdirSync, readFileSync } from 'node:fs'
import { link, mkdir, readlink, realpath, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
// @ts-ignore
import decompressTar from 'decompress-tar'
// @ts-ignore
import decompressTarbz2 from 'decompress-tarbz2'
// @ts-ignore
import decompressTargz from 'decompress-targz'
// @ts-ignore
import decompressUnzip from 'decompress-unzip'
// @ts-ignore
import stripDirs from 'strip-dirs'

interface PathFile {
path: string
mode: number
type: string
mtime: Date
linkname: string
data: string
}

interface Options {
strip: number
filter?: () => boolean
map?: (value: PathFile) => PathFile
plugins: ((input: Buffer, options: Options) => string)[]
}

function runPlugins(input: Buffer, options: Options) {
if (options.plugins.length === 0) {
return Promise.resolve([])
}

return Promise.all(options.plugins.map((x) => x(input, options))).then((files) => files.reduce((a, b) => a.concat(b)))
}

function safeMakeDir(directory: string, realOutputPath: string): Promise<string> {
return realpath(directory)
.catch((_) => {
const parent = dirname(directory)
return safeMakeDir(parent, realOutputPath)
})
.then((realParentPath) => {
if (realParentPath.indexOf(realOutputPath) !== 0) {
throw new Error('Refusing to create a directory outside the output path.')
}

mkdirSync(directory, { recursive: true })
return directory
})
}

const preventWritingThroughSymlink = (destination: string, realOutputPath: string) => {
return readlink(destination)
.catch((_) => {
// Either no file exists, or it's not a symlink. In either case, this is
// not an escape we need to worry about in this phase.
return null
})
.then(() => {
// No symlink exists at `destination`, so we can continue
return realOutputPath
})
}

const extractFile = (input: Buffer, output: string, options: Options) =>
runPlugins(input, options).then((input) => {
let files: PathFile[] = input as PathFile[]
if (options.strip > 0) {
files = files
.map((x) => {
x.path = stripDirs(x.path, options.strip)
return x
})
.filter((x) => x.path !== '.')
}

if (typeof options.filter === 'function') {
files = files.filter(options.filter)
}

if (typeof options.map === 'function') {
files = files.map(options.map)
}

if (!output) {
return files
}

return Promise.all(
files.map((x) => {
const dest = join(output, x.path)
const mode = x.mode & ~process.umask()
const now = new Date()

if (x.type === 'directory') {
mkdir(output, { recursive: true })

return realpath(output)
.then((realOutputPath) => safeMakeDir(dest, realOutputPath))
.then(() => utimes(dest, now, x.mtime))
.then(() => x)
}

mkdir(output, { recursive: true })

return realpath(output)
.then((realOutputPath) => {
// Attempt to ensure parent directory exists (failing if it's outside the output dir)
return safeMakeDir(dirname(dest), realOutputPath).then(() => realOutputPath)
})
.then((realOutputPath) => {
if (x.type === 'file') {
return preventWritingThroughSymlink(dest, realOutputPath)
}

return realOutputPath
})
.then((realOutputPath) => {
return realpath(dirname(dest)).then((realDestinationDir) => {
if (realDestinationDir.indexOf(realOutputPath) !== 0) {
throw new Error(`Refusing to write outside output directory: ${realDestinationDir}`)
}
})
})
.then(() => {
if (x.type === 'link') {
return link(x.linkname, dest)
}

if (x.type === 'symlink' && process.platform === 'win32') {
return link(x.linkname, dest)
}

if (x.type === 'symlink') {
return symlink(x.linkname, dest)
}

return writeFile(dest, x.data, { mode })
})
.then(() => {
if (x.type === 'file') {
utimes(dest, now, x.mtime)
}
})
}),
)
})

export function decompress(inputZipFile: string, destinationDirectory: string) {
if (typeof inputZipFile !== 'string' && !Buffer.isBuffer(inputZipFile)) {
return Promise.reject(new TypeError('Input file required'))
}
const options: Options = { strip: 0, plugins: [decompressTar(), decompressTarbz2(), decompressTargz(), decompressUnzip()] }
return extractFile(readFileSync(inputZipFile), destinationDirectory, options)
}
106 changes: 106 additions & 0 deletions download-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import axios from 'axios'
import { decompress } from './decompress'
import { log } from './log'

async function downloadFile(url: string, extractDirectory: string) {
if (!existsSync(extractDirectory)) {
mkdirSync(extractDirectory, { recursive: true })
}

const zipFilePath = join(process.cwd(), `temp-${Math.floor(Math.random() * (999 - 1 + 1) + 1)}.zip`)
const body = await axios.get(url, {
responseType: 'arraybuffer',
})

writeFileSync(zipFilePath, Buffer.from(body.data))
await decompress(zipFilePath, extractDirectory)
unlinkSync(zipFilePath)

return false
}

// Custom implementation of the 'download-git-repo' package.
export async function download(url: string, destination: string) {
const respository = normalize(url)
const fullUrl = respository.url || getUrl(respository)

if (!url) {
log('Repository not found', 'error')
return
}

const error = await downloadFile(fullUrl, destination)

if (error) {
log(`Failed to download repository contents from ${fullUrl}`, 'error')
return
}

return true
}

const normalize = (url: string) => {
let regex = /^(?:(direct):([^#]+)(?:#(.+))?)$/
let match = regex.exec(url)

if (match) {
// TODO customizable branch.
const [_, , url, directCheckout = 'main'] = match
return {
type: 'direct',
url,
checkout: directCheckout,
}
}

regex = /^(?:(github|gitlab|bitbucket):)?(?:(.+):)?([^/]+)\/([^#]+)(?:#(.+))?$/
match = regex.exec(url)
const [_, type = 'github', origin, owner, name, checkout = 'main'] = match ?? []
return {
type,
origin: origin ?? (type === 'github' ? 'github.com' : type === 'gitlab' ? 'gitlab.com' : 'bitbucket.org'),
owner,
name,
checkout,
}
}

function addProtocol(origin: string) {
if (!/^(f|ht)tps?:\/\//i.test(origin)) {
return `https://${origin}`
}

return origin
}

function getUrl(repository: ReturnType<typeof normalize>) {
let url = ''

// Get origin with protocol and add trailing slash or colon (for ssh)
let origin = addProtocol(repository.origin as string)
if (/^git@/i.test(origin)) {
origin = `${origin}:`
} else {
origin = `${origin}/`
}

if (repository.type === 'github') {
url = `${origin + repository.owner}/${repository.name}/archive/${repository.checkout}.zip`
} else if (repository.type === 'gitlab') {
url = `${origin + repository.owner}/${repository.name}/repository/archive.zip?ref=${repository.checkout}`
} else if (repository.type === 'bitbucket') {
url = `${origin + repository.owner}/${repository.name}/get/${repository.checkout}.zip`
}

return url
}

export async function downloadTemplate(url: string, cachePath: string) {
const success = await download(url, cachePath)

if (!success) {
log(`Couldn't download repository from ${url}`, 'error')
}
}
2 changes: 1 addition & 1 deletion utility/get-config.ts → get-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Config } from '../types'
import { log } from './log'
import type { Config } from './types'

export const getConfig = (templateDirectory: string) => {
const configFilePath = join(templateDirectory, 'template.json')
Expand Down
File renamed without changes.
18 changes: 9 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { collectVariables } from './collect-variables'
import { cachePath } from './config'
import { collectVariables } from './utility/collect-variables'
import { downloadTemplate } from './utility/download-template'
import { getConfig } from './utility/get-config'
import { cleanup, getDestinationPath, validatePackageName } from './utility/helper'
import { installDependencies } from './utility/install-dependencies'
import { loadPackage } from './utility/load-package'
import { log } from './utility/log'
import { getTemplateDirectory } from './utility/template-directory'
import { writeFiles } from './utility/write-files'
import { downloadTemplate } from './download-template'
import { getConfig } from './get-config'
import { cleanup, getDestinationPath, validatePackageName } from './helper'
import { installDependencies } from './install-dependencies'
import { loadPackage } from './load-package'
import { log } from './log'
import { getTemplateDirectory } from './template-directory'
import { writeFiles } from './write-files'

export const create = async (packageName: string, destinationPath?: string, template = 'default', variableArguments?: object) => {
validatePackageName(packageName)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { execSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Config } from '../types'
import { log } from './log'
import type { Config } from './types'

export const installDependencies = (config: Config, destination: string) => {
if (config.noInstall) {
Expand Down
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit 79a148f

Please sign in to comment.