-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: automate installing dependencies (#20)
* 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
1 parent
ea5b246
commit acf3779
Showing
9 changed files
with
386 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,4 +3,5 @@ node_modules | |
bin | ||
package-lock.json | ||
yarn-error.log | ||
pnpm-lock.yaml | ||
pnpm-lock.yaml | ||
test-dir |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
Oops, something went wrong.