Skip to content

Instantly share code, notes, and snippets.

@aem
Last active April 29, 2020 08:10
Show Gist options
  • Save aem/dab2c7eabec5d2acce26702198b6e3a2 to your computer and use it in GitHub Desktop.
Save aem/dab2c7eabec5d2acce26702198b6e3a2 to your computer and use it in GitHub Desktop.
/*
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.
*/
export interface BaseInstallerStep {
stepId: string | number
stepName: string
// a bit to display to the user to give context to the change
explanation: string
}
export interface TransformInstallerStep extends BaseInstallerStep {
// a user can either provide a function that will execute and use AST parsing to detect
// possible candidate files useful if you might need to update all usages of a library
selectTargetFiles?(api: Jscodeshift): string[]
// they can also provide a glob pattern to filter the filesystem for potential
// destinations. we'll provide a selection screen in the console if this option is used
singleFileSearch?: string
transform(fileInfo: FileInfo, api: Jscodeshift, options: any): void
}
function isTransformStep(step: BaseInstallerStep): step is TransformInstallerStep {
return (step as TransformInstallerStep).transform !== undefined
}
export interface NewFileInstallerStep extends BaseInstallerStep {
templatePath: string
destinationPath: string
destinationPathPrompt: string
}
function isNewFileStep(step: BaseInstallerStep): step is NewFileInstallerStep {
return (step as NewFileInstallerStep).templatePath !== undefined
}
export interface AddDependencyInstallerStep extends BaseInstallerStep {
packageName: string
// if absent we'll install the latest version of the dependency by default
packageVersion?: string
}
function isAddDependencyStep(step: BaseInstallerStep): step is AddDependencyInstallerStep {
return (step as AddDependencyInstallerStep).packageName !== undefined
}
type InstallerStep = TransformInstallerStep | AddDependencyInstallerStep
interface InstallerOptions {
packageName: string
packageDescription: string
packageOwner: string
packageRepo: string
// validator should throw an error with a helpful message if arguments are invalid
validateArgs?(args: {}): Promise<void>
// I'm still not certain of which lifecycle hooks we should expose, if any. I can
// definitely think of valuable reasons for each, and they're cheap to have, just
// not sure how much we want each plugin to be a different experience.
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: InstallerStep[]
private readonly options: Options
constructor(steps: InstallerStep[], options: Options) {
this.steps = steps
this.options = options
}
private async executeTransform(step: TransformInstallerStep): Promise<void> {
// show step context
// find all target files or prompt for file based on step options
// resolve prompts
// display diff of transform. if there are multiple targets, display the first
// confirm execution
// execute step
}
private async executeAddDependency(step: AddDependencyInstallerStep): Promise<void> {
// show step context
// show version that will be installed and get confirmation
// install dependency
}
private async executeNewFile(step: NewFileInstallerStep): Promise<void> {
// can use existing generator infrastructure
}
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 run(cliArgs: {}): Promise<void> {
try {
await this.validateArgs(cliArgs)
} catch (err) {
console.error(err)
return
}
await this.preInstall()
for (const step of this.steps) {
await this.beforeEach(step.stepId)
// using `if` instead of a switch allows us to strongly type the executors
if (isTransformStep(step)) {
await this.executeTransform(step)
} else if (isAddDependencyStep(step)) {
await this.executeAddDependency(step)
} else if (isNewFileStep(step)) {
await this.executeNewFile(step)
}
await this.afterEach(step.stepId)
}
await this.postInstall()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment