1
0
mirror of synced 2025-12-19 18:11:23 -05:00

Move installer package to nextjs/packages (#3044)

(patch)
This commit is contained in:
Aleksandra Sikora
2021-12-10 13:26:54 +01:00
committed by GitHub
parent d05f00c0a8
commit 5f1c6a4571
92 changed files with 2716 additions and 1019 deletions

View File

@@ -110,6 +110,33 @@ jobs:
env: env:
CI: true CI: true
testNextPackages:
name: Next - Test Packages
defaults:
run:
working-directory: nextjs
needs: build-linux
runs-on: ubuntu-latest
env:
BLITZ_TELEMETRY_DISABLED: 1
steps:
- uses: actions/cache@v2
id: restore-build
with:
path: ./*
key: ${{ runner.os }}-${{ github.sha }}
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: "14"
- name: Setup kernel to increase watchers
if: runner.os == 'Linux'
run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
- name: Test Next Packages
run: yarn testonly:packages
env:
CI: true
testBlitzExamples: testBlitzExamples:
timeout-minutes: 30 timeout-minutes: 30
name: Blitz - Test Example Apps (ubuntu-latest) name: Blitz - Test Example Apps (ubuntu-latest)
@@ -378,6 +405,7 @@ jobs:
testIntegrationBlitzWin, testIntegrationBlitzWin,
testUnit, testUnit,
testBlitzPackages, testBlitzPackages,
testNextPackages,
testBlitzExamples, testBlitzExamples,
testBlitzExamplesWin, testBlitzExamplesWin,
] ]

View File

@@ -12,6 +12,7 @@
"dev": "lerna run dev --stream --parallel", "dev": "lerna run dev --stream --parallel",
"dev2": "while true; do yarn --check-files && yarn dev; done", "dev2": "while true; do yarn --check-files && yarn dev; done",
"testonly": "jest --runInBand", "testonly": "jest --runInBand",
"testonly:packages": "ultra -r --filter \"packages/*\" --concurrency 15 test",
"testheadless": "cross-env HEADLESS=true yarn testonly", "testheadless": "cross-env HEADLESS=true yarn testonly",
"testsafari": "cross-env BROWSER_NAME=safari yarn testonly", "testsafari": "cross-env BROWSER_NAME=safari yarn testonly",
"testfirefox": "cross-env BROWSER_NAME=firefox yarn testonly", "testfirefox": "cross-env BROWSER_NAME=firefox yarn testonly",
@@ -145,6 +146,7 @@
"taskr": "1.1.0", "taskr": "1.1.0",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"typescript": "4.5.2", "typescript": "4.5.2",
"ultra-runner": "3.10.5",
"wait-port": "0.2.2", "wait-port": "0.2.2",
"web-streams-polyfill": "2.1.1", "web-streams-polyfill": "2.1.1",
"webpack-bundle-analyzer": "4.3.0", "webpack-bundle-analyzer": "4.3.0",

View File

@@ -0,0 +1,3 @@
module.exports = {
preset: '../../../jest-unit.config.js',
}

View File

@@ -0,0 +1,12 @@
import { Text } from 'ink'
import * as React from 'react'
import { Newline } from './newline'
export const EnterToContinue: React.FC<{ message?: string }> = ({
message = 'Press ENTER to continue',
}) => (
<>
<Newline />
<Text bold>{message}</Text>
</>
)

View File

@@ -0,0 +1,6 @@
import { Box } from 'ink'
import * as React from 'react'
export const Newline: React.FC<{ count?: number }> = ({ count = 1 }) => {
return <Box paddingBottom={count} />
}

View File

@@ -1,14 +1,19 @@
import {spawn} from "cross-spawn" import { spawn } from 'cross-spawn'
import * as fs from "fs-extra" import * as fs from 'fs-extra'
import {Box, Text} from "ink" import { Box, Text } from 'ink'
import Spinner from "ink-spinner" import Spinner from 'ink-spinner'
import * as path from "path" import * as path from 'path'
import * as React from "react" import * as React from 'react'
import {Newline} from "../components/newline" import { Newline } from '../components/newline'
import {RecipeCLIArgs} from "../types" import { RecipeCLIArgs } from '../types'
import {useEnterToContinue} from "../utils/use-enter-to-continue" import { useEnterToContinue } from '../utils/use-enter-to-continue'
import {useUserInput} from "../utils/use-user-input" import { useUserInput } from '../utils/use-user-input'
import {Executor, executorArgument, ExecutorConfig, getExecutorArgument} from "./executor" import {
Executor,
executorArgument,
ExecutorConfig,
getExecutorArgument,
} from './executor'
interface NpmPackage { interface NpmPackage {
name: string name: string
@@ -22,24 +27,26 @@ export interface Config extends ExecutorConfig {
packages: executorArgument<NpmPackage[]> packages: executorArgument<NpmPackage[]>
} }
export function isAddDependencyExecutor(executor: ExecutorConfig): executor is Config { export function isAddDependencyExecutor(
executor: ExecutorConfig
): executor is Config {
return (executor as Config).packages !== undefined return (executor as Config).packages !== undefined
} }
export const type = "add-dependency" export const type = 'add-dependency'
function Package({pkg, loading}: {pkg: NpmPackage; loading: boolean}) { function Package({ pkg, loading }: { pkg: NpmPackage; loading: boolean }) {
return ( return (
<Text> <Text>
{` `} {` `}
{loading ? <Spinner /> : "📦"} {loading ? <Spinner /> : '📦'}
{` ${pkg.name}@${pkg.version}`} {` ${pkg.name}@${pkg.version}`}
</Text> </Text>
) )
} }
const DependencyList = ({ const DependencyList = ({
lede = "Hang tight! Installing dependencies...", lede = 'Hang tight! Installing dependencies...',
depsLoading = false, depsLoading = false,
devDepsLoading = false, devDepsLoading = false,
packages, packages,
@@ -60,7 +67,9 @@ const DependencyList = ({
<Package key={pkg.name} pkg={pkg} loading={depsLoading} /> <Package key={pkg.name} pkg={pkg} loading={depsLoading} />
))} ))}
<Newline /> <Newline />
{devPackages.length ? <Text>Dev Dependencies to be installed:</Text> : null} {devPackages.length ? (
<Text>Dev Dependencies to be installed:</Text>
) : null}
{devPackages.map((pkg) => ( {devPackages.map((pkg) => (
<Package key={pkg.name} pkg={pkg} loading={devDepsLoading} /> <Package key={pkg.name} pkg={pkg} loading={devDepsLoading} />
))} ))}
@@ -72,10 +81,10 @@ const DependencyList = ({
* Exported for unit testing purposes * Exported for unit testing purposes
*/ */
export function getPackageManager() { export function getPackageManager() {
if (fs.existsSync(path.resolve("yarn.lock"))) { if (fs.existsSync(path.resolve('yarn.lock'))) {
return "yarn" return 'yarn'
} }
return "npm" return 'npm'
} }
/** /**
@@ -83,40 +92,46 @@ export function getPackageManager() {
*/ */
export async function installPackages(packages: NpmPackage[], isDev = false) { export async function installPackages(packages: NpmPackage[], isDev = false) {
const packageManager = getPackageManager() const packageManager = getPackageManager()
const isNPM = packageManager === "npm" const isNPM = packageManager === 'npm'
const pkgInstallArg = isNPM ? "install" : "add" const pkgInstallArg = isNPM ? 'install' : 'add'
const args: string[] = [pkgInstallArg] const args: string[] = [pkgInstallArg]
if (isDev) { if (isDev) {
args.push(isNPM ? "--save-dev" : "-D") args.push(isNPM ? '--save-dev' : '-D')
} }
packages.forEach((pkg) => { packages.forEach((pkg) => {
pkg.version ? args.push(`${pkg.name}@${pkg.version}`) : args.push(pkg.name) pkg.version ? args.push(`${pkg.name}@${pkg.version}`) : args.push(pkg.name)
}) })
await new Promise((resolve) => { await new Promise((resolve) => {
const cp = spawn(packageManager, args, { const cp = spawn(packageManager, args, {
stdio: ["inherit", "pipe", "pipe"], stdio: ['inherit', 'pipe', 'pipe'],
}) })
cp.on("exit", resolve) cp.on('exit', resolve)
}) })
} }
export const Commit: Executor["Commit"] = ({cliArgs, cliFlags, step, onChangeCommitted}) => { export const Commit: Executor['Commit'] = ({
cliArgs,
cliFlags,
step,
onChangeCommitted,
}) => {
const userInput = useUserInput(cliFlags) const userInput = useUserInput(cliFlags)
const [depsInstalled, setDepsInstalled] = React.useState(false) const [depsInstalled, setDepsInstalled] = React.useState(false)
const [devDepsInstalled, setDevDepsInstalled] = React.useState(false) const [devDepsInstalled, setDevDepsInstalled] = React.useState(false)
const handleChangeCommitted = React.useCallback(() => { const handleChangeCommitted = React.useCallback(() => {
const packages = (step as Config).packages const packages = (step as Config).packages
const dependencies = packages.length === 1 ? "dependency" : "dependencies" const dependencies = packages.length === 1 ? 'dependency' : 'dependencies'
onChangeCommitted(`Installed ${packages.length} ${dependencies}`) onChangeCommitted(`Installed ${packages.length} ${dependencies}`)
}, [onChangeCommitted, step]) }, [onChangeCommitted, step])
React.useEffect(() => { React.useEffect(() => {
async function installDeps() { async function installDeps() {
const packagesToInstall = getExecutorArgument((step as Config).packages, cliArgs).filter( const packagesToInstall = getExecutorArgument(
(p) => !p.isDevDep, (step as Config).packages,
) cliArgs
).filter((p) => !p.isDevDep)
await installPackages(packagesToInstall) await installPackages(packagesToInstall)
setDepsInstalled(true) setDepsInstalled(true)
} }
@@ -127,9 +142,10 @@ export const Commit: Executor["Commit"] = ({cliArgs, cliFlags, step, onChangeCom
React.useEffect(() => { React.useEffect(() => {
if (!depsInstalled) return if (!depsInstalled) return
async function installDevDeps() { async function installDevDeps() {
const packagesToInstall = getExecutorArgument((step as Config).packages, cliArgs).filter( const packagesToInstall = getExecutorArgument(
(p) => p.isDevDep, (step as Config).packages,
) cliArgs
).filter((p) => p.isDevDep)
await installPackages(packagesToInstall, true) await installPackages(packagesToInstall, true)
setDevDepsInstalled(true) setDevDepsInstalled(true)
} }
@@ -186,7 +202,12 @@ const CommitWithInput = ({
) )
} }
const CommitWithoutInput = ({depsInstalled, devDepsInstalled, step, cliArgs}: CommitChildProps) => ( const CommitWithoutInput = ({
depsInstalled,
devDepsInstalled,
step,
cliArgs,
}: CommitChildProps) => (
<DependencyList <DependencyList
depsLoading={!depsInstalled} depsLoading={!depsInstalled}
devDepsLoading={!devDepsInstalled} devDepsLoading={!devDepsInstalled}

View File

@@ -1,7 +1,7 @@
import {Box, Text} from "ink" import { Box, Text } from 'ink'
import * as React from "react" import * as React from 'react'
import {Newline} from "../components/newline" import { Newline } from '../components/newline'
import {RecipeCLIArgs, RecipeCLIFlags} from "../types" import { RecipeCLIArgs, RecipeCLIFlags } from '../types'
export interface ExecutorConfig { export interface ExecutorConfig {
successIcon?: string successIcon?: string
@@ -32,16 +32,16 @@ export interface Executor {
type dynamicExecutorArgument<T> = (cliArgs: RecipeCLIArgs) => T type dynamicExecutorArgument<T> = (cliArgs: RecipeCLIArgs) => T
function isDynamicExecutorArgument<T>( function isDynamicExecutorArgument<T>(
input: executorArgument<T>, input: executorArgument<T>
): input is dynamicExecutorArgument<T> { ): input is dynamicExecutorArgument<T> {
return typeof (input as dynamicExecutorArgument<T>) === "function" return typeof (input as dynamicExecutorArgument<T>) === 'function'
} }
export type executorArgument<T> = T | dynamicExecutorArgument<T> export type executorArgument<T> = T | dynamicExecutorArgument<T>
export function Frontmatter({executor}: {executor: ExecutorConfig}) { export function Frontmatter({ executor }: { executor: ExecutorConfig }) {
const lineLength = executor.stepName.length + 6 const lineLength = executor.stepName.length + 6
const verticalBorder = `+${new Array(lineLength).fill("").join("")}+` const verticalBorder = `+${new Array(lineLength).fill('').join('')}+`
return ( return (
<Box flexDirection="column" paddingBottom={1}> <Box flexDirection="column" paddingBottom={1}>
<Newline /> <Newline />
@@ -63,7 +63,10 @@ export function Frontmatter({executor}: {executor: ExecutorConfig}) {
) )
} }
export function getExecutorArgument<T>(input: executorArgument<T>, cliArgs: RecipeCLIArgs): T { export function getExecutorArgument<T>(
input: executorArgument<T>,
cliArgs: RecipeCLIArgs
): T {
if (isDynamicExecutorArgument(input)) { if (isDynamicExecutorArgument(input)) {
return input(cliArgs) return input(cliArgs)
} }

View File

@@ -1,5 +1,5 @@
import {prompt as enquirer} from "enquirer" import { prompt as enquirer } from 'enquirer'
import globby from "globby" import globby from 'globby'
enum SearchType { enum SearchType {
file, file,
@@ -13,8 +13,8 @@ interface FilePromptOptions {
context: any context: any
} }
function getMatchingFiles(filter: string = ""): Promise<string[]> { function getMatchingFiles(filter: string = ''): Promise<string[]> {
return globby(filter, {expandDirectories: true}) return globby(filter, { expandDirectories: true })
} }
export async function filePrompt(options: FilePromptOptions): Promise<string> { export async function filePrompt(options: FilePromptOptions): Promise<string> {
@@ -24,10 +24,10 @@ export async function filePrompt(options: FilePromptOptions): Promise<string> {
if (choices.length === 1) { if (choices.length === 1) {
return choices[0] return choices[0]
} }
const results: {file: string} = await enquirer({ const results: { file: string } = await enquirer({
type: "autocomplete", type: 'autocomplete',
name: "file", name: 'file',
message: "Select the target file", message: 'Select the target file',
// @ts-ignore // @ts-ignore
limit: 10, limit: 10,
choices, choices,

View File

@@ -1,10 +1,10 @@
import {createPatch} from "diff" import { createPatch } from 'diff'
import * as fs from "fs-extra" import * as fs from 'fs-extra'
import {Box, Text} from "ink" import { Box, Text } from 'ink'
import Spinner from "ink-spinner" import Spinner from 'ink-spinner'
import * as React from "react" import * as React from 'react'
import {EnterToContinue} from "../components/enter-to-continue" import { EnterToContinue } from '../components/enter-to-continue'
import {RecipeCLIArgs} from "../types" import { RecipeCLIArgs } from '../types'
import { import {
processFile, processFile,
stringProcessFile, stringProcessFile,
@@ -12,11 +12,16 @@ import {
transform, transform,
Transformer, Transformer,
TransformStatus, TransformStatus,
} from "../utils/transform" } from '../utils/transform'
import {useEnterToContinue} from "../utils/use-enter-to-continue" import { useEnterToContinue } from '../utils/use-enter-to-continue'
import {useUserInput} from "../utils/use-user-input" import { useUserInput } from '../utils/use-user-input'
import {Executor, executorArgument, ExecutorConfig, getExecutorArgument} from "./executor" import {
import {filePrompt} from "./file-prompt" Executor,
executorArgument,
ExecutorConfig,
getExecutorArgument,
} from './executor'
import { filePrompt } from './file-prompt'
export interface Config extends ExecutorConfig { export interface Config extends ExecutorConfig {
selectTargetFiles?(cliArgs: RecipeCLIArgs): any[] selectTargetFiles?(cliArgs: RecipeCLIArgs): any[]
@@ -25,19 +30,26 @@ export interface Config extends ExecutorConfig {
transformPlain?: StringTransformer transformPlain?: StringTransformer
} }
export function isFileTransformExecutor(executor: ExecutorConfig): executor is Config { export function isFileTransformExecutor(
executor: ExecutorConfig
): executor is Config {
return ( return (
(executor as Config).transform !== undefined || (executor as Config).transform !== undefined ||
(executor as Config).transformPlain !== undefined (executor as Config).transformPlain !== undefined
) )
} }
export const type = "file-transform" export const type = 'file-transform'
export const Propose: Executor["Propose"] = ({cliArgs, cliFlags, onProposalAccepted, step}) => { export const Propose: Executor['Propose'] = ({
cliArgs,
cliFlags,
onProposalAccepted,
step,
}) => {
const userInput = useUserInput(cliFlags) const userInput = useUserInput(cliFlags)
const [diff, setDiff] = React.useState<string | null>(null) const [diff, setDiff] = React.useState<string | null>(null)
const [error, setError] = React.useState<Error | null>(null) const [error, setError] = React.useState<Error | null>(null)
const [filePath, setFilePath] = React.useState("") const [filePath, setFilePath] = React.useState('')
const [proposalAccepted, setProposalAccepted] = React.useState(false) const [proposalAccepted, setProposalAccepted] = React.useState(false)
const acceptProposal = React.useCallback(() => { const acceptProposal = React.useCallback(() => {
@@ -49,11 +61,14 @@ export const Propose: Executor["Propose"] = ({cliArgs, cliFlags, onProposalAccep
async function generateDiff() { async function generateDiff() {
const fileToTransform: string = await filePrompt({ const fileToTransform: string = await filePrompt({
context: cliArgs, context: cliArgs,
globFilter: getExecutorArgument((step as Config).singleFileSearch, cliArgs), globFilter: getExecutorArgument(
(step as Config).singleFileSearch,
cliArgs
),
getChoices: (step as Config).selectTargetFiles, getChoices: (step as Config).selectTargetFiles,
}) })
setFilePath(fileToTransform) setFilePath(fileToTransform)
const originalFile = fs.readFileSync(fileToTransform).toString("utf-8") const originalFile = fs.readFileSync(fileToTransform).toString('utf-8')
const newFile = await ((step as Config).transformPlain const newFile = await ((step as Config).transformPlain
? stringProcessFile(originalFile, (step as Config).transformPlain!) ? stringProcessFile(originalFile, (step as Config).transformPlain!)
: processFile(originalFile, (step as Config).transform!)) : processFile(originalFile, (step as Config).transform!))
@@ -96,19 +111,19 @@ interface ProposeChildProps {
acceptProposal: () => void acceptProposal: () => void
} }
const Diff = ({diff}: {diff: string}) => ( const Diff = ({ diff }: { diff: string }) => (
<> <>
{diff {diff
.split("\n") .split('\n')
.slice(2) .slice(2)
.map((line, idx) => { .map((line, idx) => {
let styleProps: any = {} let styleProps: any = {}
if (line.startsWith("-") && !line.startsWith("---")) { if (line.startsWith('-') && !line.startsWith('---')) {
styleProps.bold = true styleProps.bold = true
styleProps.color = "red" styleProps.color = 'red'
} else if (line.startsWith("+") && !line.startsWith("+++")) { } else if (line.startsWith('+') && !line.startsWith('+++')) {
styleProps.bold = true styleProps.bold = true
styleProps.color = "green" styleProps.color = 'green'
} }
return ( return (
<Text {...styleProps} key={idx}> <Text {...styleProps} key={idx}>
@@ -125,7 +140,7 @@ const ProposeWithInput = ({
proposalAccepted, proposalAccepted,
acceptProposal, acceptProposal,
}: ProposeChildProps) => { }: ProposeChildProps) => {
useEnterToContinue(acceptProposal, filePath !== "" && !proposalAccepted) useEnterToContinue(acceptProposal, filePath !== '' && !proposalAccepted)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
@@ -142,7 +157,7 @@ const ProposeWithoutInput = ({
acceptProposal, acceptProposal,
}: ProposeChildProps) => { }: ProposeChildProps) => {
React.useEffect(() => { React.useEffect(() => {
if (filePath !== "" && !proposalAccepted) { if (filePath !== '' && !proposalAccepted) {
acceptProposal() acceptProposal()
} }
}, [acceptProposal, filePath, proposalAccepted]) }, [acceptProposal, filePath, proposalAccepted])
@@ -154,7 +169,11 @@ const ProposeWithoutInput = ({
) )
} }
export const Commit: Executor["Commit"] = ({onChangeCommitted, proposalData: filePath, step}) => { export const Commit: Executor['Commit'] = ({
onChangeCommitted,
proposalData: filePath,
step,
}) => {
React.useEffect(() => { React.useEffect(() => {
void (async function () { void (async function () {
const results = await transform( const results = await transform(
@@ -162,7 +181,7 @@ export const Commit: Executor["Commit"] = ({onChangeCommitted, proposalData: fil
await ((step as Config).transformPlain await ((step as Config).transformPlain
? stringProcessFile(original, (step as Config).transformPlain!) ? stringProcessFile(original, (step as Config).transformPlain!)
: processFile(original, (step as Config).transform!)), : processFile(original, (step as Config).transform!)),
[filePath], [filePath]
) )
if (results.some((r) => r.status === TransformStatus.Failure)) { if (results.some((r) => r.status === TransformStatus.Failure)) {
console.error(results) console.error(results)

View File

@@ -1,24 +1,31 @@
import {Generator, GeneratorOptions, SourceRootType} from "@blitzjs/generator" import { Generator, GeneratorOptions, SourceRootType } from '@blitzjs/generator'
import {Box, Text} from "ink" import { Box, Text } from 'ink'
import {useEffect, useState} from "react" import { useEffect, useState } from 'react'
import * as React from "react" import * as React from 'react'
import {EnterToContinue} from "../components/enter-to-continue" import { EnterToContinue } from '../components/enter-to-continue'
import {useEnterToContinue} from "../utils/use-enter-to-continue" import { useEnterToContinue } from '../utils/use-enter-to-continue'
import {useUserInput} from "../utils/use-user-input" import { useUserInput } from '../utils/use-user-input'
import {Executor, executorArgument, ExecutorConfig, getExecutorArgument} from "./executor" import {
Executor,
executorArgument,
ExecutorConfig,
getExecutorArgument,
} from './executor'
export interface Config extends ExecutorConfig { export interface Config extends ExecutorConfig {
targetDirectory?: executorArgument<string> targetDirectory?: executorArgument<string>
templatePath: executorArgument<string> templatePath: executorArgument<string>
templateValues: executorArgument<{[key: string]: string}> templateValues: executorArgument<{ [key: string]: string }>
destinationPathPrompt?: executorArgument<string> destinationPathPrompt?: executorArgument<string>
} }
export function isNewFileExecutor(executor: ExecutorConfig): executor is Config { export function isNewFileExecutor(
executor: ExecutorConfig
): executor is Config {
return (executor as Config).templatePath !== undefined return (executor as Config).templatePath !== undefined
} }
export const type = "new-file" export const type = 'new-file'
interface TempGeneratorOptions extends GeneratorOptions { interface TempGeneratorOptions extends GeneratorOptions {
targetDirectory?: string targetDirectory?: string
@@ -34,9 +41,9 @@ class TempGenerator extends Generator<TempGeneratorOptions> {
constructor(options: TempGeneratorOptions) { constructor(options: TempGeneratorOptions) {
super(options) super(options)
this.sourceRoot = {type: "absolute", path: options.templateRoot} this.sourceRoot = { type: 'absolute', path: options.templateRoot }
this.templateValues = options.templateValues this.templateValues = options.templateValues
this.targetDirectory = options.targetDirectory || "." this.targetDirectory = options.targetDirectory || '.'
} }
getTemplateValues() { getTemplateValues() {
@@ -48,26 +55,37 @@ class TempGenerator extends Generator<TempGeneratorOptions> {
} }
} }
export const Commit: Executor["Commit"] = ({cliArgs, cliFlags, onChangeCommitted, step}) => { export const Commit: Executor['Commit'] = ({
cliArgs,
cliFlags,
onChangeCommitted,
step,
}) => {
const userInput = useUserInput(cliFlags) const userInput = useUserInput(cliFlags)
const generatorArgs = React.useMemo( const generatorArgs = React.useMemo(
() => ({ () => ({
destinationRoot: ".", destinationRoot: '.',
targetDirectory: getExecutorArgument((step as Config).targetDirectory, cliArgs), targetDirectory: getExecutorArgument(
(step as Config).targetDirectory,
cliArgs
),
templateRoot: getExecutorArgument((step as Config).templatePath, cliArgs), templateRoot: getExecutorArgument((step as Config).templatePath, cliArgs),
templateValues: getExecutorArgument((step as Config).templateValues, cliArgs), templateValues: getExecutorArgument(
(step as Config).templateValues,
cliArgs
),
}), }),
[cliArgs, step], [cliArgs, step]
) )
const [fileCreateOutput, setFileCreateOutput] = useState("") const [fileCreateOutput, setFileCreateOutput] = useState('')
const [changeCommited, setChangeCommited] = useState(false) const [changeCommited, setChangeCommited] = useState(false)
const fileCreateLines = fileCreateOutput.split("\n") const fileCreateLines = fileCreateOutput.split('\n')
const handleChangeCommitted = React.useCallback(() => { const handleChangeCommitted = React.useCallback(() => {
setChangeCommited(true) setChangeCommited(true)
onChangeCommitted( onChangeCommitted(
`Successfully created ${fileCreateLines `Successfully created ${fileCreateLines
.map((l) => l.split(" ").slice(1).join("").trim()) .map((l) => l.split(' ').slice(1).join('').trim())
.join(", ")}`, .join(', ')}`
) )
}, [fileCreateLines, onChangeCommitted]) }, [fileCreateLines, onChangeCommitted])
@@ -104,11 +122,14 @@ const CommitWithInput = ({
fileCreateOutput, fileCreateOutput,
handleChangeCommitted, handleChangeCommitted,
}: CommitChildProps) => { }: CommitChildProps) => {
useEnterToContinue(handleChangeCommitted, !changeCommited && fileCreateOutput !== "") useEnterToContinue(
handleChangeCommitted,
!changeCommited && fileCreateOutput !== ''
)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{fileCreateOutput !== "" && ( {fileCreateOutput !== '' && (
<> <>
<Text>{fileCreateOutput}</Text> <Text>{fileCreateOutput}</Text>
<EnterToContinue /> <EnterToContinue />
@@ -124,12 +145,14 @@ const CommitWithoutInput = ({
handleChangeCommitted, handleChangeCommitted,
}: CommitChildProps) => { }: CommitChildProps) => {
React.useEffect(() => { React.useEffect(() => {
if (!changeCommited && fileCreateOutput !== "") { if (!changeCommited && fileCreateOutput !== '') {
handleChangeCommitted() handleChangeCommitted()
} }
}, [changeCommited, fileCreateOutput, handleChangeCommitted]) }, [changeCommited, fileCreateOutput, handleChangeCommitted])
return ( return (
<Box flexDirection="column">{fileCreateOutput !== "" && <Text>{fileCreateOutput}</Text>}</Box> <Box flexDirection="column">
{fileCreateOutput !== '' && <Text>{fileCreateOutput}</Text>}
</Box>
) )
} }

View File

@@ -1,24 +1,34 @@
import {Box, Text} from "ink" import { Box, Text } from 'ink'
import * as React from "react" import * as React from 'react'
import {EnterToContinue} from "../components/enter-to-continue" import { EnterToContinue } from '../components/enter-to-continue'
import {useEnterToContinue} from "../utils/use-enter-to-continue" import { useEnterToContinue } from '../utils/use-enter-to-continue'
import {useUserInput} from "../utils/use-user-input" import { useUserInput } from '../utils/use-user-input'
import {Executor, executorArgument, ExecutorConfig, getExecutorArgument} from "./executor" import {
Executor,
executorArgument,
ExecutorConfig,
getExecutorArgument,
} from './executor'
export interface Config extends ExecutorConfig { export interface Config extends ExecutorConfig {
message: executorArgument<string> message: executorArgument<string>
} }
export const type = "print-message" export const type = 'print-message'
export const Commit: Executor["Commit"] = ({cliArgs, cliFlags, onChangeCommitted, step}) => { export const Commit: Executor['Commit'] = ({
cliArgs,
cliFlags,
onChangeCommitted,
step,
}) => {
const userInput = useUserInput(cliFlags) const userInput = useUserInput(cliFlags)
const generatorArgs = React.useMemo( const generatorArgs = React.useMemo(
() => ({ () => ({
message: getExecutorArgument((step as Config).message, cliArgs), message: getExecutorArgument((step as Config).message, cliArgs),
stepName: getExecutorArgument((step as Config).stepName, cliArgs), stepName: getExecutorArgument((step as Config).stepName, cliArgs),
}), }),
[cliArgs, step], [cliArgs, step]
) )
const [changeCommited, setChangeCommited] = React.useState(false) const [changeCommited, setChangeCommited] = React.useState(false)
@@ -39,7 +49,7 @@ export const Commit: Executor["Commit"] = ({cliArgs, cliFlags, onChangeCommitted
interface CommitChildProps { interface CommitChildProps {
changeCommited: boolean changeCommited: boolean
generatorArgs: {message: string; stepName: string} generatorArgs: { message: string; stepName: string }
handleChangeCommitted: () => void handleChangeCommitted: () => void
} }

View File

@@ -0,0 +1,12 @@
export * from './recipe-executor'
export * from './recipe-builder'
export * from './executors/executor'
export { type as AddDependencyType } from './executors/add-dependency-executor'
export { type as FileTransformType } from './executors/file-transform-executor'
export { type as NewFileType } from './executors/new-file-executor'
export { type as PrintMessageType } from './executors/print-message-executor'
export * from './utils/paths'
export * from './transforms'
export { customTsParser } from './utils/transform'
export type { Program, RecipeCLIArgs, RecipeCLIFlags } from './types'

View File

@@ -1,21 +1,27 @@
import * as AddDependencyExecutor from "./executors/add-dependency-executor" import * as AddDependencyExecutor from './executors/add-dependency-executor'
import * as TransformFileExecutor from "./executors/file-transform-executor" import * as TransformFileExecutor from './executors/file-transform-executor'
import * as NewFileExecutor from "./executors/new-file-executor" import * as NewFileExecutor from './executors/new-file-executor'
import * as PrintMessageExecutor from "./executors/print-message-executor" import * as PrintMessageExecutor from './executors/print-message-executor'
import {ExecutorConfigUnion, RecipeExecutor} from "./recipe-executor" import { ExecutorConfigUnion, RecipeExecutor } from './recipe-executor'
import {RecipeMeta} from "./types" import { RecipeMeta } from './types'
export interface IRecipeBuilder { export interface IRecipeBuilder {
setName(name: string): IRecipeBuilder setName(name: string): IRecipeBuilder
setDescription(description: string): IRecipeBuilder setDescription(description: string): IRecipeBuilder
printMessage( printMessage(
step: Omit<Omit<PrintMessageExecutor.Config, "stepType">, "explanation">, step: Omit<Omit<PrintMessageExecutor.Config, 'stepType'>, 'explanation'>
): IRecipeBuilder ): IRecipeBuilder
setOwner(owner: string): IRecipeBuilder setOwner(owner: string): IRecipeBuilder
setRepoLink(repoLink: string): IRecipeBuilder setRepoLink(repoLink: string): IRecipeBuilder
addAddDependenciesStep(step: Omit<AddDependencyExecutor.Config, "stepType">): IRecipeBuilder addAddDependenciesStep(
addNewFilesStep(step: Omit<NewFileExecutor.Config, "stepType">): IRecipeBuilder step: Omit<AddDependencyExecutor.Config, 'stepType'>
addTransformFilesStep(step: Omit<TransformFileExecutor.Config, "stepType">): IRecipeBuilder ): IRecipeBuilder
addNewFilesStep(
step: Omit<NewFileExecutor.Config, 'stepType'>
): IRecipeBuilder
addTransformFilesStep(
step: Omit<TransformFileExecutor.Config, 'stepType'>
): IRecipeBuilder
build(): RecipeExecutor<any> build(): RecipeExecutor<any>
} }
@@ -32,7 +38,7 @@ export function RecipeBuilder(): IRecipeBuilder {
meta.description = description meta.description = description
return this return this
}, },
printMessage(step: Omit<PrintMessageExecutor.Config, "stepType">) { printMessage(step: Omit<PrintMessageExecutor.Config, 'stepType'>) {
steps.push({ steps.push({
stepType: PrintMessageExecutor.type, stepType: PrintMessageExecutor.type,
...step, ...step,
@@ -47,21 +53,25 @@ export function RecipeBuilder(): IRecipeBuilder {
meta.repoLink = repoLink meta.repoLink = repoLink
return this return this
}, },
addAddDependenciesStep(step: Omit<AddDependencyExecutor.Config, "stepType">) { addAddDependenciesStep(
step: Omit<AddDependencyExecutor.Config, 'stepType'>
) {
steps.push({ steps.push({
stepType: AddDependencyExecutor.type, stepType: AddDependencyExecutor.type,
...step, ...step,
}) })
return this return this
}, },
addNewFilesStep(step: Omit<NewFileExecutor.Config, "stepType">) { addNewFilesStep(step: Omit<NewFileExecutor.Config, 'stepType'>) {
steps.push({ steps.push({
stepType: NewFileExecutor.type, stepType: NewFileExecutor.type,
...step, ...step,
}) })
return this return this
}, },
addTransformFilesStep(step: Omit<TransformFileExecutor.Config, "stepType">) { addTransformFilesStep(
step: Omit<TransformFileExecutor.Config, 'stepType'>
) {
steps.push({ steps.push({
stepType: TransformFileExecutor.type, stepType: TransformFileExecutor.type,
...step, ...step,

View File

@@ -0,0 +1,52 @@
import { render } from 'ink'
import { baseLogger } from 'next/dist/server/lib/logging'
import React from 'react'
import * as AddDependencyExecutor from './executors/add-dependency-executor'
import * as FileTransformExecutor from './executors/file-transform-executor'
import * as NewFileExecutor from './executors/new-file-executor'
import * as PrintMessageExecutor from './executors/print-message-executor'
import { RecipeRenderer } from './recipe-renderer'
import { RecipeCLIArgs, RecipeCLIFlags, RecipeMeta } from './types'
// const debug = require('debug')("blitz:installer")
type ExecutorConfig =
| AddDependencyExecutor.Config
| FileTransformExecutor.Config
| NewFileExecutor.Config
| PrintMessageExecutor.Config
export type { ExecutorConfig as ExecutorConfigUnion }
export class RecipeExecutor<Options extends RecipeMeta> {
private readonly steps: ExecutorConfig[]
private readonly options: Options
constructor(options: Options, steps: ExecutorConfig[]) {
this.options = options
this.steps = steps
}
async run(
cliArgs: RecipeCLIArgs = {},
cliFlags: RecipeCLIFlags = { yesToAll: false }
): Promise<void> {
try {
const { waitUntilExit } = render(
<RecipeRenderer
cliArgs={cliArgs}
cliFlags={cliFlags}
steps={this.steps}
recipeMeta={this.options}
/>,
{ exitOnCtrlC: false }
)
await waitUntilExit()
baseLogger({ displayDateTime: false, displayLogLevel: false }).info(
`\n🎉 The ${this.options.name} recipe has been installed!\n`
)
} catch (e) {
baseLogger({ displayDateTime: false }).error(e as any)
return
}
}
}

View File

@@ -1,15 +1,15 @@
import {Box, Text, useApp, useInput} from "ink" import { Box, Text, useApp, useInput } from 'ink'
import React from "react" import React from 'react'
import {EnterToContinue} from "./components/enter-to-continue" import { EnterToContinue } from './components/enter-to-continue'
import {Newline} from "./components/newline" import { Newline } from './components/newline'
import * as AddDependencyExecutor from "./executors/add-dependency-executor" import * as AddDependencyExecutor from './executors/add-dependency-executor'
import {Executor, ExecutorConfig, Frontmatter} from "./executors/executor" import { Executor, ExecutorConfig, Frontmatter } from './executors/executor'
import * as FileTransformExecutor from "./executors/file-transform-executor" import * as FileTransformExecutor from './executors/file-transform-executor'
import * as NewFileExecutor from "./executors/new-file-executor" import * as NewFileExecutor from './executors/new-file-executor'
import * as PrintMessageExecutor from "./executors/print-message-executor" import * as PrintMessageExecutor from './executors/print-message-executor'
import {RecipeCLIArgs, RecipeCLIFlags, RecipeMeta} from "./types" import { RecipeCLIArgs, RecipeCLIFlags, RecipeMeta } from './types'
import {useEnterToContinue} from "./utils/use-enter-to-continue" import { useEnterToContinue } from './utils/use-enter-to-continue'
import {useUserInput} from "./utils/use-user-input" import { useUserInput } from './utils/use-user-input'
enum Action { enum Action {
SkipStep, SkipStep,
@@ -27,7 +27,7 @@ enum Status {
Committed, Committed,
} }
const ExecutorMap: {[key: string]: Executor} = { const ExecutorMap: { [key: string]: Executor } = {
[AddDependencyExecutor.type]: AddDependencyExecutor, [AddDependencyExecutor.type]: AddDependencyExecutor,
[NewFileExecutor.type]: NewFileExecutor, [NewFileExecutor.type]: NewFileExecutor,
[PrintMessageExecutor.type]: PrintMessageExecutor, [PrintMessageExecutor.type]: PrintMessageExecutor,
@@ -35,12 +35,17 @@ const ExecutorMap: {[key: string]: Executor} = {
} as const } as const
interface State { interface State {
steps: {executor: ExecutorConfig; status: Status; proposalData?: any; successMsg: string}[] steps: {
executor: ExecutorConfig
status: Status
proposalData?: any
successMsg: string
}[]
current: number current: number
} }
function recipeReducer(state: State, action: {type: Action; data?: any}) { function recipeReducer(state: State, action: { type: Action; data?: any }) {
const newState = {...state} const newState = { ...state }
switch (action.type) { switch (action.type) {
case Action.ProposeChange: case Action.ProposeChange:
newState.steps[newState.current].status = Status.Proposed newState.steps[newState.current].status = Status.Proposed
@@ -55,7 +60,10 @@ function recipeReducer(state: State, action: {type: Action; data?: any}) {
case Action.CompleteChange: case Action.CompleteChange:
newState.steps[newState.current].status = Status.Committed newState.steps[newState.current].status = Status.Committed
newState.steps[newState.current].successMsg = action.data as string newState.steps[newState.current].successMsg = action.data as string
newState.current = Math.min(newState.current + 1, newState.steps.length - 1) newState.current = Math.min(
newState.current + 1,
newState.steps.length - 1
)
break break
case Action.SkipStep: case Action.SkipStep:
newState.current += 1 newState.current += 1
@@ -71,7 +79,9 @@ interface RecipeProps {
recipeMeta: RecipeMeta recipeMeta: RecipeMeta
} }
const DispatchContext = React.createContext<React.Dispatch<{type: Action; data?: any}>>(() => {}) const DispatchContext = React.createContext<
React.Dispatch<{ type: Action; data?: any }>
>(() => {})
function WelcomeMessage({ function WelcomeMessage({
recipeMeta, recipeMeta,
@@ -101,16 +111,19 @@ function WelcomeMessage({
) )
} }
function StepMessages({state}: {state: State}) { function StepMessages({ state }: { state: State }) {
const messages = state.steps const messages = state.steps
.map((step) => ({msg: step.successMsg, icon: step.executor.successIcon ?? "✅"})) .map((step) => ({
msg: step.successMsg,
icon: step.executor.successIcon ?? '✅',
}))
.filter((s) => s.msg) .filter((s) => s.msg)
return ( return (
<> <>
{messages.map(({msg, icon}, index) => ( {messages.map(({ msg, icon }, index) => (
<Text key={msg + index} color="green"> <Text key={msg + index} color="green">
{msg === "\n" ? "" : icon} {msg} {msg === '\n' ? '' : icon} {msg}
</Text> </Text>
))} ))}
</> </>
@@ -130,30 +143,30 @@ function StepExecutor({
cliFlags: RecipeCLIFlags cliFlags: RecipeCLIFlags
proposalData?: any proposalData?: any
}) { }) {
const {Propose, Commit}: Executor = ExecutorMap[step.stepType] const { Propose, Commit }: Executor = ExecutorMap[step.stepType]
const dispatch = React.useContext(DispatchContext) const dispatch = React.useContext(DispatchContext)
const handleProposalAccepted = React.useCallback( const handleProposalAccepted = React.useCallback(
(msg) => { (msg) => {
dispatch({type: Action.CommitApproved, data: msg}) dispatch({ type: Action.CommitApproved, data: msg })
}, },
[dispatch], [dispatch]
) )
const handleChangeCommitted = React.useCallback( const handleChangeCommitted = React.useCallback(
(msg) => { (msg) => {
dispatch({type: Action.CompleteChange, data: msg}) dispatch({ type: Action.CompleteChange, data: msg })
}, },
[dispatch], [dispatch]
) )
React.useEffect(() => { React.useEffect(() => {
if (status === Status.Pending) { if (status === Status.Pending) {
dispatch({type: Action.ProposeChange}) dispatch({ type: Action.ProposeChange })
} else if (status === Status.ReadyToCommit) { } else if (status === Status.ReadyToCommit) {
dispatch({type: Action.ApplyChange}) dispatch({ type: Action.ApplyChange })
} }
if (status === Status.Proposed && !Propose) { if (status === Status.Proposed && !Propose) {
dispatch({type: Action.CommitApproved}) dispatch({ type: Action.CommitApproved })
} }
}, [dispatch, status, Propose]) }, [dispatch, status, Propose])
@@ -181,16 +194,25 @@ function StepExecutor({
) )
} }
export function RecipeRenderer({cliArgs, cliFlags, steps, recipeMeta}: RecipeProps) { export function RecipeRenderer({
cliArgs,
cliFlags,
steps,
recipeMeta,
}: RecipeProps) {
const userInput = useUserInput(cliFlags) const userInput = useUserInput(cliFlags)
const {exit} = useApp() const { exit } = useApp()
const [state, dispatch] = React.useReducer(recipeReducer, { const [state, dispatch] = React.useReducer(recipeReducer, {
current: userInput ? -1 : 0, current: userInput ? -1 : 0,
steps: steps.map((e) => ({executor: e, status: Status.Pending, successMsg: ""})), steps: steps.map((e) => ({
executor: e,
status: Status.Pending,
successMsg: '',
})),
}) })
if (steps.length === 0) { if (steps.length === 0) {
exit(new Error("This recipe has no steps")) exit(new Error('This recipe has no steps'))
} }
React.useEffect(() => { React.useEffect(() => {
@@ -228,18 +250,21 @@ function RecipeRendererWithInput({
cliFlags, cliFlags,
recipeMeta, recipeMeta,
state, state,
}: Omit<RecipeProps, "steps"> & {state: State}) { }: Omit<RecipeProps, 'steps'> & { state: State }) {
const {exit} = useApp() const { exit } = useApp()
const dispatch = React.useContext(DispatchContext) const dispatch = React.useContext(DispatchContext)
useInput((input, key) => { useInput((input, key) => {
if (input === "c" && key.ctrl) { if (input === 'c' && key.ctrl) {
exit(new Error("You aborted installation")) exit(new Error('You aborted installation'))
return return
} }
}) })
useEnterToContinue(() => dispatch({type: Action.SkipStep}), state.current === -1) useEnterToContinue(
() => dispatch({ type: Action.SkipStep }),
state.current === -1
)
return ( return (
<> <>
@@ -264,7 +289,7 @@ function RecipeRendererWithoutInput({
cliFlags, cliFlags,
recipeMeta, recipeMeta,
state, state,
}: Omit<RecipeProps, "steps"> & {state: State}) { }: Omit<RecipeProps, 'steps'> & { state: State }) {
return ( return (
<> <>
<WelcomeMessage recipeMeta={recipeMeta} enterToContinue={false} /> <WelcomeMessage recipeMeta={recipeMeta} enterToContinue={false} />

View File

@@ -0,0 +1,35 @@
import type { ExpressionKind } from 'ast-types/gen/kinds'
import j from 'jscodeshift'
import { Program } from '../types'
import { transformBlitzConfig } from '.'
export const addBlitzMiddleware = (
program: Program,
middleware: ExpressionKind
): Program =>
transformBlitzConfig(program, (config) => {
// Locate the middleware property
const middlewareProp = config.properties.find(
(value) =>
value.type === 'ObjectProperty' &&
value.key.type === 'Identifier' &&
value.key.name === 'middleware'
) as j.ObjectProperty | undefined
if (middlewareProp && middlewareProp.value.type === 'ArrayExpression') {
// We found it, pop on our middleware.
middlewareProp.value.elements.push(middleware)
} else {
// No middleware prop, add our own.
config.properties.push(
j.property('init', j.identifier('middleware'), {
type: 'ArrayExpression',
elements: [middleware],
loc: null,
comments: null,
})
)
}
return config
})

View File

@@ -1,7 +1,10 @@
import j from "jscodeshift" import j from 'jscodeshift'
import {Program} from "../types" import { Program } from '../types'
export function addImport(program: Program, importToAdd: j.ImportDeclaration): Program { export function addImport(
program: Program,
importToAdd: j.ImportDeclaration
): Program {
const importStatementCount = program.find(j.ImportDeclaration).length const importStatementCount = program.find(j.ImportDeclaration).length
if (importStatementCount === 0) { if (importStatementCount === 0) {
program.find(j.Statement).at(0).insertBefore(importToAdd) program.find(j.Statement).at(0).insertBefore(importToAdd)

View File

@@ -0,0 +1,14 @@
import j from 'jscodeshift'
import { Program } from '../types'
export const findModuleExportsExpressions = (program: Program) =>
program.find(j.AssignmentExpression).filter((path) => {
const { left, right } = path.value
return (
left.type === 'MemberExpression' &&
left.object.type === 'Identifier' &&
left.property.type === 'Identifier' &&
left.property.name === 'exports' &&
right.type === 'ObjectExpression'
)
})

View File

@@ -0,0 +1,7 @@
export * from './add-import'
export * from './add-blitz-middleware'
export * from './find-module-exports-expressions'
export * from './prisma'
export * from './transform-blitz-config'
export * from './update-babel-config'
export * from './wrap-blitz-config'

View File

@@ -1,5 +1,5 @@
import {Enum} from "@mrleebo/prisma-ast" import { Enum } from '@mrleebo/prisma-ast'
import {produceSchema} from "./produce-schema" import { produceSchema } from './produce-schema'
/** /**
* Adds an enum to your schema.prisma data model. * Adds an enum to your schema.prisma data model.
@@ -19,9 +19,14 @@ import {produceSchema} from "./produce-schema"
}) })
* ``` * ```
*/ */
export function addPrismaEnum(source: string, enumProps: Enum): Promise<string> { export function addPrismaEnum(
source: string,
enumProps: Enum
): Promise<string> {
return produceSchema(source, (schema) => { return produceSchema(source, (schema) => {
const existing = schema.list.find((x) => x.type === "enum" && x.name === enumProps.name) const existing = schema.list.find(
(x) => x.type === 'enum' && x.name === enumProps.name
)
existing ? Object.assign(existing, enumProps) : schema.list.push(enumProps) existing ? Object.assign(existing, enumProps) : schema.list.push(enumProps)
}) })
} }

View File

@@ -1,5 +1,5 @@
import {Field, Model} from "@mrleebo/prisma-ast" import { Field, Model } from '@mrleebo/prisma-ast'
import {produceSchema} from "./produce-schema" import { produceSchema } from './produce-schema'
/** /**
* Adds a field to a model in your schema.prisma data model. * Adds a field to a model in your schema.prisma data model.
@@ -22,13 +22,19 @@ import {produceSchema} from "./produce-schema"
export function addPrismaField( export function addPrismaField(
source: string, source: string,
modelName: string, modelName: string,
fieldProps: Field, fieldProps: Field
): Promise<string> { ): Promise<string> {
return produceSchema(source, (schema) => { return produceSchema(source, (schema) => {
const model = schema.list.find((x) => x.type === "model" && x.name === modelName) as Model const model = schema.list.find(
(x) => x.type === 'model' && x.name === modelName
) as Model
if (!model) return if (!model) return
const existing = model.properties.find((x) => x.type === "field" && x.name === fieldProps.name) const existing = model.properties.find(
existing ? Object.assign(existing, fieldProps) : model.properties.push(fieldProps) (x) => x.type === 'field' && x.name === fieldProps.name
)
existing
? Object.assign(existing, fieldProps)
: model.properties.push(fieldProps)
}) })
} }

View File

@@ -1,5 +1,5 @@
import {Generator} from "@mrleebo/prisma-ast" import { Generator } from '@mrleebo/prisma-ast'
import {produceSchema} from "./produce-schema" import { produceSchema } from './produce-schema'
/** /**
* Adds a generator to your schema.prisma data model. * Adds a generator to your schema.prisma data model.
@@ -16,11 +16,16 @@ import {produceSchema} from "./produce-schema"
}) })
* ``` * ```
*/ */
export function addPrismaGenerator(source: string, generatorProps: Generator): Promise<string> { export function addPrismaGenerator(
source: string,
generatorProps: Generator
): Promise<string> {
return produceSchema(source, (schema) => { return produceSchema(source, (schema) => {
const existing = schema.list.find( const existing = schema.list.find(
(x) => x.type === "generator" && x.name === generatorProps.name, (x) => x.type === 'generator' && x.name === generatorProps.name
) as Generator ) as Generator
existing ? Object.assign(existing, generatorProps) : schema.list.push(generatorProps) existing
? Object.assign(existing, generatorProps)
: schema.list.push(generatorProps)
}) })
} }

View File

@@ -1,5 +1,5 @@
import {Model, ModelAttribute} from "@mrleebo/prisma-ast" import { Model, ModelAttribute } from '@mrleebo/prisma-ast'
import {produceSchema} from "./produce-schema" import { produceSchema } from './produce-schema'
/** /**
* Adds a field to a model in your schema.prisma data model. * Adds a field to a model in your schema.prisma data model.
@@ -22,16 +22,20 @@ import {produceSchema} from "./produce-schema"
export function addPrismaModelAttribute( export function addPrismaModelAttribute(
source: string, source: string,
modelName: string, modelName: string,
attributeProps: ModelAttribute, attributeProps: ModelAttribute
): Promise<string> { ): Promise<string> {
return produceSchema(source, (schema) => { return produceSchema(source, (schema) => {
const model = schema.list.find((x) => x.type === "model" && x.name === modelName) as Model const model = schema.list.find(
(x) => x.type === 'model' && x.name === modelName
) as Model
if (!model) return if (!model) return
const existing = model.properties.find( const existing = model.properties.find(
(x) => x.type === "attribute" && x.name === attributeProps.name, (x) => x.type === 'attribute' && x.name === attributeProps.name
) )
existing ? Object.assign(existing, attributeProps) : model.properties.push(attributeProps) existing
? Object.assign(existing, attributeProps)
: model.properties.push(attributeProps)
}) })
} }

View File

@@ -1,5 +1,5 @@
import {Model} from "@mrleebo/prisma-ast" import { Model } from '@mrleebo/prisma-ast'
import {produceSchema} from "./produce-schema" import { produceSchema } from './produce-schema'
/** /**
* Adds an enum to your schema.prisma data model. * Adds an enum to your schema.prisma data model.
@@ -16,9 +16,16 @@ import {produceSchema} from "./produce-schema"
}) })
* ``` * ```
*/ */
export function addPrismaModel(source: string, modelProps: Model): Promise<string> { export function addPrismaModel(
source: string,
modelProps: Model
): Promise<string> {
return produceSchema(source, (schema) => { return produceSchema(source, (schema) => {
const existing = schema.list.find((x) => x.type === "model" && x.name === modelProps.name) const existing = schema.list.find(
existing ? Object.assign(existing, modelProps) : schema.list.push(modelProps) (x) => x.type === 'model' && x.name === modelProps.name
)
existing
? Object.assign(existing, modelProps)
: schema.list.push(modelProps)
}) })
} }

View File

@@ -0,0 +1,7 @@
export * from './add-prisma-enum'
export * from './add-prisma-field'
export * from './add-prisma-generator'
export * from './add-prisma-model-attribute'
export * from './add-prisma-model'
export * from './produce-schema'
export * from './set-prisma-data-source'

View File

@@ -1,4 +1,4 @@
import {printSchema as printer, Schema} from "@mrleebo/prisma-ast" import { printSchema as printer, Schema } from '@mrleebo/prisma-ast'
/** /**
* Takes the schema.prisma document parsed from @mrleebo/prisma-ast and * Takes the schema.prisma document parsed from @mrleebo/prisma-ast and

View File

@@ -1,4 +1,4 @@
import {getSchema, printSchema, Schema} from "@mrleebo/prisma-ast" import { getSchema, printSchema, Schema } from '@mrleebo/prisma-ast'
/** /**
* A file transformer that parses a schema.prisma string, offers you a callback * A file transformer that parses a schema.prisma string, offers you a callback
@@ -11,7 +11,7 @@ import {getSchema, printSchema, Schema} from "@mrleebo/prisma-ast"
*/ */
export async function produceSchema( export async function produceSchema(
source: string, source: string,
producer: (schema: Schema) => void, producer: (schema: Schema) => void
): Promise<string> { ): Promise<string> {
const schema = await getSchema(source) const schema = await getSchema(source)
producer(schema) producer(schema)

View File

@@ -1,5 +1,5 @@
import {Datasource} from "@mrleebo/prisma-ast" import { Datasource } from '@mrleebo/prisma-ast'
import {produceSchema} from "./produce-schema" import { produceSchema } from './produce-schema'
/** /**
* Modify the prisma datasource metadata to use the provider and url specified. * Modify the prisma datasource metadata to use the provider and url specified.
@@ -23,9 +23,14 @@ import {produceSchema} from "./produce-schema"
}) })
* ``` * ```
*/ */
export function setPrismaDataSource(source: string, datasourceProps: Datasource): Promise<string> { export function setPrismaDataSource(
source: string,
datasourceProps: Datasource
): Promise<string> {
return produceSchema(source, (schema) => { return produceSchema(source, (schema) => {
const existing = schema.list.find((x) => x.type === "datasource") const existing = schema.list.find((x) => x.type === 'datasource')
existing ? Object.assign(existing, datasourceProps) : schema.list.push(datasourceProps) existing
? Object.assign(existing, datasourceProps)
: schema.list.push(datasourceProps)
}) })
} }

View File

@@ -1,25 +1,27 @@
import type {ExpressionKind} from "ast-types/gen/kinds" import type { ExpressionKind } from 'ast-types/gen/kinds'
import j from "jscodeshift" import j from 'jscodeshift'
import {Program} from "../types" import { Program } from '../types'
function recursiveConfigSearch( function recursiveConfigSearch(
program: Program, program: Program,
obj: ExpressionKind, obj: ExpressionKind
): j.ObjectExpression | undefined { ): j.ObjectExpression | undefined {
// Identifier being a variable name // Identifier being a variable name
if (obj.type === "Identifier") { if (obj.type === 'Identifier') {
const {node} = j(obj).get() const { node } = j(obj).get()
// Get the definition of the variable // Get the definition of the variable
const identifier: j.ASTPath<j.VariableDeclarator> = program const identifier: j.ASTPath<j.VariableDeclarator> = program
.find(j.VariableDeclarator, { .find(j.VariableDeclarator, {
id: {name: node.name}, id: { name: node.name },
}) })
.get() .get()
// Return what is after the `=` // Return what is after the `=`
return identifier.value.init ? recursiveConfigSearch(program, identifier.value.init) : undefined return identifier.value.init
} else if (obj.type === "CallExpression") { ? recursiveConfigSearch(program, identifier.value.init)
: undefined
} else if (obj.type === 'CallExpression') {
// If it's an function call (like `withBundleAnalyzer`), get the first argument // If it's an function call (like `withBundleAnalyzer`), get the first argument
if (obj.arguments.length === 0) { if (obj.arguments.length === 0) {
// If it has no arguments, create an empty object: `{}` // If it has no arguments, create an empty object: `{}`
@@ -28,10 +30,10 @@ function recursiveConfigSearch(
return config return config
} else { } else {
const arg = obj.arguments[0] const arg = obj.arguments[0]
if (arg.type === "SpreadElement") return undefined if (arg.type === 'SpreadElement') return undefined
else return recursiveConfigSearch(program, arg) else return recursiveConfigSearch(program, arg)
} }
} else if (obj.type === "ObjectExpression") { } else if (obj.type === 'ObjectExpression') {
// If it's an object, return it // If it's an object, return it
return obj return obj
} else { } else {
@@ -39,15 +41,17 @@ function recursiveConfigSearch(
} }
} }
export type TransformBlitzConfigCallback = (config: j.ObjectExpression) => j.ObjectExpression export type TransformBlitzConfigCallback = (
config: j.ObjectExpression
) => j.ObjectExpression
export function transformBlitzConfig( export function transformBlitzConfig(
program: Program, program: Program,
transform: TransformBlitzConfigCallback, transform: TransformBlitzConfigCallback
): Program { ): Program {
let moduleExportsExpressions = program.find(j.AssignmentExpression, { let moduleExportsExpressions = program.find(j.AssignmentExpression, {
operator: "=", operator: '=',
left: {object: {name: "module"}, property: {name: "exports"}}, left: { object: { name: 'module' }, property: { name: 'exports' } },
right: {}, right: {},
}) })
@@ -59,10 +63,10 @@ export function transformBlitzConfig(
let moduleExportExpression = j.expressionStatement( let moduleExportExpression = j.expressionStatement(
j.assignmentExpression( j.assignmentExpression(
"=", '=',
j.memberExpression(j.identifier("module"), j.identifier("exports")), j.memberExpression(j.identifier('module'), j.identifier('exports')),
config, config
), )
) )
program.get().node.program.body.push(moduleExportExpression) program.get().node.program.body.push(moduleExportExpression)
@@ -71,14 +75,14 @@ export function transformBlitzConfig(
let config: j.ObjectExpression | undefined = recursiveConfigSearch( let config: j.ObjectExpression | undefined = recursiveConfigSearch(
program, program,
moduleExportsExpression.value.right, moduleExportsExpression.value.right
) )
if (config) { if (config) {
config = transform(config) config = transform(config)
} else { } else {
console.warn( console.warn(
"The configuration couldn't be found, but there is a 'module.exports' inside `blitz.config.js`", "The configuration couldn't be found, but there is a 'module.exports' inside `blitz.config.js`"
) )
} }
} else { } else {

View File

@@ -1,17 +1,17 @@
import type {ExpressionKind} from "ast-types/gen/kinds" import type { ExpressionKind } from 'ast-types/gen/kinds'
import j from "jscodeshift" import j from 'jscodeshift'
import {JsonObject, JsonValue} from "../types" import { JsonObject, JsonValue } from '../types'
import {Program} from "../types" import { Program } from '../types'
import {findModuleExportsExpressions} from "./find-module-exports-expressions" import { findModuleExportsExpressions } from './find-module-exports-expressions'
type AddBabelItemDefinition = string | [name: string, options: JsonObject] type AddBabelItemDefinition = string | [name: string, options: JsonObject]
const jsonValueToExpression = (value: JsonValue): ExpressionKind => const jsonValueToExpression = (value: JsonValue): ExpressionKind =>
typeof value === "string" typeof value === 'string'
? j.stringLiteral(value) ? j.stringLiteral(value)
: typeof value === "number" : typeof value === 'number'
? j.numericLiteral(value) ? j.numericLiteral(value)
: typeof value === "boolean" : typeof value === 'boolean'
? j.booleanLiteral(value) ? j.booleanLiteral(value)
: value === null : value === null
? j.nullLiteral() ? j.nullLiteral()
@@ -19,16 +19,22 @@ const jsonValueToExpression = (value: JsonValue): ExpressionKind =>
? j.arrayExpression(value.map(jsonValueToExpression)) ? j.arrayExpression(value.map(jsonValueToExpression))
: j.objectExpression( : j.objectExpression(
Object.entries(value) Object.entries(value)
.filter((entry): entry is [string, JsonValue] => entry[1] !== undefined) .filter(
(entry): entry is [string, JsonValue] => entry[1] !== undefined
)
.map(([key, value]) => .map(([key, value]) =>
j.objectProperty(j.stringLiteral(key), jsonValueToExpression(value)), j.objectProperty(j.stringLiteral(key), jsonValueToExpression(value))
), )
) )
function updateBabelConfig(program: Program, item: AddBabelItemDefinition, key: string): Program { function updateBabelConfig(
program: Program,
item: AddBabelItemDefinition,
key: string
): Program {
findModuleExportsExpressions(program).forEach((moduleExportsExpression) => { findModuleExportsExpressions(program).forEach((moduleExportsExpression) => {
j(moduleExportsExpression) j(moduleExportsExpression)
.find(j.ObjectProperty, {key: {name: key}}) .find(j.ObjectProperty, { key: { name: key } })
.forEach((items) => { .forEach((items) => {
// Don't add it again if it already exists, // Don't add it again if it already exists,
// that what this code does. For simplicity, // that what this code does. For simplicity,
@@ -36,14 +42,20 @@ function updateBabelConfig(program: Program, item: AddBabelItemDefinition, key:
const itemName = Array.isArray(item) ? item[0] : item const itemName = Array.isArray(item) ? item[0] : item
if (items.node.value.type === "Literal" || items.node.value.type === "StringLiteral") { if (
items.node.value.type === 'Literal' ||
items.node.value.type === 'StringLiteral'
) {
// { // {
// presets: "this-preset" // presets: "this-preset"
// } // }
if (itemName !== items.node.value.value) { if (itemName !== items.node.value.value) {
items.node.value = j.arrayExpression([items.node.value, jsonValueToExpression(item)]) items.node.value = j.arrayExpression([
items.node.value,
jsonValueToExpression(item),
])
} }
} else if (items.node.value.type === "ArrayExpression") { } else if (items.node.value.type === 'ArrayExpression') {
// { // {
// presets: ["this-preset", "maybe-another", ...] // presets: ["this-preset", "maybe-another", ...]
// } // }
@@ -52,22 +64,25 @@ function updateBabelConfig(program: Program, item: AddBabelItemDefinition, key:
for (const [i, element] of items.node.value.elements.entries()) { for (const [i, element] of items.node.value.elements.entries()) {
if (!element) continue if (!element) continue
if (element.type === "Literal" || element.type === "StringLiteral") { if (
element.type === 'Literal' ||
element.type === 'StringLiteral'
) {
// { // {
// presets: [..., "this-preset", ...] // presets: [..., "this-preset", ...]
// } // }
if (element.value === itemName) return if (element.value === itemName) return
} else if (element.type === "ArrayExpression") { } else if (element.type === 'ArrayExpression') {
// { // {
// presets: [..., ["this-preset"], ...] // presets: [..., ["this-preset"], ...]
// } // }
if ( if (
(element.elements[0]?.type === "Literal" || (element.elements[0]?.type === 'Literal' ||
element.elements[0]?.type === "StringLiteral") && element.elements[0]?.type === 'StringLiteral') &&
element.elements[0].value === itemName element.elements[0].value === itemName
) { ) {
if ( if (
element.elements[1]?.type === "ObjectExpression" && element.elements[1]?.type === 'ObjectExpression' &&
element.elements[1].properties.length > 0 element.elements[1].properties.length > 0
) { ) {
// The preset has a config. // The preset has a config.
@@ -81,7 +96,10 @@ function updateBabelConfig(program: Program, item: AddBabelItemDefinition, key:
const value = item[1][key] const value = item[1][key]
if (value === undefined) continue if (value === undefined) continue
obj.properties.push( obj.properties.push(
j.objectProperty(j.stringLiteral(key), jsonValueToExpression(value)), j.objectProperty(
j.stringLiteral(key),
jsonValueToExpression(value)
)
) )
} }
@@ -105,7 +123,11 @@ function updateBabelConfig(program: Program, item: AddBabelItemDefinition, key:
return program return program
} }
export const addBabelPreset = (program: Program, preset: AddBabelItemDefinition): Program => export const addBabelPreset = (
updateBabelConfig(program, preset, "presets") program: Program,
export const addBabelPlugin = (program: Program, plugin: AddBabelItemDefinition): Program => preset: AddBabelItemDefinition
updateBabelConfig(program, plugin, "plugins") ): Program => updateBabelConfig(program, preset, 'presets')
export const addBabelPlugin = (
program: Program,
plugin: AddBabelItemDefinition
): Program => updateBabelConfig(program, plugin, 'plugins')

View File

@@ -1,10 +1,13 @@
import j from "jscodeshift" import j from 'jscodeshift'
import {Program} from "../types" import { Program } from '../types'
export function wrapBlitzConfig(program: Program, functionName: string): Program { export function wrapBlitzConfig(
program: Program,
functionName: string
): Program {
let moduleExportsExpressions = program.find(j.AssignmentExpression, { let moduleExportsExpressions = program.find(j.AssignmentExpression, {
operator: "=", operator: '=',
left: {object: {name: "module"}, property: {name: "exports"}}, left: { object: { name: 'module' }, property: { name: 'exports' } },
right: {}, right: {},
}) })
@@ -12,19 +15,20 @@ export function wrapBlitzConfig(program: Program, functionName: string): Program
if (moduleExportsExpressions.length === 0) { if (moduleExportsExpressions.length === 0) {
let moduleExportExpression = j.expressionStatement( let moduleExportExpression = j.expressionStatement(
j.assignmentExpression( j.assignmentExpression(
"=", '=',
j.memberExpression(j.identifier("module"), j.identifier("exports")), j.memberExpression(j.identifier('module'), j.identifier('exports')),
j.callExpression(j.identifier(functionName), [j.objectExpression([])]), j.callExpression(j.identifier(functionName), [j.objectExpression([])])
), )
) )
program.get().node.program.body.push(moduleExportExpression) program.get().node.program.body.push(moduleExportExpression)
} else if (moduleExportsExpressions.length === 1) { } else if (moduleExportsExpressions.length === 1) {
let moduleExportsExpression: j.ASTPath<j.AssignmentExpression> = moduleExportsExpressions.get() let moduleExportsExpression: j.ASTPath<j.AssignmentExpression> = moduleExportsExpressions.get()
moduleExportsExpression.value.right = j.callExpression(j.identifier(functionName), [ moduleExportsExpression.value.right = j.callExpression(
moduleExportsExpression.value.right, j.identifier(functionName),
]) [moduleExportsExpression.value.right]
)
} else { } else {
console.warn("There are multiple 'module.exports' inside 'blitz.config.js'") console.warn("There are multiple 'module.exports' inside 'blitz.config.js'")
} }

View File

@@ -1,4 +1,4 @@
import type * as j from "jscodeshift" import type * as j from 'jscodeshift'
export interface RecipeMeta { export interface RecipeMeta {
name: string name: string
@@ -7,7 +7,7 @@ export interface RecipeMeta {
repoLink: string repoLink: string
} }
export type RecipeCLIArgs = {[Key in string]?: string | true} export type RecipeCLIArgs = { [Key in string]?: string | true }
export interface RecipeCLIFlags { export interface RecipeCLIFlags {
yesToAll: boolean yesToAll: boolean
@@ -20,7 +20,7 @@ Matches a JSON object.
This type can be useful to enforce some input to be JSON-compatible or as a super-type to be extended from. Don't use this as a direct return type as the user would have to double-cast it: `jsonObject as unknown as CustomResponse`. Instead, you could extend your CustomResponse type from it to ensure your type only uses JSON-compatible types: `interface CustomResponse extends JsonObject { … }`. This type can be useful to enforce some input to be JSON-compatible or as a super-type to be extended from. Don't use this as a direct return type as the user would have to double-cast it: `jsonObject as unknown as CustomResponse`. Instead, you could extend your CustomResponse type from it to ensure your type only uses JSON-compatible types: `interface CustomResponse extends JsonObject { … }`.
@see https://github.com/sindresorhus/type-fest @see https://github.com/sindresorhus/type-fest
*/ */
export type JsonObject = {[Key in string]?: JsonValue} export type JsonObject = { [Key in string]?: JsonValue }
/** /**
Matches a JSON array. Matches a JSON array.

View File

@@ -1,8 +1,12 @@
import * as fs from "fs-extra" import * as fs from 'fs-extra'
import * as path from "path" import * as path from 'path'
function ext(jsx = false) { function ext(jsx = false) {
return fs.existsSync(path.resolve("tsconfig.json")) ? (jsx ? ".tsx" : ".ts") : ".js" return fs.existsSync(path.resolve('tsconfig.json'))
? jsx
? '.tsx'
: '.ts'
: '.js'
} }
export const paths = { export const paths = {
@@ -16,15 +20,15 @@ export const paths = {
return `app/pages/index${ext(true)}` return `app/pages/index${ext(true)}`
}, },
babelConfig() { babelConfig() {
return "babel.config.js" return 'babel.config.js'
}, },
blitzConfig() { blitzConfig() {
return `blitz.config${ext()}` return `blitz.config${ext()}`
}, },
packageJson() { packageJson() {
return "package.json" return 'package.json'
}, },
prismaSchema() { prismaSchema() {
return "db/schema.prisma" return 'db/schema.prisma'
}, },
} }

View File

@@ -1,21 +1,21 @@
import * as fs from "fs-extra" import * as fs from 'fs-extra'
import j from "jscodeshift" import j from 'jscodeshift'
import getBabelOptions, {Overrides} from "recast/parsers/_babel_options" import getBabelOptions, { Overrides } from 'recast/parsers/_babel_options'
import * as babel from "recast/parsers/babel" import * as babel from 'recast/parsers/babel'
import {Program} from "../types" import { Program } from '../types'
export const customTsParser = { export const customTsParser = {
parse(source: string, options?: Overrides) { parse(source: string, options?: Overrides) {
const babelOptions = getBabelOptions(options) const babelOptions = getBabelOptions(options)
babelOptions.plugins.push("typescript") babelOptions.plugins.push('typescript')
babelOptions.plugins.push("jsx") babelOptions.plugins.push('jsx')
return babel.parser.parse(source, babelOptions) return babel.parser.parse(source, babelOptions)
}, },
} }
export enum TransformStatus { export enum TransformStatus {
Success = "success", Success = 'success',
Failure = "failure", Failure = 'failure',
} }
export interface TransformResult { export interface TransformResult {
status: TransformStatus status: TransformStatus
@@ -28,19 +28,22 @@ export type Transformer = (program: Program) => Program | Promise<Program>
export function stringProcessFile( export function stringProcessFile(
original: string, original: string,
transformerFn: StringTransformer, transformerFn: StringTransformer
): string | Promise<string> { ): string | Promise<string> {
return transformerFn(original) return transformerFn(original)
} }
export async function processFile(original: string, transformerFn: Transformer): Promise<string> { export async function processFile(
const program = j(original, {parser: customTsParser}) original: string,
transformerFn: Transformer
): Promise<string> {
const program = j(original, { parser: customTsParser })
return (await transformerFn(program)).toSource() return (await transformerFn(program)).toSource()
} }
export async function transform( export async function transform(
processFile: (original: string) => Promise<string>, processFile: (original: string) => Promise<string>,
targetFilePaths: string[], targetFilePaths: string[]
): Promise<TransformResult[]> { ): Promise<TransformResult[]> {
const results: TransformResult[] = [] const results: TransformResult[] = []
for (const filePath of targetFilePaths) { for (const filePath of targetFilePaths) {
@@ -53,7 +56,7 @@ export async function transform(
} }
try { try {
const fileBuffer = fs.readFileSync(filePath) const fileBuffer = fs.readFileSync(filePath)
const fileSource = fileBuffer.toString("utf-8") const fileSource = fileBuffer.toString('utf-8')
const transformedCode = await processFile(fileSource) const transformedCode = await processFile(fileSource)
fs.writeFileSync(filePath, transformedCode) fs.writeFileSync(filePath, transformedCode)
results.push({ results.push({

View File

@@ -0,0 +1,12 @@
import { useInput } from 'ink'
export function useEnterToContinue(
cb: Function,
additionalCondition: boolean = true
) {
useInput((_input, key) => {
if (additionalCondition && key.return) {
cb()
}
})
}

View File

@@ -0,0 +1,7 @@
import { useStdin } from 'ink'
import { RecipeCLIFlags } from '../types'
export function useUserInput(cliFlags: RecipeCLIFlags) {
const { isRawModeSupported } = useStdin()
return isRawModeSupported && !cliFlags.yesToAll
}

View File

@@ -0,0 +1,91 @@
import { spawn } from 'cross-spawn'
import { existsSync } from 'fs-extra'
import { mocked } from 'ts-jest/utils'
import * as AddDependencyExecutor from '../../src/executors/add-dependency-executor'
jest.mock('fs-extra')
jest.mock('cross-spawn')
describe('add dependency executor', () => {
const testConfiguration = {
stepId: 'addDependencies',
stepName: 'Add dependencies',
stepType: 'add-dependency',
explanation: 'This step will add some dependencies for testing purposes',
packages: [{ name: 'typescript', version: '4' }, { name: 'ts-node' }],
}
it('should properly identify executor', () => {
const wrongConfiguration = {
stepId: 'wrongStep',
stepName: 'Wrong Step',
stepType: 'wrong-type',
explanation: 'This step is wrong',
}
expect(
AddDependencyExecutor.isAddDependencyExecutor(wrongConfiguration)
).toBeFalsy()
expect(
AddDependencyExecutor.isAddDependencyExecutor(testConfiguration)
).toBeTruthy()
})
it('should choose proper package manager according to lock file', () => {
mocked(existsSync).mockReturnValueOnce(true)
expect(AddDependencyExecutor.getPackageManager()).toEqual('yarn')
expect(AddDependencyExecutor.getPackageManager()).toEqual('npm')
})
it('should issue proper commands according to the specified packages', async () => {
const mockedSpawn = mockSpawn()
mocked(spawn).mockImplementation(mockedSpawn.spawn as any)
// NPM
mocked(existsSync).mockReturnValue(false)
await AddDependencyExecutor.installPackages(
testConfiguration.packages,
true
)
await AddDependencyExecutor.installPackages(
testConfiguration.packages,
false
)
// Yarn
mocked(existsSync).mockReturnValue(true)
await AddDependencyExecutor.installPackages(
testConfiguration.packages,
true
)
await AddDependencyExecutor.installPackages(
testConfiguration.packages,
false
)
expect(mockedSpawn.calls.length).toEqual(4)
expect(mockedSpawn.calls[0]).toEqual(
'npm install --save-dev typescript@4 ts-node'
)
expect(mockedSpawn.calls[1]).toEqual('npm install typescript@4 ts-node')
expect(mockedSpawn.calls[2]).toEqual('yarn add -D typescript@4 ts-node')
expect(mockedSpawn.calls[3]).toEqual('yarn add typescript@4 ts-node')
})
})
/**
* Primitive mock of spawn function
*/
const mockSpawn = () => {
let calls: string[] = []
return {
spawn: (command: string, args: string[], _: unknown = {}) => {
calls.push(`${command} ${args.join(' ')}`)
return {
on: (_: string, resolve: () => void) => resolve(),
}
},
calls,
}
}

View File

@@ -0,0 +1,25 @@
import { render } from 'ink-testing-library'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { Frontmatter } from '../../src/executors/executor'
describe('Executor', () => {
const executorConfig = {
stepId: 'newFile',
stepName: 'New File',
stepType: 'new-file',
explanation: 'Testing text for a new file',
}
it('should render Frontmatter', () => {
const { lastFrame } = render(<Frontmatter executor={executorConfig} />)
expect(stripAnsi(lastFrame())).toMatchSnapshot()
})
it('should contain a step name and explanation', () => {
const { frames } = render(<Frontmatter executor={executorConfig} />)
expect(frames[0].includes('New File')).toBeTruthy()
expect(frames[0].includes('Testing text for a new file')).toBeTruthy()
})
})

View File

@@ -0,0 +1,39 @@
import { render } from 'ink-testing-library'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { Commit as PrintMessageExecutor } from '../../src/executors/print-message-executor'
describe('Executor', () => {
const executorConfig = {
stepId: 'printMessage',
stepName: 'Print message',
stepType: 'print-message',
explanation: 'Testing text for a print message',
message: 'My message',
}
it('should render PrintMessageExecutor', () => {
const { lastFrame } = render(
<PrintMessageExecutor
cliArgs={null}
cliFlags={{ yesToAll: false }}
onChangeCommitted={() => {}}
step={executorConfig}
/>
)
expect(stripAnsi(lastFrame())).toMatchSnapshot()
})
it('should contain a step name and explanation', () => {
const { frames } = render(
<PrintMessageExecutor
cliArgs={null}
cliFlags={{ yesToAll: false }}
onChangeCommitted={() => {}}
step={executorConfig}
/>
)
expect(frames[0].includes('My message')).toBeTruthy()
})
})

View File

@@ -0,0 +1,36 @@
import { addImport, customTsParser } from '@blitzjs/installer'
import j from 'jscodeshift'
function executeImport(
fileStr: string,
importStatement: j.ImportDeclaration
): string {
return addImport(
j(fileStr, { parser: customTsParser }),
importStatement
).toSource({ tabWidth: 60 })
}
describe('addImport transform', () => {
it('adds import at start of file with no imports present', () => {
const file = `export const truth = () => 42`
const importStatement = j.importDeclaration(
[j.importDefaultSpecifier(j.identifier('React'))],
j.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 = j.importDeclaration(
[],
j.literal('app/styles/app.css')
)
expect(executeImport(file, importStatement)).toMatchSnapshot()
})
})

View File

@@ -1,17 +1,17 @@
import {addPrismaEnum} from "@blitzjs/installer" import { addPrismaEnum } from '@blitzjs/installer'
describe("addPrismaEnum", () => { describe('addPrismaEnum', () => {
const subject = (source: string) => const subject = (source: string) =>
addPrismaEnum(source, { addPrismaEnum(source, {
type: "enum", type: 'enum',
name: "Role", name: 'Role',
enumerators: [ enumerators: [
{type: "enumerator", name: "USER"}, { type: 'enumerator', name: 'USER' },
{type: "enumerator", name: "ADMIN"}, { type: 'enumerator', name: 'ADMIN' },
], ],
}) })
it("creates enum", async () => { it('creates enum', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"

View File

@@ -1,16 +1,16 @@
import {addPrismaField} from "@blitzjs/installer" import { addPrismaField } from '@blitzjs/installer'
describe("addPrismaField", () => { describe('addPrismaField', () => {
const subject = (source: string) => const subject = (source: string) =>
addPrismaField(source, "Project", { addPrismaField(source, 'Project', {
type: "field", type: 'field',
name: "name", name: 'name',
fieldType: "String", fieldType: 'String',
optional: false, optional: false,
attributes: [{type: "attribute", kind: "field", name: "unique"}], attributes: [{ type: 'attribute', kind: 'field', name: 'unique' }],
}) })
it("creates field", async () => { it('creates field', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
@@ -24,7 +24,7 @@ model Project {
expect(await subject(source)).toMatchSnapshot() expect(await subject(source)).toMatchSnapshot()
}) })
it("skips if model is missing", async () => { it('skips if model is missing', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"

View File

@@ -1,14 +1,16 @@
import {addPrismaGenerator} from "@blitzjs/installer" import { addPrismaGenerator } from '@blitzjs/installer'
describe("addPrismaGenerator", () => { describe('addPrismaGenerator', () => {
const subject = (source: string) => const subject = (source: string) =>
addPrismaGenerator(source, { addPrismaGenerator(source, {
type: "generator", type: 'generator',
name: "nexusPrisma", name: 'nexusPrisma',
assignments: [{type: "assignment", key: "provider", value: '"nexus-prisma"'}], assignments: [
{ type: 'assignment', key: 'provider', value: '"nexus-prisma"' },
],
}) })
it("adds generator and keeps existing generator", async () => { it('adds generator and keeps existing generator', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
@@ -21,7 +23,7 @@ generator client {
expect(await subject(source)).toMatchSnapshot() expect(await subject(source)).toMatchSnapshot()
}) })
it("adds generator to file", async () => { it('adds generator to file', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
@@ -31,7 +33,7 @@ datasource db {
expect(await subject(source)).toMatchSnapshot() expect(await subject(source)).toMatchSnapshot()
}) })
it("overwrites same generator", async () => { it('overwrites same generator', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"

View File

@@ -1,23 +1,23 @@
import {addPrismaModelAttribute} from "@blitzjs/installer" import { addPrismaModelAttribute } from '@blitzjs/installer'
describe("addPrismaModelAttribute", () => { describe('addPrismaModelAttribute', () => {
const subject = (source: string) => const subject = (source: string) =>
addPrismaModelAttribute(source, "Project", { addPrismaModelAttribute(source, 'Project', {
type: "attribute", type: 'attribute',
kind: "model", kind: 'model',
name: "index", name: 'index',
args: [ args: [
{ {
type: "attributeArgument", type: 'attributeArgument',
value: { value: {
type: "array", type: 'array',
args: ["name"], args: ['name'],
}, },
}, },
], ],
}) })
it("creates index", async () => { it('creates index', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
@@ -32,7 +32,7 @@ model Project {
expect(await subject(source)).toMatchSnapshot() expect(await subject(source)).toMatchSnapshot()
}) })
it("skips if model is missing", async () => { it('skips if model is missing', async () => {
const source = ` const source = `
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"

View File

@@ -0,0 +1,20 @@
import { addPrismaModel } from '@blitzjs/installer'
describe('addPrismaModel', () => {
const subject = (source: string) =>
addPrismaModel(source, {
type: 'model',
name: 'Project',
properties: [{ type: 'field', name: 'id', fieldType: 'String' }],
})
it('creates model', async () => {
const source = `
datasource db {
provider = "sqlite"
url = "file:./db.sqlite"
}`.trim()
expect(await subject(source)).toMatchSnapshot()
})
})

View File

@@ -1,26 +1,28 @@
import {produceSchema} from "@blitzjs/installer" import { produceSchema } from '@blitzjs/installer'
import fs from "fs" import fs from 'fs'
import path from "path" import path from 'path'
import {promisify} from "util" import { promisify } from 'util'
const readFile = promisify(fs.readFile) const readFile = promisify(fs.readFile)
describe("produceSchema", () => { describe('produceSchema', () => {
const subject = (source: string) => produceSchema(source, () => {}) const subject = (source: string) => produceSchema(source, () => {})
let originalDatabaseUrl let originalDatabaseUrl
beforeAll(() => { beforeAll(() => {
originalDatabaseUrl = process.env.DATABASE_URL originalDatabaseUrl = process.env.DATABASE_URL
process.env.DATABASE_URL ||= "file:./db.sqlite" process.env.DATABASE_URL ||= 'file:./db.sqlite'
}) })
afterAll(() => { afterAll(() => {
process.env.DATABASE_URL = originalDatabaseUrl process.env.DATABASE_URL = originalDatabaseUrl
}) })
const fixturesDir = path.resolve(__dirname, "./fixtures") const fixturesDir = path.resolve(__dirname, './fixtures')
fs.readdirSync(fixturesDir).forEach((file) => { fs.readdirSync(fixturesDir).forEach((file) => {
it(`cleanly parses and serializes schema: [${file}]`, async () => { it(`cleanly parses and serializes schema: [${file}]`, async () => {
const source = await readFile(path.resolve(fixturesDir, file), {encoding: "utf-8"}) const source = await readFile(path.resolve(fixturesDir, file), {
encoding: 'utf-8',
})
expect(await subject(source)).toMatchSnapshot() expect(await subject(source)).toMatchSnapshot()
}) })
}) })

View File

@@ -1,21 +1,21 @@
import {setPrismaDataSource} from "@blitzjs/installer" import { setPrismaDataSource } from '@blitzjs/installer'
describe("setPrismaDataSource", () => { describe('setPrismaDataSource', () => {
const subject = (source: string) => const subject = (source: string) =>
setPrismaDataSource(source, { setPrismaDataSource(source, {
type: "datasource", type: 'datasource',
name: "db", name: 'db',
assignments: [ assignments: [
{type: "assignment", key: "provider", value: '"postgresql"'}, { type: 'assignment', key: 'provider', value: '"postgresql"' },
{ {
type: "assignment", type: 'assignment',
key: "url", key: 'url',
value: {type: "function", name: "env", params: ['"DATABASE_URL"']}, value: { type: 'function', name: 'env', params: ['"DATABASE_URL"'] },
}, },
], ],
}) })
it("sets datasource", async () => { it('sets datasource', async () => {
const source = ` const source = `
// comment up here // comment up here
@@ -29,7 +29,7 @@ datasource db {
expect(await subject(source)).toMatchSnapshot() expect(await subject(source)).toMatchSnapshot()
}) })
it("adds datasource if missing", async () => { it('adds datasource if missing', async () => {
const source = ` const source = `
// wow there is no datasource here // wow there is no datasource here
`.trim() `.trim()

View File

@@ -2,21 +2,23 @@ import {
customTsParser, customTsParser,
transformBlitzConfig, transformBlitzConfig,
TransformBlitzConfigCallback, TransformBlitzConfigCallback,
} from "@blitzjs/installer" } from '@blitzjs/installer'
import j from "jscodeshift" import j from 'jscodeshift'
import type {Options as RecastOptions} from "recast" import type { Options as RecastOptions } from 'recast'
const recastOptions: RecastOptions = { const recastOptions: RecastOptions = {
tabWidth: 2, tabWidth: 2,
arrayBracketSpacing: false, arrayBracketSpacing: false,
objectCurlySpacing: false, objectCurlySpacing: false,
quote: "single", quote: 'single',
} }
const executeTransform = (fileStr: string, transform: TransformBlitzConfigCallback) => const executeTransform = (
transformBlitzConfig(j(fileStr, {parser: customTsParser}), transform) fileStr: string,
transform: TransformBlitzConfigCallback
) => transformBlitzConfig(j(fileStr, { parser: customTsParser }), transform)
describe("transformBlitzConfig finds config", () => { describe('transformBlitzConfig finds config', () => {
const CONFIG = `{testProp: 'found'}` const CONFIG = `{testProp: 'found'}`
function findConfig(fileStr: string, equalTo = CONFIG): boolean { function findConfig(fileStr: string, equalTo = CONFIG): boolean {
@@ -32,28 +34,28 @@ describe("transformBlitzConfig finds config", () => {
return configStr === equalTo return configStr === equalTo
} }
it("simple module.exports", () => { it('simple module.exports', () => {
const file = ` const file = `
module.exports = ${CONFIG} module.exports = ${CONFIG}
` `
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("different config object as a control", () => { it('different config object as a control', () => {
const file = ` const file = `
module.exports = {other: false} module.exports = {other: false}
` `
expect(findConfig(file)).toBe(false) expect(findConfig(file)).toBe(false)
}) })
it("simple module.exports", () => { it('simple module.exports', () => {
const file = ` const file = `
module.exports = ${CONFIG} module.exports = ${CONFIG}
` `
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("inside a variable", () => { it('inside a variable', () => {
const file = ` const file = `
const config = ${CONFIG} const config = ${CONFIG}
@@ -62,21 +64,21 @@ describe("transformBlitzConfig finds config", () => {
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("with a wrapper", () => { it('with a wrapper', () => {
const file = ` const file = `
module.exports = withBundleAnalyzer(${CONFIG}) module.exports = withBundleAnalyzer(${CONFIG})
` `
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("with an empty wrapper", () => { it('with an empty wrapper', () => {
const file = ` const file = `
module.exports = withBundleAnalyzer() module.exports = withBundleAnalyzer()
` `
expect(findConfig(file)).toBe(false) expect(findConfig(file)).toBe(false)
}) })
it("as a variable inside a wrapper", () => { it('as a variable inside a wrapper', () => {
const file = ` const file = `
const config = ${CONFIG} const config = ${CONFIG}
module.exports = withBundleAnalyzer(config) module.exports = withBundleAnalyzer(config)
@@ -84,14 +86,14 @@ describe("transformBlitzConfig finds config", () => {
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("nested wrapper", () => { it('nested wrapper', () => {
const file = ` const file = `
module.exports = wrapper(wrapper(wrapper(${CONFIG}))) module.exports = wrapper(wrapper(wrapper(${CONFIG})))
` `
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("wrapper inside a variable", () => { it('wrapper inside a variable', () => {
const file = ` const file = `
const config = wrapper(${CONFIG}) const config = wrapper(${CONFIG})
module.exports = config module.exports = config
@@ -99,7 +101,7 @@ describe("transformBlitzConfig finds config", () => {
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("the very worst case", () => { it('the very worst case', () => {
const file = ` const file = `
const config1 = wrapper( const config1 = wrapper(
wrapper(${CONFIG}), wrapper(${CONFIG}),
@@ -112,110 +114,123 @@ describe("transformBlitzConfig finds config", () => {
expect(findConfig(file)).toBe(true) expect(findConfig(file)).toBe(true)
}) })
it("create empty object on empty function", () => { it('create empty object on empty function', () => {
const file = ` const file = `
module.exports = withBundleAnalyzer() module.exports = withBundleAnalyzer()
` `
expect(findConfig(file, "{}")).toBe(true) expect(findConfig(file, '{}')).toBe(true)
}) })
}) })
describe("transformBlitzConfig transform", () => { describe('transformBlitzConfig transform', () => {
it("module.exports", () => { it('module.exports', () => {
const file = `module.exports = {}` const file = `module.exports = {}`
expect( expect(
executeTransform(file, (config) => { executeTransform(file, (config) => {
config.properties.push(j.objectProperty(j.identifier("test"), j.booleanLiteral(true))) config.properties.push(
j.objectProperty(j.identifier('test'), j.booleanLiteral(true))
)
return config return config
}).toSource(recastOptions), }).toSource(recastOptions)
).toMatchSnapshot() ).toMatchSnapshot()
}) })
it("empty file", () => { it('empty file', () => {
const file = "" const file = ''
expect(executeTransform(file, (config) => config).toSource(recastOptions)).toMatchSnapshot() expect(
executeTransform(file, (config) => config).toSource(recastOptions)
).toMatchSnapshot()
}) })
it("with wrapper", () => { it('with wrapper', () => {
const file = `module.exports = withBundleAnalyzer({})` const file = `module.exports = withBundleAnalyzer({})`
expect(executeTransform(file, (config) => config).toSource(recastOptions)).toMatchSnapshot() expect(
executeTransform(file, (config) => config).toSource(recastOptions)
).toMatchSnapshot()
}) })
it("with empty wrapper", () => { it('with empty wrapper', () => {
const file = `module.exports = withBundleAnalyzer()` const file = `module.exports = withBundleAnalyzer()`
expect(executeTransform(file, (config) => config).toSource(recastOptions)).toMatchSnapshot() expect(
executeTransform(file, (config) => config).toSource(recastOptions)
).toMatchSnapshot()
}) })
it("the config file from examples/auth", () => { it('the config file from examples/auth', () => {
const file = [ const file = [
'import {sessionMiddleware, simpleRolesIsAuthorized} from "blitz"', 'import {sessionMiddleware, simpleRolesIsAuthorized} from "blitz"',
'import db from "db"', 'import db from "db"',
'const withBundleAnalyzer = require("@next/bundle-analyzer")({', 'const withBundleAnalyzer = require("@next/bundle-analyzer")({',
' enabled: process.env.ANALYZE === "true",', ' enabled: process.env.ANALYZE === "true",',
"})", '})',
"", '',
"module.exports = withBundleAnalyzer({", 'module.exports = withBundleAnalyzer({',
" middleware: [", ' middleware: [',
" sessionMiddleware({", ' sessionMiddleware({',
' cookiePrefix: "blitz-auth-example",', ' cookiePrefix: "blitz-auth-example",',
" isAuthorized: simpleRolesIsAuthorized,", ' isAuthorized: simpleRolesIsAuthorized,',
" // sessionExpiryMinutes: 4,", ' // sessionExpiryMinutes: 4,',
" getSession: (handle) => db.session.findFirst({where: {handle}}),", ' getSession: (handle) => db.session.findFirst({where: {handle}}),',
" }),", ' }),',
" ],", ' ],',
" cli: {", ' cli: {',
" clearConsoleOnBlitzDev: false,", ' clearConsoleOnBlitzDev: false,',
" },", ' },',
" log: {", ' log: {',
' // level: "trace",', ' // level: "trace",',
" },", ' },',
" experimental: {", ' experimental: {',
" initServer() {", ' initServer() {',
' console.log("Hello world from initServer")', ' console.log("Hello world from initServer")',
" },", ' },',
" },", ' },',
" /*", ' /*',
" webpack: (config, {buildId, dev, isServer, defaultLoaders, webpack}) => {", ' webpack: (config, {buildId, dev, isServer, defaultLoaders, webpack}) => {',
" // Note: we provide webpack above so you should not `require` it", ' // Note: we provide webpack above so you should not `require` it',
" // Perform customizations to webpack config", ' // Perform customizations to webpack config',
" // Important: return the modified config", ' // Important: return the modified config',
" return config", ' return config',
" },", ' },',
" webpackDevMiddleware: (config) => {", ' webpackDevMiddleware: (config) => {',
" // Perform customizations to webpack dev middleware config", ' // Perform customizations to webpack dev middleware config',
" // Important: return the modified config", ' // Important: return the modified config',
" return config", ' return config',
" },", ' },',
" */", ' */',
"})", '})',
].join("\n") ].join('\n')
expect( expect(
executeTransform(file, (config) => { executeTransform(file, (config) => {
const cliValue = j.objectExpression([ const cliValue = j.objectExpression([
j.objectProperty(j.identifier("clearConsoleOnBlitzDev"), j.booleanLiteral(true)), j.objectProperty(
j.identifier('clearConsoleOnBlitzDev'),
j.booleanLiteral(true)
),
]) ])
const cliProp = config.properties.find( const cliProp = config.properties.find(
(value) => (value) =>
value.type === "ObjectProperty" && value.type === 'ObjectProperty' &&
value.key.type === "Identifier" && value.key.type === 'Identifier' &&
value.key.name === "cli", value.key.name === 'cli'
) as j.ObjectProperty | undefined ) as j.ObjectProperty | undefined
if (!cliProp) { if (!cliProp) {
config.properties.push(j.objectProperty(j.identifier("cli"), cliValue)) config.properties.push(
j.objectProperty(j.identifier('cli'), cliValue)
)
return config return config
} }
cliProp.value = cliValue cliProp.value = cliValue
return config return config
}).toSource(recastOptions), }).toSource(recastOptions)
).toMatchSnapshot() ).toMatchSnapshot()
}) })
}) })

View File

@@ -0,0 +1,96 @@
import {
addBabelPlugin,
addBabelPreset,
customTsParser,
} from '@blitzjs/installer'
import j from 'jscodeshift'
function executeBabelPlugin(
fileStr: string,
plugin: string | [string, Object]
): string {
return addBabelPlugin(
j(fileStr, { parser: customTsParser }),
plugin
).toSource()
}
function executeBabelPreset(
fileStr: string,
plugin: string | [string, Object]
): string {
return addBabelPreset(
j(fileStr, { parser: customTsParser }),
plugin
).toSource()
}
describe('addBabelPlugin transform', () => {
it('adds babel plugin literal', () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(executeBabelPlugin(source, '@emotion')).toMatchSnapshot()
})
it('adds babel plugin array', () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(
executeBabelPlugin(source, [
'@babel/plugin-proposal-decorators',
{ legacy: true },
])
).toMatchSnapshot()
})
it('avoid duplicated', () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: ["@babel/plugin-proposal-decorators"],
}`
expect(
executeBabelPlugin(source, [
'@babel/plugin-proposal-decorators',
{ legacy: true },
])
).toMatchSnapshot()
})
})
describe('addBabelPreset transform', () => {
it('adds babel preset literal', () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(executeBabelPreset(source, 'blitz/babel')).toMatchSnapshot()
})
it('adds babel preset array', () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(
executeBabelPreset(source, ['blitz/babel', { legacy: true }])
).toMatchSnapshot()
})
it('avoid duplicated', () => {
const source = `module.exports = {
presets: [["blitz/babel", {legacy: true}]],
plugins: [],
}`
expect(executeBabelPreset(source, 'blitz/babel')).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,28 @@
import { paths } from '@blitzjs/installer'
import * as fs from 'fs-extra'
jest.mock('fs-extra')
const testIfNotWindows = process.platform === 'win32' ? test.skip : test
describe('path utils', () => {
it('returns proper file paths in a TS project', () => {
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 and Babel configs are always JS, we shouldn't transform this extension
expect(paths.blitzConfig()).toBe('blitz.config.ts')
expect(paths.babelConfig()).toBe('babel.config.js')
})
// SKIP test because the fs mock is failing on windows
testIfNotWindows('returns proper file paths in a JS project', () => {
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')
expect(paths.babelConfig()).toBe('babel.config.js')
})
})

View File

@@ -0,0 +1,3 @@
module.exports = {
preset: '../../../jest-unit.config.js',
}

View File

@@ -1,5 +1,5 @@
export default (class extends React.Component { export default class extends React.Component {
render() { render() {
const test = this.props.url const test = this.props.url
} }
}) }

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"nextjs", "nextjs",
"nextjs/packages/next", "nextjs/packages/next",
"nextjs/packages/next-mdx", "nextjs/packages/next-mdx",
"nextjs/packages/installer",
"nextjs/packages/eslint-config-next", "nextjs/packages/eslint-config-next",
"nextjs/packages/eslint-plugin-next", "nextjs/packages/eslint-plugin-next",
"nextjs/packages/next-env", "nextjs/packages/next-env",
@@ -20,6 +21,7 @@
"preconstruct": { "preconstruct": {
"packages": [ "packages": [
"packages/*", "packages/*",
"nextjs/packages/installer",
"!packages/cli", "!packages/cli",
"!packages/eslint-config" "!packages/eslint-config"
] ]

View File

@@ -1,3 +0,0 @@
module.exports = {
preset: "../../jest-unit.config.js",
}

View File

@@ -1,12 +0,0 @@
import {Text} from "ink"
import * as React from "react"
import {Newline} from "./newline"
export const EnterToContinue: React.FC<{message?: string}> = ({
message = "Press ENTER to continue",
}) => (
<>
<Newline />
<Text bold>{message}</Text>
</>
)

View File

@@ -1,6 +0,0 @@
import {Box} from "ink"
import * as React from "react"
export const Newline: React.FC<{count?: number}> = ({count = 1}) => {
return <Box paddingBottom={count} />
}

View File

@@ -1,12 +0,0 @@
export * from "./recipe-executor"
export * from "./recipe-builder"
export * from "./executors/executor"
export {type as AddDependencyType} from "./executors/add-dependency-executor"
export {type as FileTransformType} from "./executors/file-transform-executor"
export {type as NewFileType} from "./executors/new-file-executor"
export {type as PrintMessageType} from "./executors/print-message-executor"
export * from "./utils/paths"
export * from "./transforms"
export {customTsParser} from "./utils/transform"
export type {Program, RecipeCLIArgs, RecipeCLIFlags} from "./types"

View File

@@ -1,52 +0,0 @@
import {render} from "ink"
import {baseLogger} from "next/dist/server/lib/logging"
import React from "react"
import * as AddDependencyExecutor from "./executors/add-dependency-executor"
import * as FileTransformExecutor from "./executors/file-transform-executor"
import * as NewFileExecutor from "./executors/new-file-executor"
import * as PrintMessageExecutor from "./executors/print-message-executor"
import {RecipeRenderer} from "./recipe-renderer"
import {RecipeCLIArgs, RecipeCLIFlags, RecipeMeta} from "./types"
// const debug = require('debug')("blitz:installer")
type ExecutorConfig =
| AddDependencyExecutor.Config
| FileTransformExecutor.Config
| NewFileExecutor.Config
| PrintMessageExecutor.Config
export type {ExecutorConfig as ExecutorConfigUnion}
export class RecipeExecutor<Options extends RecipeMeta> {
private readonly steps: ExecutorConfig[]
private readonly options: Options
constructor(options: Options, steps: ExecutorConfig[]) {
this.options = options
this.steps = steps
}
async run(
cliArgs: RecipeCLIArgs = {},
cliFlags: RecipeCLIFlags = {yesToAll: false},
): Promise<void> {
try {
const {waitUntilExit} = render(
<RecipeRenderer
cliArgs={cliArgs}
cliFlags={cliFlags}
steps={this.steps}
recipeMeta={this.options}
/>,
{exitOnCtrlC: false},
)
await waitUntilExit()
baseLogger({displayDateTime: false, displayLogLevel: false}).silly(
`\n🎉 The ${this.options.name} recipe has been installed!\n`,
)
} catch (e) {
baseLogger({displayDateTime: false}).error(e as any)
return
}
}
}

View File

@@ -1,32 +0,0 @@
import type {ExpressionKind} from "ast-types/gen/kinds"
import j from "jscodeshift"
import {Program} from "../types"
import {transformBlitzConfig} from "."
export const addBlitzMiddleware = (program: Program, middleware: ExpressionKind): Program =>
transformBlitzConfig(program, (config) => {
// Locate the middleware property
const middlewareProp = config.properties.find(
(value) =>
value.type === "ObjectProperty" &&
value.key.type === "Identifier" &&
value.key.name === "middleware",
) as j.ObjectProperty | undefined
if (middlewareProp && middlewareProp.value.type === "ArrayExpression") {
// We found it, pop on our middleware.
middlewareProp.value.elements.push(middleware)
} else {
// No middleware prop, add our own.
config.properties.push(
j.property("init", j.identifier("middleware"), {
type: "ArrayExpression",
elements: [middleware],
loc: null,
comments: null,
}),
)
}
return config
})

View File

@@ -1,14 +0,0 @@
import j from "jscodeshift"
import {Program} from "../types"
export const findModuleExportsExpressions = (program: Program) =>
program.find(j.AssignmentExpression).filter((path) => {
const {left, right} = path.value
return (
left.type === "MemberExpression" &&
left.object.type === "Identifier" &&
left.property.type === "Identifier" &&
left.property.name === "exports" &&
right.type === "ObjectExpression"
)
})

View File

@@ -1,7 +0,0 @@
export * from "./add-import"
export * from "./add-blitz-middleware"
export * from "./find-module-exports-expressions"
export * from "./prisma"
export * from "./transform-blitz-config"
export * from "./update-babel-config"
export * from "./wrap-blitz-config"

View File

@@ -1,7 +0,0 @@
export * from "./add-prisma-enum"
export * from "./add-prisma-field"
export * from "./add-prisma-generator"
export * from "./add-prisma-model-attribute"
export * from "./add-prisma-model"
export * from "./produce-schema"
export * from "./set-prisma-data-source"

View File

@@ -1,9 +0,0 @@
import {useInput} from "ink"
export function useEnterToContinue(cb: Function, additionalCondition: boolean = true) {
useInput((_input, key) => {
if (additionalCondition && key.return) {
cb()
}
})
}

View File

@@ -1,7 +0,0 @@
import {useStdin} from "ink"
import {RecipeCLIFlags} from "../types"
export function useUserInput(cliFlags: RecipeCLIFlags) {
const {isRawModeSupported} = useStdin()
return isRawModeSupported && !cliFlags.yesToAll
}

View File

@@ -1,73 +0,0 @@
import {spawn} from "cross-spawn"
import {existsSync} from "fs-extra"
import {mocked} from "ts-jest/utils"
import * as AddDependencyExecutor from "../../src/executors/add-dependency-executor"
jest.mock("fs-extra")
jest.mock("cross-spawn")
describe("add dependency executor", () => {
const testConfiguration = {
stepId: "addDependencies",
stepName: "Add dependencies",
stepType: "add-dependency",
explanation: "This step will add some dependencies for testing purposes",
packages: [{name: "typescript", version: "4"}, {name: "ts-node"}],
}
it("should properly identify executor", () => {
const wrongConfiguration = {
stepId: "wrongStep",
stepName: "Wrong Step",
stepType: "wrong-type",
explanation: "This step is wrong",
}
expect(AddDependencyExecutor.isAddDependencyExecutor(wrongConfiguration)).toBeFalsy()
expect(AddDependencyExecutor.isAddDependencyExecutor(testConfiguration)).toBeTruthy()
})
it("should choose proper package manager according to lock file", () => {
mocked(existsSync).mockReturnValueOnce(true)
expect(AddDependencyExecutor.getPackageManager()).toEqual("yarn")
expect(AddDependencyExecutor.getPackageManager()).toEqual("npm")
})
it("should issue proper commands according to the specified packages", async () => {
const mockedSpawn = mockSpawn()
mocked(spawn).mockImplementation(mockedSpawn.spawn as any)
// NPM
mocked(existsSync).mockReturnValue(false)
await AddDependencyExecutor.installPackages(testConfiguration.packages, true)
await AddDependencyExecutor.installPackages(testConfiguration.packages, false)
// Yarn
mocked(existsSync).mockReturnValue(true)
await AddDependencyExecutor.installPackages(testConfiguration.packages, true)
await AddDependencyExecutor.installPackages(testConfiguration.packages, false)
expect(mockedSpawn.calls.length).toEqual(4)
expect(mockedSpawn.calls[0]).toEqual("npm install --save-dev typescript@4 ts-node")
expect(mockedSpawn.calls[1]).toEqual("npm install typescript@4 ts-node")
expect(mockedSpawn.calls[2]).toEqual("yarn add -D typescript@4 ts-node")
expect(mockedSpawn.calls[3]).toEqual("yarn add typescript@4 ts-node")
})
})
/**
* Primitive mock of spawn function
*/
const mockSpawn = () => {
let calls: string[] = []
return {
spawn: (command: string, args: string[], _: unknown = {}) => {
calls.push(`${command} ${args.join(" ")}`)
return {
on: (_: string, resolve: () => void) => resolve(),
}
},
calls,
}
}

View File

@@ -1,25 +0,0 @@
import {render} from "ink-testing-library"
import React from "react"
import stripAnsi from "strip-ansi"
import {Frontmatter} from "../../src/executors/executor"
describe("Executor", () => {
const executorConfig = {
stepId: "newFile",
stepName: "New File",
stepType: "new-file",
explanation: "Testing text for a new file",
}
it("should render Frontmatter", () => {
const {lastFrame} = render(<Frontmatter executor={executorConfig} />)
expect(stripAnsi(lastFrame())).toMatchSnapshot()
})
it("should contain a step name and explanation", () => {
const {frames} = render(<Frontmatter executor={executorConfig} />)
expect(frames[0].includes("New File")).toBeTruthy()
expect(frames[0].includes("Testing text for a new file")).toBeTruthy()
})
})

View File

@@ -1,39 +0,0 @@
import {render} from "ink-testing-library"
import React from "react"
import stripAnsi from "strip-ansi"
import {Commit as PrintMessageExecutor} from "../../src/executors/print-message-executor"
describe("Executor", () => {
const executorConfig = {
stepId: "printMessage",
stepName: "Print message",
stepType: "print-message",
explanation: "Testing text for a print message",
message: "My message",
}
it("should render PrintMessageExecutor", () => {
const {lastFrame} = render(
<PrintMessageExecutor
cliArgs={null}
cliFlags={{yesToAll: false}}
onChangeCommitted={() => {}}
step={executorConfig}
/>,
)
expect(stripAnsi(lastFrame())).toMatchSnapshot()
})
it("should contain a step name and explanation", () => {
const {frames} = render(
<PrintMessageExecutor
cliArgs={null}
cliFlags={{yesToAll: false}}
onChangeCommitted={() => {}}
step={executorConfig}
/>,
)
expect(frames[0].includes("My message")).toBeTruthy()
})
})

View File

@@ -1,27 +0,0 @@
import {addImport, customTsParser} from "@blitzjs/installer"
import j from "jscodeshift"
function executeImport(fileStr: string, importStatement: j.ImportDeclaration): string {
return addImport(j(fileStr, {parser: customTsParser}), importStatement).toSource({tabWidth: 60})
}
describe("addImport transform", () => {
it("adds import at start of file with no imports present", () => {
const file = `export const truth = () => 42`
const importStatement = j.importDeclaration(
[j.importDefaultSpecifier(j.identifier("React"))],
j.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 = j.importDeclaration([], j.literal("app/styles/app.css"))
expect(executeImport(file, importStatement)).toMatchSnapshot()
})
})

View File

@@ -1,20 +0,0 @@
import {addPrismaModel} from "@blitzjs/installer"
describe("addPrismaModel", () => {
const subject = (source: string) =>
addPrismaModel(source, {
type: "model",
name: "Project",
properties: [{type: "field", name: "id", fieldType: "String"}],
})
it("creates model", async () => {
const source = `
datasource db {
provider = "sqlite"
url = "file:./db.sqlite"
}`.trim()
expect(await subject(source)).toMatchSnapshot()
})
})

View File

@@ -1,72 +0,0 @@
import {addBabelPlugin, addBabelPreset, customTsParser} from "@blitzjs/installer"
import j from "jscodeshift"
function executeBabelPlugin(fileStr: string, plugin: string | [string, Object]): string {
return addBabelPlugin(j(fileStr, {parser: customTsParser}), plugin).toSource()
}
function executeBabelPreset(fileStr: string, plugin: string | [string, Object]): string {
return addBabelPreset(j(fileStr, {parser: customTsParser}), plugin).toSource()
}
describe("addBabelPlugin transform", () => {
it("adds babel plugin literal", () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(executeBabelPlugin(source, "@emotion")).toMatchSnapshot()
})
it("adds babel plugin array", () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(
executeBabelPlugin(source, ["@babel/plugin-proposal-decorators", {legacy: true}]),
).toMatchSnapshot()
})
it("avoid duplicated", () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: ["@babel/plugin-proposal-decorators"],
}`
expect(
executeBabelPlugin(source, ["@babel/plugin-proposal-decorators", {legacy: true}]),
).toMatchSnapshot()
})
})
describe("addBabelPreset transform", () => {
it("adds babel preset literal", () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(executeBabelPreset(source, "blitz/babel")).toMatchSnapshot()
})
it("adds babel preset array", () => {
const source = `module.exports = {
presets: ["@babel/preset-typescript"],
plugins: [],
}`
expect(executeBabelPreset(source, ["blitz/babel", {legacy: true}])).toMatchSnapshot()
})
it("avoid duplicated", () => {
const source = `module.exports = {
presets: [["blitz/babel", {legacy: true}]],
plugins: [],
}`
expect(executeBabelPreset(source, "blitz/babel")).toMatchSnapshot()
})
})

View File

@@ -1,28 +0,0 @@
import {paths} from "@blitzjs/installer"
import * as fs from "fs-extra"
jest.mock("fs-extra")
const testIfNotWindows = process.platform === "win32" ? test.skip : test
describe("path utils", () => {
it("returns proper file paths in a TS project", () => {
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 and Babel configs are always JS, we shouldn't transform this extension
expect(paths.blitzConfig()).toBe("blitz.config.ts")
expect(paths.babelConfig()).toBe("babel.config.js")
})
// SKIP test because the fs mock is failing on windows
testIfNotWindows("returns proper file paths in a JS project", () => {
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")
expect(paths.babelConfig()).toBe("babel.config.js")
})
})

View File

@@ -32,10 +32,10 @@
"packages/blitz/src/**/*", "packages/blitz/src/**/*",
"packages/display/src/**/*", "packages/display/src/**/*",
"packages/generator/src/**/*", "packages/generator/src/**/*",
"packages/installer/src/**/*",
"packages/repl/src/**/*", "packages/repl/src/**/*",
"packages/server/src/**/*", "packages/server/src/**/*",
"recipes/*" "recipes/*",
"nextjs/packages/installer/src/**/*"
], ],
"exclude": ["*.test.ts", "*.test.tsx"] "exclude": ["*.test.ts", "*.test.tsx"]
} }