diff --git a/commands/add.ts b/commands/add.ts new file mode 100644 index 00000000..3c5659d8 --- /dev/null +++ b/commands/add.ts @@ -0,0 +1,157 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { detectPackageManager, installPackage } from '@antfu/install-pkg' + +import { CommandOptions } from '../types/ace.js' +import { args, BaseCommand, flags } from '../modules/ace/main.js' + +/** + * The install command is used to `npm install` and `node ace configure` a new package + * in one go. + */ +export default class Add extends BaseCommand { + static commandName = 'install' + static description = 'Install and configure a package' + static options: CommandOptions = { + allowUnknownFlags: true, + } + + @args.string({ description: 'Package name' }) + declare name: string + + @flags.boolean({ description: 'Display logs in verbose mode' }) + declare verbose?: boolean + + @flags.string({ description: 'Select the package manager you want to use' }) + declare packageManager?: 'npm' | 'pnpm' | 'yarn' + + @flags.boolean({ description: 'Should we install the package as a dev dependency', alias: 'D' }) + declare dev?: boolean + + @flags.boolean({ description: 'Forcefully overwrite existing files' }) + declare force?: boolean + + /** + * Detect the package manager to use + */ + async #getPackageManager() { + const pkgManager = + this.packageManager || (await detectPackageManager(this.app.makePath())) || 'npm' + + if (['npm', 'pnpm', 'yarn'].includes(pkgManager)) { + return pkgManager as 'npm' | 'pnpm' | 'yarn' + } + + throw new Error('Invalid package manager. Must be one of npm, pnpm or yarn') + } + + /** + * Configure the package by delegating the work to the `node ace configure` command + */ + async #configurePackage() { + /** + * Sending unknown flags to the configure command + */ + const flagValueArray = this.parsed.unknownFlags + .filter((flag) => !!this.parsed.flags[flag]) + .map((flag) => `--${flag}=${this.parsed.flags[flag]}`) + + const configureArgs = [ + this.name, + this.force ? '--force' : undefined, + this.verbose ? '--verbose' : undefined, + ...flagValueArray, + ].filter(Boolean) as string[] + + return await this.kernel.exec('configure', configureArgs) + } + + /** + * Install the package using the selected package manager + */ + async #installPackage(npmPackageName: string) { + const colors = this.colors + const spinner = this.logger + .await(`installing ${colors.green(this.name)} using ${colors.grey(this.packageManager!)}`) + .start() + + spinner.start() + + try { + await installPackage(npmPackageName, { + dev: this.dev, + silent: this.verbose === true ? false : true, + cwd: this.app.makePath(), + packageManager: this.packageManager, + }) + + spinner.update('package installed successfully') + spinner.stop() + + return true + } catch (error) { + spinner.update('unable to install the package') + spinner.stop() + + this.logger.fatal(error) + this.exitCode = 1 + return false + } + } + + /** + * Run method is invoked by ace automatically + */ + async run() { + const colors = this.colors + this.packageManager = await this.#getPackageManager() + + /** + * Handle special packages to configure + */ + let npmPackageName = this.name + if (this.name === 'vinejs') { + npmPackageName = '@vinejs/vine' + } else if (this.name === 'edge') { + npmPackageName = 'edge.js' + } + + /** + * Prompt the user to confirm the installation + */ + const cmd = colors.grey(`${this.packageManager} add ${this.dev ? '-D ' : ''}${this.name}`) + this.logger.info(`Installing the package using the following command : ${cmd}`) + + const shouldInstall = await this.prompt.confirm('Continue ?', { name: 'install' }) + if (!shouldInstall) { + this.logger.info('Installation cancelled') + return + } + + /** + * Install package + */ + const pkgWasInstalled = await this.#installPackage(npmPackageName) + if (!pkgWasInstalled) { + return + } + + /** + * Configure package + */ + const { exitCode } = await this.#configurePackage() + this.exitCode = exitCode + if (exitCode === 0) { + this.logger.success(`Installed and configured ${colors.green(this.name)}`) + } else { + this.logger.fatal(`Unable to configure ${colors.green(this.name)}`) + } + } +} diff --git a/tests/commands/add.spec.ts b/tests/commands/add.spec.ts new file mode 100644 index 00000000..7b933e98 --- /dev/null +++ b/tests/commands/add.spec.ts @@ -0,0 +1,328 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { join } from 'node:path' +import { test } from '@japa/runner' +import { ListLoader } from '@adonisjs/ace' +import type { FileSystem } from '@japa/file-system' + +import Add from '../../commands/add.js' +import Configure from '../../commands/configure.js' +import { AceFactory } from '../../factories/core/ace.js' + +const VERBOSE = !!process.env.CI + +/** + * Setup a fake adonis project in the file system + */ +async function setupProject(fs: FileSystem, pkgManager?: 'npm' | 'pnpm' | 'yarn') { + await fs.create( + 'package.json', + JSON.stringify({ type: 'module', name: 'test', dependencies: {} }) + ) + + if (pkgManager === 'pnpm') { + await fs.create('pnpm-lock.yaml', '') + } else if (pkgManager === 'yarn') { + await fs.create('yarn.lock', '') + } else { + await fs.create('package-lock.json', '') + } + + await fs.create('tsconfig.json', JSON.stringify({ compilerOptions: {} })) + await fs.create('adonisrc.ts', `export default defineConfig({})`) + await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) + await fs.create('start/kernel.ts', `export default Env.create(new URL('./'), {})`) + await fs.create('.env', '') +} + +/** + * Setup a fake package inside the node_modules directory + */ +async function setupPackage(fs: FileSystem, configureContent?: string) { + await fs.create( + 'node_modules/foo/package.json', + JSON.stringify({ type: 'module', name: 'test', main: 'index.js', dependencies: {} }) + ) + + await fs.create( + 'node_modules/foo/index.js', + `export const stubsRoot = './' + export async function configure(command) { ${configureContent} }` + ) +} + +test.group('Install', (group) => { + group.tap((t) => t.disableTimeout()) + + test('detect correct pkg manager ( npm )', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'npm') + await setupPackage(fs) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE + + await command.exec() + + await assert.fileIsNotEmpty('package-lock.json') + }) + + test('detect correct pkg manager ( pnpm )', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'pnpm') + await setupPackage(fs) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE + + await command.exec() + + await assert.fileIsNotEmpty('pnpm-lock.yaml') + }) + + test('use specific package manager', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'npm') + await setupPackage(fs) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE + command.packageManager = 'pnpm' + + await command.exec() + + await assert.fileIsNotEmpty('pnpm-lock.yaml') + }) + + test('should install dependency', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'npm') + await setupPackage(fs) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE + + await command.exec() + + await assert.fileContains('package.json', 'foo') + }) + + test('should install dev dependency', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'npm') + await setupPackage(fs) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href, '-D']) + command.verbose = VERBOSE + + await command.exec() + + const pkgJson = await fs.contentsJson('package.json') + assert.deepEqual(pkgJson.devDependencies, { test: 'file:node_modules/foo' }) + }) + + test('pass unknown args to configure', async ({ fs, assert }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'npm') + await setupPackage( + fs, + ` + command.logger.log(command.parsedFlags) + ` + ) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [ + new URL('node_modules/foo', fs.baseUrl).href, + '--foo', + '--auth=session', + '-x', + ]) + command.verbose = VERBOSE + + await command.exec() + + const logs = command.logger.getLogs() + + assert.deepInclude(logs, { + message: { foo: 'true', auth: 'session', x: 'true', verbose: VERBOSE }, + stream: 'stdout', + }) + }) + + test('should configure package', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'pnpm') + await setupPackage( + fs, + ` const codemods = await command.createCodemods() + await codemods.updateRcFile((rcFile) => { + rcFile.addProvider('@adonisjs/cache/cache_provider') + })` + ) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE + + await command.exec() + + await assert.fileContains('adonisrc.ts', '@adonisjs/cache/cache_provider') + }) + + test('display error and stop if package install fail', async ({ fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'pnpm') + await setupPackage(fs) + + await ace.app.init() + + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [ + new URL('node_modules/inexistent', fs.baseUrl).toString(), + ]) + command.verbose = VERBOSE + + await command.exec() + + command.assertExitCode(1) + command.assertLogMatches(/Command failed with exit code 1/) + }) + + test('display error if configure fail', async ({ fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(join(filePath, `index.js?${Math.random()}`)), + }) + + await setupProject(fs, 'pnpm') + await setupPackage(fs, 'throw new Error("Invalid configure")') + + await ace.app.init() + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE + + await command.exec() + + command.assertExitCode(1) + command.assertLogMatches(/Invalid configure/) + }) + + test('configure edge', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(filePath), + }) + + await setupProject(fs, 'pnpm') + + await ace.app.init() + ace.addLoader(new ListLoader([Configure])) + ace.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, ['edge']) + command.verbose = VERBOSE + + await command.exec() + + await assert.fileContains('package.json', 'edge.js') + await assert.fileContains('adonisrc.ts', '@adonisjs/core/providers/edge_provider') + }) + + test('configure vinejs', async ({ assert, fs }) => { + const ace = await new AceFactory().make(fs.baseUrl, { + importer: (filePath) => import(filePath), + }) + + await setupProject(fs, 'pnpm') + + await ace.app.init() + ace.addLoader(new ListLoader([Configure])) + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, ['vinejs']) + command.verbose = VERBOSE + + await command.exec() + + await assert.fileContains('package.json', '@vinejs/vine') + await assert.fileContains('adonisrc.ts', '@adonisjs/core/providers/vinejs_provider') + }) +})