1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -6,3 +6,4 @@
|
||||
packages/server/**/* @ryardley
|
||||
packages/cli/**/* @aem
|
||||
packages/generator/**/* @aem
|
||||
packages/installer/**/* @aem
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
42
packages/cli/src/commands/install.ts
Normal file
42
packages/cli/src/commands/install.ts
Normal file
@@ -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<any>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PromptActions> {
|
||||
@@ -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 ')
|
||||
|
||||
@@ -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'
|
||||
|
||||
15
packages/installer/.gitignore
vendored
Normal file
15
packages/installer/.gitignore
vendored
Normal file
@@ -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
|
||||
5
packages/installer/README.md
Normal file
5
packages/installer/README.md
Normal file
@@ -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`.
|
||||
25
packages/installer/jest.config.js
Normal file
25
packages/installer/jest.config.js
Normal file
@@ -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: ['<rootDie>/tmp', '<rootDir>/dist'],
|
||||
// TODO enable threshold
|
||||
// coverageThreshold: {
|
||||
// global: {
|
||||
// branches: 100,
|
||||
// functions: 100,
|
||||
// lines: 100,
|
||||
// statements: 100,
|
||||
// },
|
||||
// },
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsConfig: 'test/tsconfig.json',
|
||||
isolatedModules: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
56
packages/installer/package.json
Normal file
56
packages/installer/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
47
packages/installer/src/executors/add-dependency-executor.ts
Normal file
47
packages/installer/src/executors/add-dependency-executor.ts
Normal file
@@ -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<NpmPackage[]>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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`)
|
||||
}
|
||||
34
packages/installer/src/executors/executor.ts
Normal file
34
packages/installer/src/executors/executor.ts
Normal file
@@ -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<T> = (cliArgs: any) => T
|
||||
|
||||
function isDynamicExecutorArgument<T>(input: executorArgument<T>): input is dynamicExecutorArgument<T> {
|
||||
return typeof (input as dynamicExecutorArgument<T>) === 'function'
|
||||
}
|
||||
|
||||
export type executorArgument<T> = T | dynamicExecutorArgument<T>
|
||||
|
||||
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<T>(input: executorArgument<T>, cliArgs: any): T {
|
||||
if (isDynamicExecutorArgument(input)) {
|
||||
return input(cliArgs)
|
||||
}
|
||||
return input
|
||||
}
|
||||
36
packages/installer/src/executors/file-prompt.ts
Normal file
36
packages/installer/src/executors/file-prompt.ts
Normal file
@@ -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<string[]> {
|
||||
return globby(filter, {expandDirectories: true})
|
||||
}
|
||||
|
||||
export async function filePrompt(options: FilePromptOptions): Promise<string> {
|
||||
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
|
||||
}
|
||||
58
packages/installer/src/executors/file-transform-executor.ts
Normal file
58
packages/installer/src/executors/file-transform-executor.ts
Normal file
@@ -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<string>
|
||||
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<void> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
60
packages/installer/src/executors/new-file-executor.ts
Normal file
60
packages/installer/src/executors/new-file-executor.ts
Normal file
@@ -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<string>
|
||||
templatePath: executorArgument<string>
|
||||
templateValues: executorArgument<{[key: string]: string}>
|
||||
destinationPathPrompt?: executorArgument<string>
|
||||
}
|
||||
|
||||
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<TempGeneratorOptions> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
8
packages/installer/src/index.ts
Normal file
8
packages/installer/src/index.ts
Normal file
@@ -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'
|
||||
129
packages/installer/src/installer.ts
Normal file
129
packages/installer/src/installer.ts
Normal file
@@ -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<Options extends InstallerOptions> {
|
||||
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<void>
|
||||
preInstall?(): Promise<void>
|
||||
beforeEach?(stepId: string | number): Promise<void>
|
||||
afterEach?(stepId: string | number): Promise<void>
|
||||
postInstall?(): Promise<void>
|
||||
}
|
||||
|
||||
export class Installer<Options extends InstallerOptions> {
|
||||
private readonly steps: Executor[]
|
||||
private readonly options: Options
|
||||
|
||||
constructor(options: Options, steps: Executor[]) {
|
||||
this.options = options
|
||||
this.steps = steps
|
||||
}
|
||||
|
||||
private async validateArgs(cliArgs: {}): Promise<void> {
|
||||
if (this.options.validateArgs) return this.options.validateArgs(cliArgs)
|
||||
}
|
||||
private async preInstall(): Promise<void> {
|
||||
if (this.options.preInstall) return this.options.preInstall()
|
||||
}
|
||||
private async beforeEach(stepId: string | number): Promise<void> {
|
||||
if (this.options.beforeEach) return this.options.beforeEach(stepId)
|
||||
}
|
||||
private async afterEach(stepId: string | number): Promise<void> {
|
||||
if (this.options.afterEach) return this.options.afterEach(stepId)
|
||||
}
|
||||
private async postInstall(): Promise<void> {
|
||||
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<void> {
|
||||
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!`)
|
||||
}
|
||||
}
|
||||
21
packages/installer/src/transforms/add-import.ts
Normal file
21
packages/installer/src/transforms/add-import.ts
Normal file
@@ -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
|
||||
}
|
||||
1
packages/installer/src/transforms/index.ts
Normal file
1
packages/installer/src/transforms/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './add-import'
|
||||
21
packages/installer/src/utils/paths.ts
Normal file
21
packages/installer/src/utils/paths.ts
Normal file
@@ -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'
|
||||
},
|
||||
}
|
||||
57
packages/installer/src/utils/transform.ts
Normal file
57
packages/installer/src/utils/transform.ts
Normal file
@@ -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
|
||||
}
|
||||
12
packages/installer/src/utils/wait-for-confirmation.ts
Normal file
12
packages/installer/src/utils/wait-for-confirmation.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 <div>hello world!</div>
|
||||
}"
|
||||
`;
|
||||
37
packages/installer/test/transforms/add-import.test.ts
Normal file
37
packages/installer/test/transforms/add-import.test.ts
Normal file
@@ -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 <div>hello world!</div>
|
||||
}`
|
||||
const importStatement = b.importDeclaration([], b.literal('app/styles/app.css'))
|
||||
expect(executeImport(file, importStatement)).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
9
packages/installer/test/tsconfig.json
Normal file
9
packages/installer/test/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["jest"],
|
||||
"target": "es6"
|
||||
},
|
||||
"references": [{"path": ".."}]
|
||||
}
|
||||
23
packages/installer/test/utils/paths.test.ts
Normal file
23
packages/installer/test/utils/paths.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
13
packages/installer/tsconfig.json
Normal file
13
packages/installer/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src", "types", "test"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"declarationDir": "./dist",
|
||||
"downlevelIteration": true,
|
||||
"paths": {
|
||||
"*": ["src/*", "node_modules/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
43
yarn.lock
43
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==
|
||||
|
||||
Reference in New Issue
Block a user