From 5b9b355e4b297e6a6ddb69313621677ca1d29479 Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Sun, 26 Nov 2023 21:11:26 +0100 Subject: [PATCH 1/3] feat: add Add command --- commands/add.ts | 149 +++++++++++++++++++++ tests/commands/add.spec.ts | 251 +++++++++++++++++++++++++++++++++++ tests/commands/serve.spec.ts | 2 - 3 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 commands/add.ts create mode 100644 tests/commands/add.spec.ts diff --git a/commands/add.ts b/commands/add.ts new file mode 100644 index 00000000..0126c5d5 --- /dev/null +++ b/commands/add.ts @@ -0,0 +1,149 @@ +/* + * @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' }) + 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() { + const configureArgs = [ + this.name, + this.force ? '--force' : undefined, + this.verbose ? '--verbose' : undefined, + ].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..317dde4e --- /dev/null +++ b/tests/commands/add.spec.ts @@ -0,0 +1,251 @@ +/* + * @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 { fileURLToPath } from 'node:url' +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' + +/** + * 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, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + await command.exec() + + await assert.fileContains('package.json', 'foo') + }) + + 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, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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, [ + join(fileURLToPath(fs.baseUrl), 'node_modules', 'inexistent'), + ]) + 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, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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']) + 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.ui.switchMode('raw') + ace.prompt.trap('install').accept() + + const command = await ace.create(Add, ['vinejs']) + await command.exec() + + await assert.fileContains('package.json', '@vinejs/vine') + await assert.fileContains('adonisrc.ts', '@adonisjs/core/providers/vinejs_provider') + }) +}) diff --git a/tests/commands/serve.spec.ts b/tests/commands/serve.spec.ts index 60ea9b54..19535059 100644 --- a/tests/commands/serve.spec.ts +++ b/tests/commands/serve.spec.ts @@ -173,8 +173,6 @@ test.group('Serve command', () => { await command.exec() await sleep(600) - console.log(ace.ui.logger.getLogs()) - assert.exists( ace.ui.logger.getLogs().find((log) => { return log.message.match(/starting "vite" dev server/) From 26dc2cb6339a2cfffdaf79bef0df5c329d894868 Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Sun, 18 Feb 2024 18:24:29 +0100 Subject: [PATCH 2/3] refactor: pass unknown args to configure command --- commands/add.ts | 10 ++++++- tests/commands/add.spec.ts | 60 +++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/commands/add.ts b/commands/add.ts index 0126c5d5..3c5659d8 100644 --- a/commands/add.ts +++ b/commands/add.ts @@ -32,7 +32,7 @@ export default class Add extends BaseCommand { @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' }) + @flags.boolean({ description: 'Should we install the package as a dev dependency', alias: 'D' }) declare dev?: boolean @flags.boolean({ description: 'Forcefully overwrite existing files' }) @@ -56,10 +56,18 @@ export default class Add extends BaseCommand { * 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) diff --git a/tests/commands/add.spec.ts b/tests/commands/add.spec.ts index 317dde4e..d9808993 100644 --- a/tests/commands/add.spec.ts +++ b/tests/commands/add.spec.ts @@ -142,6 +142,65 @@ test.group('Install', (group) => { 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, [ + join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo'), + '-D', + ]) + 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, [ + join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo'), + '--foo', + '--auth=session', + '-x', + ]) + await command.exec() + + const logs = command.logger.getLogs() + + assert.deepInclude(logs, { + message: { foo: 'true', auth: 'session', x: 'true' }, + 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()}`)), @@ -239,7 +298,6 @@ test.group('Install', (group) => { await ace.app.init() ace.addLoader(new ListLoader([Configure])) - // ace.ui.switchMode('raw') ace.prompt.trap('install').accept() const command = await ace.create(Add, ['vinejs']) From cc9f4006b74e5cdd8ac7d0687523edb47d58afdf Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Sun, 18 Feb 2024 20:34:18 +0100 Subject: [PATCH 3/3] fix: test on windows --- tests/commands/add.spec.ts | 47 ++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/tests/commands/add.spec.ts b/tests/commands/add.spec.ts index d9808993..7b933e98 100644 --- a/tests/commands/add.spec.ts +++ b/tests/commands/add.spec.ts @@ -9,7 +9,6 @@ import { join } from 'node:path' import { test } from '@japa/runner' -import { fileURLToPath } from 'node:url' import { ListLoader } from '@adonisjs/ace' import type { FileSystem } from '@japa/file-system' @@ -17,6 +16,8 @@ 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 */ @@ -74,7 +75,9 @@ test.group('Install', (group) => { ace.ui.switchMode('raw') ace.prompt.trap('install').accept() - const command = await ace.create(Add, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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') @@ -94,7 +97,9 @@ test.group('Install', (group) => { ace.ui.switchMode('raw') ace.prompt.trap('install').accept() - const command = await ace.create(Add, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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') @@ -114,7 +119,8 @@ test.group('Install', (group) => { ace.ui.switchMode('raw') ace.prompt.trap('install').accept() - const command = await ace.create(Add, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE command.packageManager = 'pnpm' await command.exec() @@ -136,7 +142,9 @@ test.group('Install', (group) => { ace.ui.switchMode('raw') ace.prompt.trap('install').accept() - const command = await ace.create(Add, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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') @@ -156,10 +164,9 @@ test.group('Install', (group) => { ace.ui.switchMode('raw') ace.prompt.trap('install').accept() - const command = await ace.create(Add, [ - join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo'), - '-D', - ]) + 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') @@ -186,17 +193,19 @@ test.group('Install', (group) => { ace.prompt.trap('install').accept() const command = await ace.create(Add, [ - join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo'), + 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' }, + message: { foo: 'true', auth: 'session', x: 'true', verbose: VERBOSE }, stream: 'stdout', }) }) @@ -221,7 +230,9 @@ test.group('Install', (group) => { ace.ui.switchMode('raw') ace.prompt.trap('install').accept() - const command = await ace.create(Add, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + 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') @@ -242,8 +253,10 @@ test.group('Install', (group) => { ace.prompt.trap('install').accept() const command = await ace.create(Add, [ - join(fileURLToPath(fs.baseUrl), 'node_modules', 'inexistent'), + new URL('node_modules/inexistent', fs.baseUrl).toString(), ]) + command.verbose = VERBOSE + await command.exec() command.assertExitCode(1) @@ -263,7 +276,9 @@ test.group('Install', (group) => { ace.ui.switchMode('raw') ace.prompt.trap('install').accept() - const command = await ace.create(Add, [join(fileURLToPath(fs.baseUrl), 'node_modules', 'foo')]) + const command = await ace.create(Add, [new URL('node_modules/foo', fs.baseUrl).href]) + command.verbose = VERBOSE + await command.exec() command.assertExitCode(1) @@ -283,6 +298,8 @@ test.group('Install', (group) => { 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') @@ -301,6 +318,8 @@ test.group('Install', (group) => { 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')