From 75a7feabf94044d11cae9d8569de2510f5e0612b Mon Sep 17 00:00:00 2001 From: Adam Markon Date: Thu, 7 May 2020 09:18:59 -0400 Subject: [PATCH] Add base infrastructure for `blitz install` (#434) (meta) --- .github/CODEOWNERS | 1 + packages/blitz/package.json | 1 + packages/cli/package.json | 3 +- packages/cli/src/commands/install.ts | 42 ++++++ packages/generator/src/conflict-checker.ts | 10 +- packages/generator/src/index.ts | 2 + packages/installer/.gitignore | 15 ++ packages/installer/README.md | 5 + packages/installer/jest.config.js | 25 ++++ packages/installer/package.json | 56 ++++++++ .../src/executors/add-dependency-executor.ts | 47 +++++++ packages/installer/src/executors/executor.ts | 34 +++++ .../installer/src/executors/file-prompt.ts | 36 +++++ .../src/executors/file-transform-executor.ts | 58 ++++++++ .../src/executors/new-file-executor.ts | 60 ++++++++ packages/installer/src/index.ts | 8 ++ packages/installer/src/installer.ts | 129 ++++++++++++++++++ .../installer/src/transforms/add-import.ts | 21 +++ packages/installer/src/transforms/index.ts | 1 + packages/installer/src/utils/paths.ts | 21 +++ packages/installer/src/utils/transform.ts | 57 ++++++++ .../src/utils/wait-for-confirmation.ts | 12 ++ .../__snapshots__/add-import.test.ts.snap | 16 +++ .../test/transforms/add-import.test.ts | 37 +++++ packages/installer/test/tsconfig.json | 9 ++ packages/installer/test/utils/paths.test.ts | 23 ++++ packages/installer/tsconfig.json | 13 ++ packages/server/src/log.ts | 5 + yarn.lock | 43 ++++-- 29 files changed, 770 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/commands/install.ts create mode 100644 packages/installer/.gitignore create mode 100644 packages/installer/README.md create mode 100644 packages/installer/jest.config.js create mode 100644 packages/installer/package.json create mode 100644 packages/installer/src/executors/add-dependency-executor.ts create mode 100644 packages/installer/src/executors/executor.ts create mode 100644 packages/installer/src/executors/file-prompt.ts create mode 100644 packages/installer/src/executors/file-transform-executor.ts create mode 100644 packages/installer/src/executors/new-file-executor.ts create mode 100644 packages/installer/src/index.ts create mode 100644 packages/installer/src/installer.ts create mode 100644 packages/installer/src/transforms/add-import.ts create mode 100644 packages/installer/src/transforms/index.ts create mode 100644 packages/installer/src/utils/paths.ts create mode 100644 packages/installer/src/utils/transform.ts create mode 100644 packages/installer/src/utils/wait-for-confirmation.ts create mode 100644 packages/installer/test/transforms/__snapshots__/add-import.test.ts.snap create mode 100644 packages/installer/test/transforms/add-import.test.ts create mode 100644 packages/installer/test/tsconfig.json create mode 100644 packages/installer/test/utils/paths.test.ts create mode 100644 packages/installer/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb2d1c604..63910da10 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,3 +6,4 @@ packages/server/**/* @ryardley packages/cli/**/* @aem packages/generator/**/* @aem +packages/installer/**/* @aem diff --git a/packages/blitz/package.json b/packages/blitz/package.json index 62f7c3a04..7d46b3ad5 100644 --- a/packages/blitz/package.json +++ b/packages/blitz/package.json @@ -43,6 +43,7 @@ "@blitzjs/cli": "0.9.0", "@blitzjs/core": "0.9.0", "@blitzjs/generator": "0.9.0", + "@blitzjs/installer": "0.9.0", "@blitzjs/server": "0.9.0", "os-name": "^3.1.0", "pkg-dir": "^4.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5a93e9bdd..ff7950a07 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,7 +6,7 @@ "scripts": { "b": "./bin/run", "clean": "rimraf lib", - "predev": "wait-on ../server/dist/packages/server/src/index.d.ts && wait-on ../generator/dist/packages/generator/src/index.d.ts", + "predev": "wait-on ../installer/dist/packages/installer/src/index.d.ts && wait-on ../server/dist/packages/server/src/index.d.ts && wait-on ../generator/dist/packages/generator/src/index.d.ts", "dev": "rimraf lib && tsc --watch --preserveWatchOutput", "build": "rimraf lib && tsc", "lint": "tsdx lint", @@ -49,6 +49,7 @@ }, "devDependencies": { "@blitzjs/generator": "0.9.0", + "@blitzjs/installer": "0.9.0", "@blitzjs/server": "0.9.0", "@oclif/dev-cli": "^1.22.2", "@oclif/test": "^1.2.5", diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 000000000..12fb8208f --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,42 @@ +import {Command} from '../command' +import * as path from 'path' +import {Installer} from '@blitzjs/installer' + +// eslint-disable-next-line import/no-default-export +export default class Install extends Command { + static description = 'Install a third-party package into your Blitz app' + static aliases = ['i'] + static strict = false + static hidden = true + + static args = [ + { + name: 'installer', + required: true, + description: 'Name of a Blitz installer from @blitzjs/installers, or a file path to a local installer', + }, + { + name: 'installer-flags', + description: + 'A list of flags to pass to the installer. Blitz will only parse these in the form key=value', + }, + ] + + async run() { + const {args} = this.parse(Install) + const isNavtiveInstaller = /^([\w]*)$/.test(args.installer) + if (isNavtiveInstaller) { + } else { + const installerPath = path.resolve(args.installer) + const installer = require(installerPath).default as Installer + const installerArgs = this.argv.reduce( + (acc, arg) => ({ + ...acc, + [arg.split('=')[0]]: JSON.parse(arg.split('=')[1] || String(true)), // if no value is provided, assume it's a boolean flag + }), + {}, + ) + await installer.run(installerArgs) + } + } +} diff --git a/packages/generator/src/conflict-checker.ts b/packages/generator/src/conflict-checker.ts index a3fe7d96d..bc2a56dad 100644 --- a/packages/generator/src/conflict-checker.ts +++ b/packages/generator/src/conflict-checker.ts @@ -45,7 +45,7 @@ export class ConflictChecker extends Transform { if (status !== 'skip') { this.handlePush(file, status) } else { - this.fileStatusString(file, status) + this.fileStatusString(file, status, this.options?.dryRun) } cb() @@ -71,7 +71,7 @@ export class ConflictChecker extends Transform { handlePush(file: File, status: PromptActions): void { if (!this.options?.dryRun) this.push(file) - this.emit('fileStatus', this.fileStatusString(file, status)) + this.emit('fileStatus', this.fileStatusString(file, status, this.options?.dryRun)) } private async checkDiff(file: File): Promise { @@ -120,14 +120,14 @@ export class ConflictChecker extends Transform { console.log('\n') } - private fileStatusString(file: File, status: PromptActions) { + private fileStatusString(file: File, status: PromptActions, dryRun: boolean = false) { let statusLog = null switch (status) { case 'create': - statusLog = chalk.green('CREATE ') + statusLog = chalk.green(`${dryRun ? 'Would create' : 'CREATE'} `) break case 'overwrite': - statusLog = chalk.cyan('OVERWRITE') + statusLog = chalk.cyan(`${dryRun ? 'Would overwrite' : 'OVERWRITE'} `) break case 'skip': statusLog = chalk.blue('SKIP ') diff --git a/packages/generator/src/index.ts b/packages/generator/src/index.ts index 333524fda..a00d21025 100644 --- a/packages/generator/src/index.ts +++ b/packages/generator/src/index.ts @@ -3,3 +3,5 @@ export * from './generators/model-generator' export * from './generators/mutation-generator' export * from './generators/page-generator' export * from './generators/query-generator' +export * from './generator' +export * from './conflict-checker' diff --git a/packages/installer/.gitignore b/packages/installer/.gitignore new file mode 100644 index 000000000..775069de5 --- /dev/null +++ b/packages/installer/.gitignore @@ -0,0 +1,15 @@ +*.log +.DS_Store +node_modules +.rts2_cache_cjs +.rts2_cache_esm +.rts2_cache_umd +.rts2_cache_system +dist +tmp +.blitz + +# good directory to use for testing app generation +_app + +!templates/**/.env diff --git a/packages/installer/README.md b/packages/installer/README.md new file mode 100644 index 000000000..fabf80a1f --- /dev/null +++ b/packages/installer/README.md @@ -0,0 +1,5 @@ +# `installer` + +The installer package houses all of the types, classes, and utilities for building a Blitz Installer. A Blitz installer is effectively just a list of steps, represented as an array of objects that conform to one of the types in the `Executor` union type (`NewFileExecutor | AddDependencyExecutor } FileTransformExecutor`). These executors are processed by the framework, executed interactively by the user, and ultimately run to install new packages to an existing Blitz app. + +You can find the implementation of all Executors in the `executors/` directory, stock transforms that we'll be supplying to authors in `transforms/`, and various utilities in `utils/`, including a `paths` utility that the user can access for common paths to modify such as `_document.tsx`. diff --git a/packages/installer/jest.config.js b/packages/installer/jest.config.js new file mode 100644 index 000000000..9e8a29092 --- /dev/null +++ b/packages/installer/jest.config.js @@ -0,0 +1,25 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'js', 'json'], + coverageReporters: ['json', 'lcov', 'text', 'clover'], + // collectCoverage: !!`Boolean(process.env.CI)`, + collectCoverageFrom: ['src/**/*.ts'], + modulePathIgnorePatterns: ['/tmp', '/dist'], + // TODO enable threshold + // coverageThreshold: { + // global: { + // branches: 100, + // functions: 100, + // lines: 100, + // statements: 100, + // }, + // }, + + globals: { + 'ts-jest': { + tsConfig: 'test/tsconfig.json', + isolatedModules: true, + }, + }, +} diff --git a/packages/installer/package.json b/packages/installer/package.json new file mode 100644 index 000000000..885e8810b --- /dev/null +++ b/packages/installer/package.json @@ -0,0 +1,56 @@ +{ + "name": "@blitzjs/installer", + "version": "0.9.0", + "description": "Package installation for the Blitz CLI", + "homepage": "https://github.com/blitz-js/blitz#readme", + "license": "MIT", + "scripts": { + "predev": "wait-on ../generator/dist/packages/generator/src/index.d.ts && wait-on ../server/dist/packages/server/src/index.d.ts", + "dev": "tsdx watch --verbose", + "build": "tsdx build", + "test": "tsdx test", + "test:watch": "tsdx test --watch", + "lint": "tsdx lint" + }, + "author": { + "name": "Brandon Bayer", + "email": "b@bayer.ws", + "url": "https://twitter.com/flybayer" + }, + "main": "dist/index.js", + "module": "dist/installer.esm.js", + "types": "dist/packages/installer/src/index.d.ts", + "files": [ + "dist" + ], + "husky": { + "hooks": { + "pre-commit": "tsdx lint" + } + }, + "keywords": [ + "blitz", + "installer" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/blitz-js/blitz.git" + }, + "dependencies": { + "@babel/core": "^7.9.0", + "@babel/plugin-transform-typescript": "^7.9.4", + "ast-types": "^0.13.3", + "cross-spawn": "^7.0.2", + "diff": "^4.0.2", + "enquirer": "^2.3.5", + "fs-extra": "^9.0.0", + "globby": "11.0.0", + "recast": "^0.19.1", + "ts-node": "^8.9.1" + }, + "devDependencies": { + "@blitzjs/generator": "0.9.0", + "@blitzjs/server": "0.9.0", + "@types/node": "^13.13.4" + } +} diff --git a/packages/installer/src/executors/add-dependency-executor.ts b/packages/installer/src/executors/add-dependency-executor.ts new file mode 100644 index 000000000..39a567e8c --- /dev/null +++ b/packages/installer/src/executors/add-dependency-executor.ts @@ -0,0 +1,47 @@ +import {BaseExecutor, executorArgument, getExecutorArgument} from './executor' +import * as fs from 'fs-extra' +import * as path from 'path' +import spawn from 'cross-spawn' +import {log} from '@blitzjs/server/src/log' + +interface NpmPackage { + name: string + // defaults to latest published + version?: string + // defaults to false + isDevDep?: boolean +} + +export interface AddDependencyExecutor extends BaseExecutor { + packages: executorArgument +} + +export function isAddDependencyExecutor(executor: BaseExecutor): executor is AddDependencyExecutor { + return (executor as AddDependencyExecutor).packages !== undefined +} + +async function getPackageManager(): Promise<'yarn' | 'npm'> { + if (fs.existsSync(path.resolve('package-lock.json'))) { + return 'npm' + } + return 'yarn' +} + +export async function addDependencyExecutor(executor: AddDependencyExecutor, cliArgs: any): Promise { + const packageManager = await getPackageManager() + const packagesToInstall = getExecutorArgument(executor.packages, cliArgs) + for (const pkg of packagesToInstall) { + const args: string[] = ['add'] + // if devDep flag isn't specified we install as a regular dependency, so + // we need to explicitly check for `true` + if (pkg.isDevDep === true) { + args.push(packageManager === 'yarn' ? '-D' : '--save-dev') + } + pkg.version ? args.push(`${pkg.name}@${pkg.version}`) : args.push(pkg.name) + log.meta(`Installing ${pkg.name} ${pkg.isDevDep !== false ? 'as a dev dependency' : ''}`) + spawn.sync(packageManager, args, { + stdio: ['inherit', 'pipe', 'pipe'], + }) + } + log.progress(`${packagesToInstall.length} packages installed successfully`) +} diff --git a/packages/installer/src/executors/executor.ts b/packages/installer/src/executors/executor.ts new file mode 100644 index 000000000..810f9f85b --- /dev/null +++ b/packages/installer/src/executors/executor.ts @@ -0,0 +1,34 @@ +import {log} from '@blitzjs/server/src/log' + +export interface BaseExecutor { + stepId: string | number + stepName: string + // a bit to display to the user to give context to the change + explanation: string +} + +type dynamicExecutorArgument = (cliArgs: any) => T + +function isDynamicExecutorArgument(input: executorArgument): input is dynamicExecutorArgument { + return typeof (input as dynamicExecutorArgument) === 'function' +} + +export type executorArgument = T | dynamicExecutorArgument + +export function logExecutorFrontmatter(executor: BaseExecutor) { + console.log() + const lineLength = executor.stepName.length + 6 + const verticalBorder = `+${new Array(lineLength).fill('–').join('')}+` + log.branded(verticalBorder) + log.branded(`⎪ ${executor.stepName} ⎪`) + log.branded(verticalBorder) + log.info(executor.explanation) + console.log() +} + +export function getExecutorArgument(input: executorArgument, cliArgs: any): T { + if (isDynamicExecutorArgument(input)) { + return input(cliArgs) + } + return input +} diff --git a/packages/installer/src/executors/file-prompt.ts b/packages/installer/src/executors/file-prompt.ts new file mode 100644 index 000000000..f291fe957 --- /dev/null +++ b/packages/installer/src/executors/file-prompt.ts @@ -0,0 +1,36 @@ +import {prompt as enquirer} from 'enquirer' +import globby from 'globby' + +enum SearchType { + file, + directory, +} + +interface FilePromptOptions { + globFilter?: string + getChoices?(context: any): string[] + searchType?: SearchType + context: any +} + +async function getMatchingFiles(filter: string = ''): Promise { + return globby(filter, {expandDirectories: true}) +} + +export async function filePrompt(options: FilePromptOptions): Promise { + const choices = options.getChoices + ? options.getChoices(options.context) + : await getMatchingFiles(options.globFilter) + if (choices.length === 1) { + return choices[0] + } + const results = await enquirer({ + type: 'autocomplete', + name: 'file', + message: 'Select the target file', + // @ts-ignore + limit: 10, + choices, + }) + return results.file +} diff --git a/packages/installer/src/executors/file-transform-executor.ts b/packages/installer/src/executors/file-transform-executor.ts new file mode 100644 index 000000000..a9769725a --- /dev/null +++ b/packages/installer/src/executors/file-transform-executor.ts @@ -0,0 +1,58 @@ +import {BaseExecutor, executorArgument, getExecutorArgument} from './executor' +import {filePrompt} from './file-prompt' +import {transform, Transformer} from '../utils/transform' +import {log} from '@blitzjs/server/src/log' +import {waitForConfirmation} from '../utils/wait-for-confirmation' +import {createPatch} from 'diff' +import chokidar from 'chokidar' +import * as fs from 'fs-extra' +import chalk from 'chalk' + +export interface FileTransformExecutor extends BaseExecutor { + selectTargetFiles?(cliArgs: any): any[] + singleFileSearch?: executorArgument + transform: Transformer +} + +export function isFileTransformExecutor(executor: BaseExecutor): executor is FileTransformExecutor { + return (executor as FileTransformExecutor).transform !== undefined +} + +async function executeWithDiff(transformFn: Transformer, filePath: string) { + await new Promise((res, rej) => { + const watcher = chokidar.watch(filePath) + const originalFileContents = fs.readFileSync(filePath).toString('utf-8') + watcher.on('change', (path) => { + watcher.close().then(() => { + const patch = createPatch(path, originalFileContents, fs.readFileSync(path).toString('utf-8')) + patch + .split('\n') + .slice(2) + .forEach((line) => { + if (line[0] === '-') console.log(chalk.bold.red(line)) + else if (line[0] === '+') console.log(chalk.bold.green(line)) + else console.log(line) + }) + res(path) + }) + }) + watcher.on('error', (error) => { + rej(error) + }) + transform(transformFn, [filePath]) + }) +} + +export async function fileTransformExecutor(executor: FileTransformExecutor, cliArgs: any): Promise { + const fileToTransform: string = await filePrompt({ + context: cliArgs, + globFilter: getExecutorArgument(executor.singleFileSearch, cliArgs), + getChoices: executor.selectTargetFiles, + }) + try { + await executeWithDiff(executor.transform, fileToTransform) + await waitForConfirmation('The above changes were applied. Press enter to continue') + } catch (err) { + log.error(`Failed to transform ${fileToTransform}`) + } +} diff --git a/packages/installer/src/executors/new-file-executor.ts b/packages/installer/src/executors/new-file-executor.ts new file mode 100644 index 000000000..19e0b0ef7 --- /dev/null +++ b/packages/installer/src/executors/new-file-executor.ts @@ -0,0 +1,60 @@ +import {BaseExecutor, executorArgument, getExecutorArgument} from './executor' +import {Generator, GeneratorOptions} from '@blitzjs/generator' +import {log} from '@blitzjs/server/src/log' +import {waitForConfirmation} from '../utils/wait-for-confirmation' + +export interface NewFileExecutor extends BaseExecutor { + targetDirectory?: executorArgument + templatePath: executorArgument + templateValues: executorArgument<{[key: string]: string}> + destinationPathPrompt?: executorArgument +} + +export function isNewFileExecutor(executor: BaseExecutor): executor is NewFileExecutor { + return (executor as NewFileExecutor).templatePath !== undefined +} + +interface TempGeneratorOptions extends GeneratorOptions { + targetDirectory?: string + templateRoot: string + templateValues: any +} + +class TempGenerator extends Generator { + sourceRoot: string + targetDirectory: string + templateValues: any + + constructor(options: TempGeneratorOptions) { + super(options) + this.sourceRoot = options.templateRoot + this.templateValues = options.templateValues + this.targetDirectory = options.targetDirectory || '.' + } + + getTemplateValues() { + return this.templateValues + } + + getTargetDirectory() { + return this.targetDirectory + } +} + +export async function newFileExecutor(executor: NewFileExecutor, cliArgs: any): Promise { + const generatorArgs = { + destinationRoot: '.', + targetDirectory: getExecutorArgument(executor.targetDirectory, cliArgs), + templateRoot: getExecutorArgument(executor.templatePath, cliArgs), + templateValues: getExecutorArgument(executor.templateValues, cliArgs), + } + const dryRunGenerator = new TempGenerator({ + ...generatorArgs, + dryRun: true, + }) + const commitGenerator = new TempGenerator(generatorArgs) + log.progress("First we'll do a dry-run. Here's a list of files that would be created:") + await dryRunGenerator.run() + await waitForConfirmation('To commit the changes, press enter. Press Ctrl+C to abort') + await commitGenerator.run() +} diff --git a/packages/installer/src/index.ts b/packages/installer/src/index.ts new file mode 100644 index 000000000..efdeb1df1 --- /dev/null +++ b/packages/installer/src/index.ts @@ -0,0 +1,8 @@ +export * from './installer' +export * from './executors/executor' +export * from './executors/add-dependency-executor' +export * from './executors/file-transform-executor' +export * from './executors/new-file-executor' +export * from './utils/paths' +export * from './transforms' +export {customTsParser} from './utils/transform' diff --git a/packages/installer/src/installer.ts b/packages/installer/src/installer.ts new file mode 100644 index 000000000..613442139 --- /dev/null +++ b/packages/installer/src/installer.ts @@ -0,0 +1,129 @@ +/* + +An installer is a package that has a single index.(ts|js) file with a single +default export. Its type is + +class Installer { + steps: InstallerStep[] + options: Options +} + +The Installer class is, at its core, a flexible step execution framework for +clients. It's likely that much of this code can be reused for plugins when +the time comes. + +The client exporting its own instance of Installer allows installers authored +in TypeScript to ensure thier installer conforms to the Installer's interface. +In the Blitz CLI, the `install` command will know how to fetch installers from +the Blitz-hosted installer set. Alternatively, you could supply a relative +filesystem path to an installer or an absolute URL to a GitHub repo that houses +an installer. + +The `install` command will read the package, execute the script steps, guiding +the user through the installation step-by-step based on the `steps` config. +Any extra CLI args passed will be parsed into a JS object and passed directly +to each installer step and lifecycle method. + +We'll begin by supporting three step types, or executors: transform files, add +files, and add dependencies. These steps are each strongly typed and have +strict validation, including requirements for explanations of the changes +provided. We'll use these fields to create the wizard for the end user. + +*/ + +import { + AddDependencyExecutor, + isAddDependencyExecutor, + addDependencyExecutor, +} from './executors/add-dependency-executor' +import {NewFileExecutor, isNewFileExecutor, newFileExecutor} from './executors/new-file-executor' +import { + FileTransformExecutor, + isFileTransformExecutor, + fileTransformExecutor, +} from './executors/file-transform-executor' +import {log} from '@blitzjs/server/src/log' +import {logExecutorFrontmatter} from './executors/executor' +import {waitForConfirmation} from './utils/wait-for-confirmation' + +type Executor = FileTransformExecutor | AddDependencyExecutor | NewFileExecutor + +interface InstallerOptions { + packageName: string + packageDescription: string + packageOwner: string + packageRepoLink: string + validateArgs?(args: {}): Promise + preInstall?(): Promise + beforeEach?(stepId: string | number): Promise + afterEach?(stepId: string | number): Promise + postInstall?(): Promise +} + +export class Installer { + private readonly steps: Executor[] + private readonly options: Options + + constructor(options: Options, steps: Executor[]) { + this.options = options + this.steps = steps + } + + private async validateArgs(cliArgs: {}): Promise { + if (this.options.validateArgs) return this.options.validateArgs(cliArgs) + } + private async preInstall(): Promise { + if (this.options.preInstall) return this.options.preInstall() + } + private async beforeEach(stepId: string | number): Promise { + if (this.options.beforeEach) return this.options.beforeEach(stepId) + } + private async afterEach(stepId: string | number): Promise { + if (this.options.afterEach) return this.options.afterEach(stepId) + } + private async postInstall(): Promise { + if (this.options.postInstall) return this.options.postInstall() + } + + async displayFrontmatter() { + log.branded(`Welcome to the installer for ${this.options.packageName}`) + log.branded(this.options.packageDescription) + log.info(`This package is authored and supported by ${this.options.packageOwner}`) + log.info(`For additional documentation and support please visit ${this.options.packageRepoLink}`) + console.log() + await waitForConfirmation('Press enter to begin installation') + } + + async run(cliArgs: {}): Promise { + await this.displayFrontmatter() + try { + await this.validateArgs(cliArgs) + } catch (err) { + log.error(err) + return + } + await this.preInstall() + for (const step of this.steps) { + console.log() // newline + + await this.beforeEach(step.stepId) + + logExecutorFrontmatter(step) + + // using if instead of a switch allows us to strongly type the executors + if (isFileTransformExecutor(step)) { + await fileTransformExecutor(step, cliArgs) + } else if (isAddDependencyExecutor(step)) { + await addDependencyExecutor(step, cliArgs) + } else if (isNewFileExecutor(step)) { + await newFileExecutor(step, cliArgs) + } + + await this.afterEach(step.stepId) + } + await this.postInstall() + + console.log() + log.success(`Installer complete, ${this.options.packageName} is now be configured for your app!`) + } +} diff --git a/packages/installer/src/transforms/add-import.ts b/packages/installer/src/transforms/add-import.ts new file mode 100644 index 000000000..4c529e379 --- /dev/null +++ b/packages/installer/src/transforms/add-import.ts @@ -0,0 +1,21 @@ +import {ASTNode} from 'ast-types' +import {NamedTypes} from 'ast-types/gen/namedTypes' +import {builders} from 'ast-types/gen/builders' +import {types} from 'recast' + +export function addImport( + ast: ASTNode, + __b: builders, + t: NamedTypes, + importToAdd: types.namedTypes.ImportDeclaration, +) { + if (!t.File.check(ast) || !t.ImportDeclaration.check(importToAdd)) return + const statements = ast.program.body + if (statements.length > 0 && !t.ImportDeclaration.check(statements[0])) { + ast.program.body.splice(0, 0, importToAdd) + } else { + const idx = ast.program.body.findIndex((node) => t.ImportDeclaration.check(node)) + ast.program.body.splice(idx + 1, 0, importToAdd) + } + return ast +} diff --git a/packages/installer/src/transforms/index.ts b/packages/installer/src/transforms/index.ts new file mode 100644 index 000000000..2227ae655 --- /dev/null +++ b/packages/installer/src/transforms/index.ts @@ -0,0 +1 @@ +export * from './add-import' diff --git a/packages/installer/src/utils/paths.ts b/packages/installer/src/utils/paths.ts new file mode 100644 index 000000000..85d004abf --- /dev/null +++ b/packages/installer/src/utils/paths.ts @@ -0,0 +1,21 @@ +import * as fs from 'fs-extra' +import * as path from 'path' + +function ext(jsx = false) { + return fs.existsSync(path.resolve('tsconfig.json')) ? (jsx ? '.tsx' : '.ts') : '.js' +} + +export const paths = { + document() { + return `app/pages/_document${ext(true)}` + }, + app() { + return `app/pages/_app${ext(true)}` + }, + entry() { + return `app/pages/index${ext(true)}` + }, + blitzConfig() { + return 'blitz.config.js' + }, +} diff --git a/packages/installer/src/utils/transform.ts b/packages/installer/src/utils/transform.ts new file mode 100644 index 000000000..4857f7e97 --- /dev/null +++ b/packages/installer/src/utils/transform.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs-extra' +import {parse, print, types} from 'recast' +import {builders} from 'ast-types/gen/builders' +import {namedTypes, NamedTypes} from 'ast-types/gen/namedTypes' +import * as babel from 'recast/parsers/babel' +import getBabelOptions, {Overrides} from 'recast/parsers/_babel_options' + +export const customTsParser = { + parse(source: string, options?: Overrides) { + const babelOptions = getBabelOptions(options) + babelOptions.plugins.push('typescript') + babelOptions.plugins.push('jsx') + return babel.parser.parse(source, babelOptions) + }, +} + +export enum TransformStatus { + Success = 'success', + Failure = 'failure', +} +export interface TransformResult { + status: TransformStatus + filename: string + error?: Error +} +export type Transformer = (ast: types.ASTNode, builder: builders, types: NamedTypes) => types.ASTNode + +export function transform(transformerFn: Transformer, targetFilePaths: string[]): TransformResult[] { + const results: TransformResult[] = [] + for (const filePath of targetFilePaths) { + if (!fs.existsSync(filePath)) { + results.push({ + status: TransformStatus.Failure, + filename: filePath, + error: new Error(`Error: ${filePath} not found`), + }) + } + try { + const fileBuffer = fs.readFileSync(filePath) + const fileSource = fileBuffer.toString('utf-8') + const ast = parse(fileSource, {parser: customTsParser}) + const transformedCode = print(transformerFn(ast, types.builders, namedTypes)).code + fs.writeFileSync(filePath, transformedCode) + results.push({ + status: TransformStatus.Success, + filename: filePath, + }) + } catch (err) { + results.push({ + status: TransformStatus.Failure, + filename: filePath, + error: err, + }) + } + } + return results +} diff --git a/packages/installer/src/utils/wait-for-confirmation.ts b/packages/installer/src/utils/wait-for-confirmation.ts new file mode 100644 index 000000000..05bde090a --- /dev/null +++ b/packages/installer/src/utils/wait-for-confirmation.ts @@ -0,0 +1,12 @@ +const defaultMessage = 'Press enter to continue' + +export async function waitForConfirmation(message: string = defaultMessage) { + return new Promise((res) => { + process.stdin.resume() + process.stdout.write(message) + process.stdin.once('data', () => { + process.stdin.pause() + res() + }) + }) +} diff --git a/packages/installer/test/transforms/__snapshots__/add-import.test.ts.snap b/packages/installer/test/transforms/__snapshots__/add-import.test.ts.snap new file mode 100644 index 000000000..62f176abd --- /dev/null +++ b/packages/installer/test/transforms/__snapshots__/add-import.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addImport transform adds import at start of file with no imports present 1`] = ` +"import React from \\"react\\"; +export const truth = () => 42" +`; + +exports[`addImport transform adds import at the end of all imports if imports are present 1`] = ` +"import React from 'react' + +import \\"app/styles/app.css\\"; + +export default function Comp() { + return
hello world!
+}" +`; diff --git a/packages/installer/test/transforms/add-import.test.ts b/packages/installer/test/transforms/add-import.test.ts new file mode 100644 index 000000000..abc15af09 --- /dev/null +++ b/packages/installer/test/transforms/add-import.test.ts @@ -0,0 +1,37 @@ +import {addImport, customTsParser} from '@blitzjs/installer' +import {parse, print, types} from 'recast' +import {namedTypes} from 'ast-types/gen/namedTypes' + +const b = types.builders + +function executeImport(fileStr: string, importStatement: types.namedTypes.ImportDeclaration): string { + return print( + addImport( + parse(fileStr, {parser: customTsParser}) as types.namedTypes.File, + types.builders, + namedTypes, + importStatement, + ) as types.namedTypes.File, + ).code +} + +describe('addImport transform', () => { + it('adds import at start of file with no imports present', () => { + const file = `export const truth = () => 42` + const importStatement = b.importDeclaration( + [b.importDefaultSpecifier(b.identifier('React'))], + b.literal('react'), + ) + expect(executeImport(file, importStatement)).toMatchSnapshot() + }) + + it('adds import at the end of all imports if imports are present', () => { + const file = `import React from 'react' + +export default function Comp() { + return
hello world!
+}` + const importStatement = b.importDeclaration([], b.literal('app/styles/app.css')) + expect(executeImport(file, importStatement)).toMatchSnapshot() + }) +}) diff --git a/packages/installer/test/tsconfig.json b/packages/installer/test/tsconfig.json new file mode 100644 index 000000000..b8dea2dff --- /dev/null +++ b/packages/installer/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "noEmit": true, + "types": ["jest"], + "target": "es6" + }, + "references": [{"path": ".."}] +} diff --git a/packages/installer/test/utils/paths.test.ts b/packages/installer/test/utils/paths.test.ts new file mode 100644 index 000000000..f5e063b90 --- /dev/null +++ b/packages/installer/test/utils/paths.test.ts @@ -0,0 +1,23 @@ +import {paths} from '@blitzjs/installer' +import * as fs from 'fs-extra' + +jest.mock('fs-extra') + +describe('path utils', () => { + it('returns proper file paths in a TS project', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true) + expect(paths.document()).toBe('app/pages/_document.tsx') + expect(paths.app()).toBe('app/pages/_app.tsx') + expect(paths.entry()).toBe('app/pages/index.tsx') + // blitz config is always JS, we shouldn't transform this extension + expect(paths.blitzConfig()).toBe('blitz.config.js') + }) + + it('returns JS file paths in a JS project', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(false) + expect(paths.document()).toBe('app/pages/_document.js') + expect(paths.app()).toBe('app/pages/_app.js') + expect(paths.entry()).toBe('app/pages/index.js') + expect(paths.blitzConfig()).toBe('blitz.config.js') + }) +}) diff --git a/packages/installer/tsconfig.json b/packages/installer/tsconfig.json new file mode 100644 index 000000000..d0f89e8e3 --- /dev/null +++ b/packages/installer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "types", "test"], + "exclude": ["node_modules"], + "compilerOptions": { + "baseUrl": "./", + "declarationDir": "./dist", + "downlevelIteration": true, + "paths": { + "*": ["src/*", "node_modules/*"] + } + } +} diff --git a/packages/server/src/log.ts b/packages/server/src/log.ts index d4f70b798..fbc695792 100644 --- a/packages/server/src/log.ts +++ b/packages/server/src/log.ts @@ -84,6 +84,10 @@ const progress = (msg: string) => { console.log(withCaret(chalk.bold(msg))) } +const info = (msg: string) => { + console.log(chalk.bold(msg)) +} + const spinner = (str: string) => { return ora({ text: str, @@ -128,4 +132,5 @@ export const log = { spinner, success, variable, + info, } diff --git a/yarn.lock b/yarn.lock index 58f5c9bae..e101b1beb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2911,7 +2911,7 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@>= 8", "@types/node@^13.13.2": +"@types/node@*", "@types/node@>= 8", "@types/node@^13.13.2", "@types/node@^13.13.4": version "13.13.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.4.tgz#1581d6c16e3d4803eb079c87d4ac893ee7501c2c" integrity sha512-x26ur3dSXgv5AwKS0lNfbjpCakGIduWU1DU91Zz58ONRWrIKGunmZBNv4P7N+e27sJkiGDsw/3fT4AtsqQBrBA== @@ -3902,6 +3902,11 @@ ast-types-flow@0.0.7, ast-types-flow@^0.0.7: resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= +ast-types@0.13.3, ast-types@^0.13.3: + version "0.13.3" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.3.tgz#50da3f28d17bdbc7969a3a2d83a0e4a72ae755a7" + integrity sha512-XTZ7xGML849LkQP86sWdQzfhwbt3YwIO6MqbX9mUNYY98VKaaVZP7YNNm70IpwecbkkxmfC5IYAzOQ/2p29zRA== + astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" @@ -7781,6 +7786,18 @@ globalyzer@^0.1.0: resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.4.tgz#bc8e273afe1ac7c24eea8def5b802340c5cc534f" integrity sha512-LeguVWaxgHN0MNbWC6YljNMzHkrCny9fzjmEUdnF1kQ7wATFD1RHFRqA1qxaX2tgxGENlcxjOflopBwj3YZiXA== +globby@11.0.0, globby@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.0.tgz#56fd0e9f0d4f8fb0c456f1ab0dee96e1380bc154" + integrity sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + globby@^10.0.1: version "10.0.2" resolved "https://registry.yarnpkg.com/globby/-/globby-10.0.2.tgz#277593e745acaa4646c3ab411289ec47a0392543" @@ -7795,18 +7812,6 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" -globby@^11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.0.tgz#56fd0e9f0d4f8fb0c456f1ab0dee96e1380bc154" - integrity sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" - slash "^3.0.0" - globby@^9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d" @@ -13061,6 +13066,16 @@ realpath-native@^1.1.0: dependencies: util.promisify "^1.0.0" +recast@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.19.1.tgz#555f3612a5a10c9f44b9a923875c51ff775de6c8" + integrity sha512-8FCjrBxjeEU2O6I+2hyHyBFH1siJbMBLwIRvVr1T3FD2cL754sOaJDsJ/8h3xYltasbJ8jqWRIhMuDGBSiSbjw== + dependencies: + ast-types "0.13.3" + esprima "~4.0.0" + private "^0.1.8" + source-map "~0.6.1" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -15088,7 +15103,7 @@ ts-jest@24.3.0, ts-jest@^24.0.2: semver "^5.5" yargs-parser "10.x" -ts-node@^8.9.0: +ts-node@^8.9.0, ts-node@^8.9.1: version "8.9.1" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.9.1.tgz#2f857f46c47e91dcd28a14e052482eb14cfd65a5" integrity sha512-yrq6ODsxEFTLz0R3BX2myf0WBCSQh9A+py8PBo1dCzWIOcvisbyH6akNKqDHMgXePF2kir5mm5JXJTH3OUJYOQ==