diff --git a/waspc/cli/src/Wasp/Cli/Command/BuildStart/ArgumentsParser.hs b/waspc/cli/src/Wasp/Cli/Command/BuildStart/ArgumentsParser.hs index 1d2f434d9f..a16076848c 100644 --- a/waspc/cli/src/Wasp/Cli/Command/BuildStart/ArgumentsParser.hs +++ b/waspc/cli/src/Wasp/Cli/Command/BuildStart/ArgumentsParser.hs @@ -6,7 +6,7 @@ where import qualified Options.Applicative as Opt import Wasp.Cli.Util.EnvVarArgument (envVarReader) -import Wasp.Cli.Util.PathArgument (FilePathArgument, filePathReader) +import Wasp.Cli.Util.PathArgument (FilePathArgument) import Wasp.Env (EnvVar) data BuildStartArgs = BuildStartArgs @@ -44,7 +44,7 @@ buildStartArgsParser = makeEnvironmentFileParser :: String -> String -> Opt.Parser FilePathArgument makeEnvironmentFileParser targetName longOptionName = - Opt.option filePathReader $ + Opt.strOption $ Opt.long longOptionName <> Opt.metavar "FILE_PATH" <> Opt.help ("Load environment variables for the " <> targetName <> " from a file (can be used multiple times)") diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs index 809f634939..a83ec102d0 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs @@ -22,6 +22,7 @@ import Wasp.Cli.Command.CreateNewProject.StarterTemplates ) import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Bundled (createProjectOnDiskFromBundledTemplate) import Wasp.Cli.Command.CreateNewProject.StarterTemplates.GhReleaseArchive (createProjectOnDiskFromGhReleaseArchiveTemplate) +import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local (createProjectOnDiskFromLocalTemplate) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Util.Parser (parseArguments) import qualified Wasp.Message as Msg @@ -53,6 +54,8 @@ createProjectOnDisk createProjectOnDiskFromGhReleaseArchiveTemplate absWaspProjectDir projectName appName ghRepoRef archiveName' archivePath' BundledStarterTemplate {bundledPath = bundledPath'} -> liftIO $ createProjectOnDiskFromBundledTemplate absWaspProjectDir projectName appName bundledPath' + LocalStarterTemplate {localPath = localPath'} -> + liftIO $ createProjectOnDiskFromLocalTemplate absWaspProjectDir projectName appName localPath' AiGeneratedStarterTemplate -> AI.createNewProjectInteractiveOnDisk absWaspProjectDir appName diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ArgumentsParser.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ArgumentsParser.hs index 23fd0978e4..bb9d06cd9a 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ArgumentsParser.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ArgumentsParser.hs @@ -1,24 +1,38 @@ module Wasp.Cli.Command.CreateNewProject.ArgumentsParser ( NewProjectArgs (..), + TemplateArg (..), newProjectArgsParser, ) where +import Control.Applicative (optional, (<|>)) import qualified Options.Applicative as Opt +import Wasp.Cli.Util.PathArgument (DirPathArgument) data NewProjectArgs = NewProjectArgs { _projectName :: Maybe String, - _templateName :: Maybe String + _templateArg :: Maybe TemplateArg } +data TemplateArg + = NamedTemplate String + | CustomTemplate DirPathArgument + newProjectArgsParser :: Opt.Parser NewProjectArgs newProjectArgsParser = NewProjectArgs - <$> Opt.optional projectNameParser - <*> Opt.optional templateNameParser + <$> optional projectNameParser + <*> optional templateArgParser where projectNameParser :: Opt.Parser String - projectNameParser = Opt.strArgument $ Opt.metavar "PROJECT_NAME" + projectNameParser = + Opt.strArgument $ + Opt.metavar "PROJECT_NAME" + + templateArgParser :: Opt.Parser TemplateArg + templateArgParser = + (NamedTemplate <$> templateNameParser) + <|> (CustomTemplate <$> customTemplatePathParser) templateNameParser :: Opt.Parser String templateNameParser = @@ -27,3 +41,11 @@ newProjectArgsParser = <> Opt.short 't' <> Opt.metavar "TEMPLATE_NAME" <> Opt.help "Template to use for the new project" + + customTemplatePathParser :: Opt.Parser DirPathArgument + customTemplatePathParser = + Opt.strOption $ + Opt.long "custom-template" + -- This is an internal option only intended for internal testing usage, + -- so we don't want to show help for it. + <> Opt.internal diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ProjectDescription.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ProjectDescription.hs index 0b113bdb62..c13fb64b6f 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ProjectDescription.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/ProjectDescription.hs @@ -13,21 +13,25 @@ import Data.List (intercalate) import Data.List.NonEmpty (fromList) import Data.Maybe (isNothing) import Path.IO (doesDirExist) -import StrongPath (Abs, Dir, Path') +import StrongPath (Abs, Dir, Path', fromAbsDir) import StrongPath.Path (toPathAbsDir) +import System.Directory (doesDirectoryExist) import Wasp.Analyzer.Parser (isValidWaspIdentifier) import Wasp.Cli.Command (Command) import Wasp.Cli.Command.CreateNewProject.ArgumentsParser ( NewProjectArgs (..), + TemplateArg (..), ) import Wasp.Cli.Command.CreateNewProject.AvailableTemplates (defaultStarterTemplate) import Wasp.Cli.Command.CreateNewProject.Common (throwProjectCreationError) import Wasp.Cli.Command.CreateNewProject.StarterTemplates - ( StarterTemplate, + ( StarterTemplate (LocalStarterTemplate, localPath), findTemplateByString, ) import Wasp.Cli.FileSystem (getAbsPathToDirInCwd) import qualified Wasp.Cli.Interactive as Interactive +import Wasp.Cli.Util.PathArgument (DirPathArgument) +import qualified Wasp.Cli.Util.PathArgument as PathArgument import Wasp.Project.Common (WaspProjectDir) import Wasp.Util (indent, kebabToCamelCase, whenM) @@ -50,12 +54,14 @@ instance Show NewProjectAppName where {- There are two ways of getting the project description: + 1. From CLI arguments - wasp new [-t ] + wasp new [-t | -c ] - Project name is required. - - Template name is optional, if not provided, we use the default template. + - Template name/dir is optional, if not provided, we use the default template. + 2. Interactively wasp new @@ -64,19 +70,21 @@ instance Show NewProjectAppName where - Template name is required, we ask the user to choose from available templates. -} obtainNewProjectDescription :: NewProjectArgs -> [StarterTemplate] -> Command NewProjectDescription -obtainNewProjectDescription NewProjectArgs {_projectName = projectNameArg, _templateName = templateNameArg} starterTemplates = do +obtainNewProjectDescription NewProjectArgs {_projectName = projectNameArg, _templateArg = templateArg} starterTemplates = do projectName <- maybe askForName return projectNameArg appName <- either throwProjectCreationError pure $ parseWaspProjectNameIntoAppName projectName let prefersInteractive = isNothing projectNameArg - getFallbackTemplate = - if prefersInteractive - then askForTemplate starterTemplates - else return defaultStarterTemplate - template <- maybe getFallbackTemplate (findTemplateOrThrow starterTemplates) templateNameArg + template <- case templateArg of + Just (NamedTemplate templateName) -> findNamedTemplate starterTemplates templateName + Just (CustomTemplate templatePath) -> findCustomTemplate templatePath + Nothing -> + if prefersInteractive + then askForTemplate starterTemplates + else return defaultStarterTemplate absWaspProjectDir <- obtainAvailableProjectDirPath projectName return $ mkNewProjectDescription projectName appName absWaspProjectDir template @@ -102,17 +110,32 @@ parseWaspProjectNameIntoAppName projectName where appName = kebabToCamelCase projectName -findTemplateOrThrow :: [StarterTemplate] -> String -> Command StarterTemplate -findTemplateOrThrow availableTemplates templateName = case findTemplateByString availableTemplates templateName of - Just template -> return template - Nothing -> throwProjectCreationError invalidTemplateNameError +findNamedTemplate :: [StarterTemplate] -> String -> Command StarterTemplate +findNamedTemplate availableTemplates templateName = + maybe throwInvalidTemplateNameError return $ + findTemplateByString availableTemplates templateName + where + throwInvalidTemplateNameError = + throwProjectCreationError $ + "The template " + <> show templateName + <> " doesn't exist. Available starter templates are: " + <> intercalate ", " (map show availableTemplates) + <> "." + +findCustomTemplate :: DirPathArgument -> Command StarterTemplate +findCustomTemplate templatePath = do + absTemplatePath <- liftIO $ PathArgument.getDirPath templatePath + templateExists <- liftIO $ doesDirectoryExist $ fromAbsDir absTemplatePath + if templateExists + then return $ LocalStarterTemplate {localPath = absTemplatePath} + else throwInvalidCustomTemplatePathError absTemplatePath where - invalidTemplateNameError = - "The template '" - <> templateName - <> "' doesn't exist. Available starter templates are: " - <> intercalate ", " (map show availableTemplates) - <> "." + throwInvalidCustomTemplatePathError absTemplatePath = + throwProjectCreationError $ + "The directory " + <> show (fromAbsDir absTemplatePath) + <> " doesn't exist or can't be found." obtainAvailableProjectDirPath :: String -> Command (Path' Abs (Dir WaspProjectDir)) obtainAvailableProjectDirPath projectName = do diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs index bcdf0896bd..de2392ee43 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs @@ -11,7 +11,7 @@ where import Data.Foldable (find) import Data.Text (Text) -import StrongPath (Dir', File', Path, Path', Rel, Rel', System, reldir, ()) +import StrongPath (Abs, Dir', File', Path, Path', Rel, Rel', System, reldir, ()) import qualified StrongPath as SP import qualified Wasp.Cli.GithubRepo as GhRepo import qualified Wasp.Cli.Interactive as Interactive @@ -36,6 +36,8 @@ data StarterTemplate { bundledPath :: Path' Rel' Dir', metadata :: !TemplateMetadata } + | -- | Template from disk, that the user has locally extracted. + LocalStarterTemplate {localPath :: !(Path' Abs Dir')} | -- | Template that will be dynamically generated by Wasp AI based on user's input. AiGeneratedStarterTemplate @@ -49,6 +51,7 @@ data TemplateMetadata = TemplateMetadata instance Show StarterTemplate where show (GhRepoReleaseArchiveTemplate {metadata = metadata'}) = _name metadata' show (BundledStarterTemplate {metadata = metadata'}) = _name metadata' + show (LocalStarterTemplate _) = "custom" show AiGeneratedStarterTemplate = "ai-generated" instance Interactive.IsOption StarterTemplate where @@ -56,6 +59,7 @@ instance Interactive.IsOption StarterTemplate where showOptionDescription (GhRepoReleaseArchiveTemplate {metadata = metadata'}) = Just $ _description metadata' showOptionDescription (BundledStarterTemplate {metadata = metadata'}) = Just $ _description metadata' + showOptionDescription (LocalStarterTemplate _) = Just "A custom starter template from a local path." showOptionDescription AiGeneratedStarterTemplate = Just "🤖 Describe an app in a couple of sentences and have Wasp AI generate initial code for you. (experimental)" @@ -68,6 +72,7 @@ getTemplateStartingInstructions :: String -> StarterTemplate -> String getTemplateStartingInstructions projectDirName = \case GhRepoReleaseArchiveTemplate {metadata = metadata'} -> _buildStartingInstructions metadata' projectDirName BundledStarterTemplate {metadata = metadata'} -> _buildStartingInstructions metadata' projectDirName + LocalStarterTemplate _ -> "Check the starter's documentation for guidance on how to start your app." AiGeneratedStarterTemplate -> unlines [ styleText $ "To run your new app, do:", diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs new file mode 100644 index 0000000000..314ecf1914 --- /dev/null +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs @@ -0,0 +1,17 @@ +module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local + ( createProjectOnDiskFromLocalTemplate, + ) +where + +import Path.IO (copyDirRecur) +import StrongPath (Abs, Dir, Dir', Path') +import StrongPath.Path (toPathAbsDir) +import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName) +import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating (replaceTemplatePlaceholdersInTemplateFiles) +import Wasp.Project (WaspProjectDir) + +createProjectOnDiskFromLocalTemplate :: + Path' Abs (Dir WaspProjectDir) -> NewProjectName -> NewProjectAppName -> Path' Abs Dir' -> IO () +createProjectOnDiskFromLocalTemplate absWaspProjectDir projectName appName templatePath = do + copyDirRecur (toPathAbsDir templatePath) (toPathAbsDir absWaspProjectDir) + replaceTemplatePlaceholdersInTemplateFiles appName projectName absWaspProjectDir diff --git a/waspc/cli/src/Wasp/Cli/Util/PathArgument.hs b/waspc/cli/src/Wasp/Cli/Util/PathArgument.hs index 9dbc781454..93cb20b3b9 100644 --- a/waspc/cli/src/Wasp/Cli/Util/PathArgument.hs +++ b/waspc/cli/src/Wasp/Cli/Util/PathArgument.hs @@ -1,12 +1,13 @@ module Wasp.Cli.Util.PathArgument ( FilePathArgument, + DirPathArgument, getFilePath, - filePathReader, + getDirPath, ) where -import Options.Applicative (ReadM, str) -import StrongPath (Abs, File', Path') +import Data.String (IsString (..)) +import StrongPath (Abs, Dir', File', Path', parseAbsDir) import StrongPath.FilePath (parseAbsFile) import System.Directory (makeAbsolute) @@ -16,11 +17,18 @@ import System.Directory (makeAbsolute) -- in the meantime; so we make these types opaque until we have access to the IO -- monad. -newtype FilePathArgument = FilePathArgument FilePath - deriving (Show, Eq) +newtype FilePathArgument = FilePathArgument FilePath deriving (Show, Eq) -filePathReader :: ReadM FilePathArgument -filePathReader = FilePathArgument <$> str +newtype DirPathArgument = DirPathArgument FilePath deriving (Show, Eq) + +instance IsString FilePathArgument where + fromString = FilePathArgument . fromString + +instance IsString DirPathArgument where + fromString = DirPathArgument . fromString getFilePath :: FilePathArgument -> IO (Path' Abs File') getFilePath (FilePathArgument filePath) = makeAbsolute filePath >>= parseAbsFile + +getDirPath :: DirPathArgument -> IO (Path' Abs Dir') +getDirPath (DirPathArgument dirPath) = makeAbsolute dirPath >>= parseAbsDir diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index c296bd037e..9eb675255e 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -591,6 +591,7 @@ library cli-lib Wasp.Cli.Command.CreateNewProject.StarterTemplates Wasp.Cli.Command.CreateNewProject.StarterTemplates.Bundled Wasp.Cli.Command.CreateNewProject.StarterTemplates.GhReleaseArchive + Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating Wasp.Cli.Command.Db Wasp.Cli.Command.Db.Migrate