Skip to content

Commit

Permalink
feat: automate installing dependencies (#20)
Browse files Browse the repository at this point in the history
* feat: automate installing dependencies

* Detect current package manager

* chore: Variable name cleanups for dependency installation hook

* Added 'Get started with' message

* Use execa to test cli

* test: Add dependencies test

* test: Add bin script to allow triggering ./bin using a dedicated package manager

* test: Use a specific test-dir directory to store test project files

* chore: Add link to handleQuestions inspiration

* test: Before running a test, build the ./bin file

* ci: Fix package manager selection & programmatically run build

* test: Remove bin file after test to prevent clash with ci

* ci: Fix linting issues

* ci: Fix formatting issues

* chore: Add format & format:fix scripts to package.json

* ci: Fix package manager detection bug
  • Loading branch information
neutrino2211 authored Feb 23, 2024
1 parent ea5b246 commit acf3779
Show file tree
Hide file tree
Showing 9 changed files with 386 additions and 10 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ node_modules
bin
package-lock.json
yarn-error.log
pnpm-lock.yaml
pnpm-lock.yaml
test-dir
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
"version": "0.4.0",
"scripts": {
"build": "tsx ./build.ts",
"bin": "./bin",
"test": "vitest --run",
"prepack": "yarn build",
"release": "np",
"lint": "eslint --ext js,ts src",
"lint:fix": "eslint --ext js,ts src --fix"
"lint:fix": "eslint --ext js,ts src --fix",
"format": "prettier src --check",
"format:fix": "prettier src --write"
},
"bin": "./bin",
"files": [
Expand All @@ -31,9 +34,11 @@
"@types/yargs-parser": "^21.0.0",
"esbuild": "^0.16.17",
"eslint": "^8.55.0",
"execa": "^8.0.1",
"kleur": "^4.1.5",
"node-fetch": "^3.3.0",
"np": "^7.6.3",
"prettier": "^3.2.5",
"prompts": "^2.4.2",
"tiged": "^2.12.7",
"tsx": "^3.12.2",
Expand Down
2 changes: 1 addition & 1 deletion src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const viaContentsApi = async ({
}: Options) => {
const files = []
const contents = await api(
`${user}/${repository}/contents/${directory}?ref=${ref}`
`${user}/${repository}/contents/${directory}?ref=${ref}`,
)

if ('message' in contents) {
Expand Down
15 changes: 13 additions & 2 deletions src/hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class Hook<HookFunction extends (...args: any[]) => any> {
type HookSignature = (...args: any[]) => any
export class Hook<HookFunction extends HookSignature> {
#hookMap: Map<string, HookFunction[]>
constructor() {
this.#hookMap = new Map<string, HookFunction[]>()
Expand Down Expand Up @@ -39,5 +40,15 @@ type AfterHookOptions = {
}

type AfterHookFunction = (options: AfterHookOptions) => void

export const afterCreateHook = new Hook<AfterHookFunction>()

/**
* Dependencies Hook
*/

type DependenciesHookOptions = {
directoryPath: string
}

type DependenciesHookFunction = (options: DependenciesHookOptions) => void
export const projectDependenciesHook = new Hook<DependenciesHookFunction>()
2 changes: 1 addition & 1 deletion src/hooks/after-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ afterCreateHook.addHook(
.replaceAll(/[^a-z0-9\-_]/gm, '-')
const rewritten = wrangler.replaceAll(PROJECT_NAME, convertProjectName)
writeFileSync(wranglerPath, rewritten)
}
},
)

export { afterCreateHook }
201 changes: 201 additions & 0 deletions src/hooks/dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { Buffer } from 'buffer'

import { existsSync, rmSync } from 'fs'
import { cwd } from 'process'
import { execa, execaSync } from 'execa'
import type { ExecaChildProcess } from 'execa'
import { afterAll, describe, expect, it } from 'vitest'

let cmdBuffer = ''

const packageManagersCommands: { [key: string]: string[] } = {
npm: 'npm run bin'.split(' '),
bun: 'bun bin'.split(' '),
pnpm: 'pnpm run bin'.split(' '),
yarn: 'yarn run bin'.split(' '),
}

const packageManagersLockfiles: { [key: string]: string } = {
npm: 'package-lock.json',
bun: 'bun.lockb',
pnpm: 'pnpm-lock.yml',
yarn: 'yarn.lock',
}

const availablePackageManagers = Object.keys(packageManagersCommands).filter(
(p) => {
if (p === 'npm') return true // Skip check for npm because it's most likely here and for some wierd reason, it returns an exitCode of 1 from `npm -h`

let stderr = ''

try {
const { stderr: err } = execaSync(p, ['-h'])
stderr = err
} catch (error) {
stderr = error as string
}

return stderr.length == 0
},
)

// Run build to have ./bin
execaSync('yarn', 'run build'.split(' '))
execaSync('chmod', ['+x', './bin'])

describe('dependenciesHook', async () => {
afterAll(() => {
rmSync('test-dir', { recursive: true, force: true })
rmSync('bin') // Might be beneficial to remove the bin file
})

describe.each(availablePackageManagers.map((p) => ({ pm: p })))(
'$pm',
({ pm }) => {
const proc = execa(
packageManagersCommands[pm][0],
packageManagersCommands[pm].slice(1),
{
cwd: cwd(),
stdin: 'pipe',
stdout: 'pipe',
env: { ...process.env, npm_config_user_agent: pm },
},
)
const targetDirectory = 'test-dir/' + generateRandomAlphanumericString(8)

afterAll(() => {
rmSync(targetDirectory, { recursive: true, force: true })
})

it('should ask for a target directory', async () => {
const out = await handleQuestions(proc, [
{
question: 'Target directory',
answer: answerWithValue(targetDirectory),
},
])

expect(out)
})

it('should clone a template to the directory', async () => {
const out = await handleQuestions(proc, [
{
question: 'Which template do you want to use?',
answer: CONFIRM, // Should pick aws-lambda
},
])

expect(out, 'Selected aws-lambda')
})

it('should ask if you want to install dependencies', async () => {
const out = await handleQuestions(proc, [
{
question: 'Do you want to install project dependencies?',
answer: CONFIRM, // Should pick Y
},
])

expect(out, 'Installing dependencies')
})

it('should ask for which package manager to use', async () => {
const out = await handleQuestions(proc, [
{
question: 'Which package manager do you want to use?',
answer: CONFIRM, // Should pick current package manager
},
])

expect(
out.trim().includes(pm),
`Current package manager '${pm}' was picked`,
)
})

it('should have installed dependencies', async () => {
while (!existsSync(targetDirectory + '/node_modules'))
await timeout(3_000) // 3 seconds;

expect(
existsSync(targetDirectory + '/node_modules'),
'node_modules directory exists',
)
})

it(
'should have package manager specific lock file (' +
packageManagersLockfiles[pm] +
')',
async () => {
expect(
existsSync(targetDirectory + '/' + packageManagersLockfiles[pm]),
'lockfile exists',
)

cmdBuffer = ''
},
)
},
{ timeout: 60_000 },
)
})

const generateRandomAlphanumericString = (length: number): string => {
const alphabet =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let result = ''

for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * alphabet.length)
result += alphabet[randomIndex]
}

return result
}

const timeout = (milliseconds: number) =>
new Promise((res) => setTimeout(res, milliseconds))

/**
* Utility to mock the stdin of the cli. You must provide the correct number of
* questions correctly typed or the process will keep waiting for input.
* https://github.com/netlify/cli/blob/0c91f20e14e84e9b21d39d592baf10c7abd8f37c/tests/integration/utils/handle-questions.js#L11
*/
const handleQuestions = (
process: ExecaChildProcess<string>,
questions: { question: string; answer: string | string[] }[],
) =>
new Promise<string>((res) => {
process.stdout?.on('data', (data) => {
cmdBuffer = (cmdBuffer + data).replace(/\n/g, '')
const index = questions.findIndex(({ question }) =>
cmdBuffer.includes(question),
)

if (index >= 0) {
res(cmdBuffer)
const { answer } = questions[index]

writeResponse(process, Array.isArray(answer) ? answer : [answer])
}
})
})

const writeResponse = (
process: ExecaChildProcess<string>,
responses: string[],
) => {
const response = responses.shift()
if (!response) return

if (!response.endsWith(CONFIRM))
process.stdin?.write(Buffer.from(response + CONFIRM))
else process.stdin?.write(Buffer.from(response))
}

export const answerWithValue = (value = '') => [value, CONFIRM].flat()

export const CONFIRM = '\n'
76 changes: 76 additions & 0 deletions src/hooks/dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { exec } from 'child_process'
import { chdir, exit } from 'process'
import { bold, green, red } from 'kleur/colors'
import prompts from 'prompts'
import { projectDependenciesHook } from '../hook'

type PackageManager = 'npm' | 'bun' | 'pnpm' | 'yarn'

const knownPackageManagers: { [key: string]: string } = {
npm: 'npm install',
bun: 'bun install',
pnpm: 'pnpm install',
yarn: 'yarn',
}

const knownPackageManagerNames = Object.keys(knownPackageManagers)
const currentPackageManager = getCurrentPackageManager()

const registerInstallationHook = (template: string) => {
if (template == 'deno') return // Deno needs no dependency installation step

projectDependenciesHook.addHook(template, async ({ directoryPath }) => {
const { installDeps } = await prompts({
type: 'confirm',
name: 'installDeps',
message: 'Do you want to install project dependencies?',
initial: true,
})

if (!installDeps) return

const { packageManager } = await prompts({
type: 'select',
name: 'packageManager',
message: 'Which package manager do you want to use?',
choices: knownPackageManagerNames.map((template: string) => ({
title: template,
value: template,
})),
initial: knownPackageManagerNames.indexOf(currentPackageManager),
})

chdir(directoryPath)

if (!knownPackageManagers[packageManager]) {
exit(1)
}

const proc = exec(knownPackageManagers[packageManager])

const procExit: number = await new Promise((res) => {
proc.on('exit', (code) => res(code == null ? 0xff : code))
})

if (procExit == 0) {
console.log(bold(`${green('✔')} Installed project dependencies`))
} else {
console.log(bold(`${red('×')} Failed to install project dependencies`))
exit(procExit)
}

return
})
}

function getCurrentPackageManager(): PackageManager {
const agent = process.env.npm_config_user_agent || 'npm' // Types say it might be undefined, just being cautious;

if (agent.startsWith('bun')) return 'bun'
else if (agent.startsWith('pnpm')) return 'pnpm'
else if (agent.startsWith('yarn')) return 'yarn'

return 'npm'
}

export { registerInstallationHook }
Loading

0 comments on commit acf3779

Please sign in to comment.