diff --git a/waspc/cabal.project b/waspc/cabal.project index 0b6a54506a..2a9dff5494 100644 --- a/waspc/cabal.project +++ b/waspc/cabal.project @@ -20,4 +20,4 @@ jobs: $ncpus test-show-details: direct -- WARNING: Run cabal update if your local package index is older than this date. -index-state: 2022-03-22T14:16:26Z +index-state: 2022-07-05T07:45:23Z diff --git a/waspc/src/Wasp/Generator/Common.hs b/waspc/src/Wasp/Generator/Common.hs index 0bfa3a797a..b44fbc553f 100644 --- a/waspc/src/Wasp/Generator/Common.hs +++ b/waspc/src/Wasp/Generator/Common.hs @@ -3,9 +3,14 @@ module Wasp.Generator.Common nodeVersionRange, npmVersionRange, prismaVersion, + npmCmd, + buildNpmCmdWithArgs, + npxCmd, + buildNpxCmdWithArgs, ) where +import System.Info (os) import qualified Wasp.SemanticVersion as SV -- | Directory where the whole web app project (client, server, ...) is generated. @@ -38,3 +43,36 @@ npmVersionRange = prismaVersion :: SV.Version prismaVersion = SV.Version 3 15 2 + +npmCmd :: String +npmCmd = case os of + -- Windows adds ".exe" to command, when calling it programmatically, if it doesn't + -- have an extension already, meaning that calling `npm` actually calls `npm.exe`. + -- However, there is no `npm.exe` on Windows, instead there is `npm` or `npm.cmd`, so we make sure here to call `npm.cmd`. + -- Extra info: https://stackoverflow.com/questions/43139364/createprocess-weird-behavior-with-files-without-extension . + "mingw32" -> "npm.cmd" + _ -> "npm" + +npxCmd :: String +npxCmd = case os of + -- Read above, for "npm", why we need to handle Win in special way. + "mingw32" -> "npx.cmd" + _ -> "npx" + +buildNpmCmdWithArgs :: [String] -> (String, [String]) +buildNpmCmdWithArgs args = case os of + -- On Windows, due to how npm.cmd script is written, it happens that script + -- resolves some paths (work directory) incorrectly when called programmatically, sometimes. + -- Therefore, we call it via `cmd.exe`, which ensures this issue doesn't happen. + -- Extra info: https://stackoverflow.com/a/44820337 . + "mingw32" -> wrapCmdAndArgsInWinCmdExe (npmCmd, args) + _ -> (npmCmd, args) + +buildNpxCmdWithArgs :: [String] -> (String, [String]) +buildNpxCmdWithArgs args = case os of + -- Read above, for "npm", why we need to handle Win in special way. + "mingw32" -> wrapCmdAndArgsInWinCmdExe (npxCmd, args) + _ -> (npxCmd, args) + +wrapCmdAndArgsInWinCmdExe :: (String, [String]) -> (String, [String]) +wrapCmdAndArgsInWinCmdExe (cmd, args) = ("cmd.exe", [unwords $ "/c" : cmd : args]) diff --git a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs index 1908c006b8..711c9d9170 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs @@ -11,20 +11,21 @@ import StrongPath (Abs, Dir, Path', ()) import qualified StrongPath as SP import System.Exit (ExitCode (..)) import qualified System.Info -import Wasp.Generator.Common (ProjectRootDir, prismaVersion) +import Wasp.Generator.Common (ProjectRootDir, buildNpmCmdWithArgs, buildNpxCmdWithArgs, prismaVersion) import Wasp.Generator.DbGenerator.Common (dbSchemaFileInProjectRootDir) import Wasp.Generator.Job (JobMessage, JobMessageData (JobExit, JobOutput)) import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import Wasp.Generator.Job.Process (runNodeDependentCommandAsJob) import Wasp.Generator.ServerGenerator.Common (serverRootDirInProjectRootDir) +-- Args to be passed to `npx` in order to run prisma. -- `--no-install` is the magic that causes this command to fail if npx cannot find it locally -- (either in node_modules, or globally in npx). We do not want to allow npx to ask -- a user without prisma to install the latest version. -- We also pin the version to what we need so it won't accidentally find a different version globally -- somewhere on the PATH. -npxPrismaCmd :: [String] -npxPrismaCmd = ["npx", "--no-install", "prisma@" ++ show prismaVersion] +npxPrismaArgs :: [String] +npxPrismaArgs = ["--no-install", "prisma@" ++ show prismaVersion] migrateDev :: Path' Abs (Dir ProjectRootDir) -> Maybe String -> J.Job migrateDev projectDir maybeMigrationName = do @@ -33,24 +34,35 @@ migrateDev projectDir maybeMigrationName = do let optionalMigrationArgs = maybe [] (\name -> ["--name", name]) maybeMigrationName - -- NOTE(matija): We are running this command from server's root dir since that is where - -- Prisma packages (cli and client) are currently installed. + -- NOTE(martin): For this to work on Mac, filepath in the list below must be as it is now - not wrapped in any quotes. + let npxPrismaMigrateArgs = npxPrismaArgs ++ ["migrate", "dev", "--schema", SP.toFilePath schemaFile] ++ optionalMigrationArgs + let npxPrismaMigrateCmdWithArgs = buildNpxCmdWithArgs npxPrismaMigrateArgs + -- NOTE(martin): `prisma migrate dev` refuses to execute when interactivity is needed if stdout is being piped, -- because it assumes it is used in non-interactive environment. In our case we are piping both stdin and stdout -- so we do have interactivity, but Prisma doesn't know that. -- I opened an issue with Prisma https://github.com/prisma/prisma/issues/7113, but in the meantime -- we are using `script` to trick Prisma into thinking it is running in TTY (interactively). + let osSpecificCmdAndArgs = case System.Info.os of + "darwin" -> osxCmdAndArgs + "mingw32" -> winCmdAndArgs + _ -> posixCmdAndArgs + where + osxCmdAndArgs = + -- NOTE(martin): On MacOS, command that `script` should execute is treated as multiple arguments. + ("script", ["-Fq", "/dev/null"] ++ uncurry (:) npxPrismaMigrateCmdWithArgs) + posixCmdAndArgs = + -- NOTE(martin): On Linux, command that `script` should execute is treated as one argument. + ("script", ["-feqc", unwords $ uncurry (:) npxPrismaMigrateCmdWithArgs, "/dev/null"]) + winCmdAndArgs = + -- TODO: For Windows we don't do anything at the moment. + -- Does this work when interactivity is needed, or does Prisma block us same as on mac and linux? + -- If it does, find an alternative to `script` since it does not exist for Windows. + npxPrismaMigrateCmdWithArgs - -- NOTE(martin): For this to work on Mac, filepath in the list below must be as it is now - not wrapped in any quotes. - let npxPrismaMigrateCmd = npxPrismaCmd ++ ["migrate", "dev", "--schema", SP.toFilePath schemaFile] ++ optionalMigrationArgs - let scriptArgs = - if System.Info.os == "darwin" - then -- NOTE(martin): On MacOS, command that `script` should execute is treated as multiple arguments. - ["-Fq", "/dev/null"] ++ npxPrismaMigrateCmd - else -- NOTE(martin): On Linux, command that `script` should execute is treated as one argument. - ["-feqc", unwords npxPrismaMigrateCmd, "/dev/null"] - - let job = runNodeCommandAsJob serverDir "script" scriptArgs J.Db + -- NOTE(matija): We are running this command from server's root dir since that is where + -- Prisma packages (cli and client) are currently installed. + let job = runNodeDependentCommandAsJob J.Db serverDir osSpecificCmdAndArgs retryJobOnErrorWith job (npmInstall projectDir) ForwardEverything @@ -60,8 +72,8 @@ runStudio projectDir = do let serverDir = projectDir serverRootDirInProjectRootDir let schemaFile = projectDir dbSchemaFileInProjectRootDir - let npxPrismaStudioCmd = npxPrismaCmd ++ ["studio", "--schema", SP.toFilePath schemaFile] - let job = runNodeCommandAsJob serverDir (head npxPrismaStudioCmd) (tail npxPrismaStudioCmd) J.Db + let npxPrismaStudioCmdWithArgs = buildNpxCmdWithArgs $ npxPrismaArgs ++ ["studio", "--schema", SP.toFilePath schemaFile] + let job = runNodeDependentCommandAsJob J.Db serverDir npxPrismaStudioCmdWithArgs retryJobOnErrorWith job (npmInstall projectDir) ForwardEverything @@ -70,8 +82,8 @@ generatePrismaClient projectDir = do let serverDir = projectDir serverRootDirInProjectRootDir let schemaFile = projectDir dbSchemaFileInProjectRootDir - let npxPrismaGenerateCmd = npxPrismaCmd ++ ["generate", "--schema", SP.toFilePath schemaFile] - let job = runNodeCommandAsJob serverDir (head npxPrismaGenerateCmd) (tail npxPrismaGenerateCmd) J.Db + let npxPrismaGenerateCmdWithArgs = buildNpxCmdWithArgs $ npxPrismaArgs ++ ["generate", "--schema", SP.toFilePath schemaFile] + let job = runNodeDependentCommandAsJob J.Db serverDir npxPrismaGenerateCmdWithArgs retryJobOnErrorWith job (npmInstall projectDir) ForwardOnlyRetryErrors @@ -80,7 +92,7 @@ generatePrismaClient projectDir = do npmInstall :: Path' Abs (Dir ProjectRootDir) -> J.Job npmInstall projectDir = do let serverDir = projectDir serverRootDirInProjectRootDir - runNodeCommandAsJob serverDir "npm" ["install"] J.Db + runNodeDependentCommandAsJob J.Db serverDir $ buildNpmCmdWithArgs ["install"] data JobMessageForwardingStrategy = ForwardEverything | ForwardOnlyRetryErrors diff --git a/waspc/src/Wasp/Generator/Job/Process.hs b/waspc/src/Wasp/Generator/Job/Process.hs index 2663fff4a5..e640ab6e6b 100644 --- a/waspc/src/Wasp/Generator/Job/Process.hs +++ b/waspc/src/Wasp/Generator/Job/Process.hs @@ -1,19 +1,21 @@ {-# LANGUAGE ScopedTypeVariables #-} +{-# OPTIONS_GHC -Wno-deferred-out-of-scope-variables #-} module Wasp.Generator.Job.Process ( runProcessAsJob, - runNodeCommandAsJob, + runNodeDependentCommandAsJob, parseNodeVersion, ) where import Control.Concurrent (writeChan) import Control.Concurrent.Async (Concurrently (..)) +import Data.ByteString (ByteString) import Data.Conduit (runConduit, (.|)) import qualified Data.Conduit.List as CL import qualified Data.Conduit.Process as CP import qualified Data.Text as T -import Data.Text.Encoding (decodeUtf8) +import GHC.IO.Encoding (initLocaleEncoding) import StrongPath (Abs, Dir, Path') import qualified StrongPath as SP import System.Exit (ExitCode (..)) @@ -26,6 +28,7 @@ import UnliftIO.Exception (bracket) import qualified Wasp.Generator.Common as C import qualified Wasp.Generator.Job as J import qualified Wasp.SemanticVersion as SV +import qualified Wasp.Util.Encoding as E -- TODO: -- Switch from Data.Conduit.Process to Data.Conduit.Process.Typed. @@ -42,29 +45,8 @@ runProcessAsJob process jobType chan = runStreamingProcessAsJob where runStreamingProcessAsJob (CP.Inherited, stdoutStream, stderrStream, processHandle) = do - let forwardStdoutToChan = - runConduit $ - stdoutStream - .| CL.mapM_ - ( \bs -> - writeChan chan $ - J.JobMessage - { J._data = J.JobOutput (decodeUtf8 bs) J.Stdout, - J._jobType = jobType - } - ) - - let forwardStderrToChan = - runConduit $ - stderrStream - .| CL.mapM_ - ( \bs -> - writeChan chan $ - J.JobMessage - { J._data = J.JobOutput (decodeUtf8 bs) J.Stderr, - J._jobType = jobType - } - ) + let forwardStdoutToChan = forwardStandardOutputStreamToChan stdoutStream J.Stdout + let forwardStderrToChan = forwardStandardOutputStreamToChan stderrStream J.Stderr exitCode <- runConcurrently $ @@ -79,6 +61,22 @@ runProcessAsJob process jobType chan = } return exitCode + where + -- @stream@ can be stdout stream or stderr stream. + forwardStandardOutputStreamToChan stream jobOutputType = runConduit $ stream .| CL.mapM_ forwardByteStringChunkToChan + where + forwardByteStringChunkToChan bs = do + writeChan chan $ + J.JobMessage + { -- NOTE: We decode while using locale encoding, since that is the best option when + -- dealing with ephemeral standard in/outputs. Here is a blog explaining this in more details: + -- https://serokell.io/blog/haskell-with-utf8 . + J._data = J.JobOutput (decodeLocaleEncoding bs) jobOutputType, + J._jobType = jobType + } + + decodeLocaleEncoding :: ByteString -> T.Text + decodeLocaleEncoding = T.pack . E.decodeWithTELenient initLocaleEncoding -- NOTE(shayne): On *nix, we use interruptProcessGroupOf instead of terminateProcess because many -- processes we run will spawn child processes, which themselves may spawn child processes. @@ -94,8 +92,10 @@ runProcessAsJob process jobType chan = else P.interruptProcessGroupOf processHandle return $ ExitFailure 1 -runNodeCommandAsJob :: Path' Abs (Dir a) -> String -> [String] -> J.JobType -> J.Job -runNodeCommandAsJob fromDir command args jobType chan = do +-- | First checks if correct version of node is installed on the machine, then runs the given command +-- as a Job (since it assumes this command requires node to be installed). +runNodeDependentCommandAsJob :: J.JobType -> Path' Abs (Dir a) -> (String, [String]) -> J.Job +runNodeDependentCommandAsJob jobType fromDir (command, args) chan = do errorOrNodeVersion <- getNodeVersion case errorOrNodeVersion of Left errorMsg -> exitWithError (ExitFailure 1) (T.pack errorMsg) diff --git a/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs index 8bd1c49ecf..41b2b0ae7d 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs @@ -19,6 +19,7 @@ import StrongPath Posix, Rel, parseRelFile, + relFileToPosix, reldir, reldirP, relfile, @@ -62,7 +63,7 @@ genJob (jobName, job) = -- `Aeson.Text.encodeToLazyText` on an Aeson.Object, or `show` on an AS.JSON. "jobSchedule" .= Aeson.Text.encodeToLazyText (fromMaybe Aeson.Null maybeJobSchedule), "jobPerformOptions" .= show (fromMaybe AS.JSON.emptyObject maybeJobPerformOptions), - "executorJobRelFP" .= toFilePath (executorJobTemplateInJobsDir (J.executor job)) + "executorJobRelFP" .= toFilePath (fromJust $ relFileToPosix $ executorJobTemplateInJobsDir $ J.executor job) ] ) where diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Setup.hs b/waspc/src/Wasp/Generator/ServerGenerator/Setup.hs index 03194b729f..df90704c98 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Setup.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Setup.hs @@ -4,12 +4,12 @@ module Wasp.Generator.ServerGenerator.Setup where import StrongPath (Abs, Dir, Path', ()) -import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.Common (ProjectRootDir, buildNpmCmdWithArgs) import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import Wasp.Generator.Job.Process (runNodeDependentCommandAsJob) import qualified Wasp.Generator.ServerGenerator.Common as Common installNpmDependencies :: Path' Abs (Dir ProjectRootDir) -> J.Job installNpmDependencies projectDir = do let serverDir = projectDir Common.serverRootDirInProjectRootDir - runNodeCommandAsJob serverDir "npm" ["install"] J.Server + runNodeDependentCommandAsJob J.Server serverDir $ buildNpmCmdWithArgs ["install"] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Start.hs b/waspc/src/Wasp/Generator/ServerGenerator/Start.hs index 5628213983..f5787b87e6 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Start.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Start.hs @@ -4,12 +4,12 @@ module Wasp.Generator.ServerGenerator.Start where import StrongPath (Abs, Dir, Path', ()) -import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.Common (ProjectRootDir, buildNpmCmdWithArgs) import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import Wasp.Generator.Job.Process (runNodeDependentCommandAsJob) import qualified Wasp.Generator.ServerGenerator.Common as Common startServer :: Path' Abs (Dir ProjectRootDir) -> J.Job startServer projectDir = do let serverDir = projectDir Common.serverRootDirInProjectRootDir - runNodeCommandAsJob serverDir "npm" ["start"] J.Server + runNodeDependentCommandAsJob J.Server serverDir $ buildNpmCmdWithArgs ["start"] diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs index 4e82d4d399..46a7a099bc 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs @@ -4,12 +4,12 @@ module Wasp.Generator.WebAppGenerator.Setup where import StrongPath (Abs, Dir, Path', ()) -import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.Common (ProjectRootDir, buildNpmCmdWithArgs) import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import Wasp.Generator.Job.Process (runNodeDependentCommandAsJob) import qualified Wasp.Generator.WebAppGenerator.Common as Common installNpmDependencies :: Path' Abs (Dir ProjectRootDir) -> J.Job installNpmDependencies projectDir = do let webAppDir = projectDir Common.webAppRootDirInProjectRootDir - runNodeCommandAsJob webAppDir "npm" ["install"] J.WebApp + runNodeDependentCommandAsJob J.WebApp webAppDir $ buildNpmCmdWithArgs ["install"] diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs index 188560ee81..d44d0eaebc 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs @@ -4,12 +4,12 @@ module Wasp.Generator.WebAppGenerator.Start where import StrongPath (Abs, Dir, Path', ()) -import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.Common (ProjectRootDir, buildNpmCmdWithArgs) import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import Wasp.Generator.Job.Process (runNodeDependentCommandAsJob) import qualified Wasp.Generator.WebAppGenerator.Common as Common startWebApp :: Path' Abs (Dir ProjectRootDir) -> J.Job startWebApp projectDir = do let webAppDir = projectDir Common.webAppRootDirInProjectRootDir - runNodeCommandAsJob webAppDir "npm" ["start"] J.WebApp + runNodeDependentCommandAsJob J.WebApp webAppDir $ buildNpmCmdWithArgs ["start"] diff --git a/waspc/src/Wasp/Util/Encoding.hs b/waspc/src/Wasp/Util/Encoding.hs new file mode 100644 index 0000000000..066d314997 --- /dev/null +++ b/waspc/src/Wasp/Util/Encoding.hs @@ -0,0 +1,57 @@ +{-# LANGUAGE TypeApplications #-} + +module Wasp.Util.Encoding + ( decodeWithTE, + decodeWithTELenient, + encodeWithTE, + ) +where + +import Control.DeepSeq (force) +import Control.Exception (Exception (displayException), SomeException, evaluate, try) +import Data.Bifunctor (first) +import Data.ByteString (ByteString) +import qualified Data.ByteString as BS +import Data.List (isSuffixOf) +import qualified GHC.Foreign as GHC +import GHC.IO (unsafePerformIO) +import GHC.IO.Encoding (mkTextEncoding) +import GHC.IO.Encoding.Types (TextEncoding (textEncodingName)) + +-- decodeWithTE and encodeWithTE are modeled by this reddit comment: +-- https://www.reddit.com/r/haskell/comments/vrw476/comment/iezwd0t/?utm_source=share&utm_medium=web2x&context=3 +-- which points to this code in Filepath (with some small modifications): +-- https://gitlab.haskell.org/haskell/filepath/-/blob/master/System/OsPath/Encoding/Internal.hs#L298 + +type EncodingError = String + +-- | Decode with the given 'TextEncoding'. +decodeWithTE :: TextEncoding -> ByteString -> Either EncodingError String +decodeWithTE enc bs = unsafePerformIO $ do + decodedOrError <- decodeWithTEIO enc bs + evaluate $ force decodedOrError + +-- | Same like decodeWithTE, but it will choose a replacement character for illegal sequences or code points. +decodeWithTELenient :: TextEncoding -> ByteString -> String +decodeWithTELenient enc bs = unsafePerformIO $ do + encTranslit <- mkTextEncoding $ textEncodingName enc `addSuffixIfMissing` "//TRANSLIT" + decodedOrError <- decodeWithTEIO encTranslit bs + evaluate $ force $ fromRight' error decodedOrError + +decodeWithTEIO :: TextEncoding -> ByteString -> IO (Either EncodingError String) +decodeWithTEIO enc bs = do + decodedStrOrError <- try @SomeException $ BS.useAsCStringLen bs $ GHC.peekCStringLen enc + return $ first displayException decodedStrOrError + +-- | Encode with the given 'TextEncoding'. +encodeWithTE :: TextEncoding -> String -> Either EncodingError ByteString +encodeWithTE enc str = unsafePerformIO $ do + encodedBsOrError <- try @SomeException $ GHC.withCStringLen enc str BS.packCStringLen + evaluate $ force $ first displayException encodedBsOrError + +fromRight' :: (a -> b) -> Either a b -> b +fromRight' f (Left x) = f x +fromRight' _ (Right x) = x + +addSuffixIfMissing :: (Eq a) => [a] -> [a] -> [a] +addSuffixIfMissing t suffix = if suffix `isSuffixOf` t then t else t <> suffix diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index dd0bd5bd9f..12ee398c45 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -108,6 +108,7 @@ library , fsnotify ^>= 0.3.0 , http-conduit ^>= 2.3.8 , uuid ^>= 1.3.15 + , deepseq ^>= 1.4.4 -- 'array' is used by code generated by Alex for src/Analyzer/Parser/Lexer.x , array ^>= 0.5.4 other-modules: Paths_waspc @@ -235,6 +236,7 @@ library Wasp.SemanticVersion Wasp.Util Wasp.Util.Control.Monad + Wasp.Util.Encoding Wasp.Util.Fib Wasp.Util.IO Wasp.Util.Terminal