Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Windows NPM #658

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions waspc/src/Wasp/Generator/Common.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ module Wasp.Generator.Common
nodeVersionRange,
npmVersionRange,
prismaVersion,
oSSpecificNpm,
buildNpmCmdWithArgs,
)
where

import Data.List (intercalate)
import System.Info (os)
import qualified Wasp.SemanticVersion as SV

-- | Directory where the whole web app project (client, server, ...) is generated.
Expand Down Expand Up @@ -38,3 +42,16 @@ npmVersionRange =

prismaVersion :: SV.Version
prismaVersion = SV.Version 3 15 2

-- | Adds .cmd extension to callable npm command if ran on Windows, since npm does note have .exe on Windows.
-- The reason explained here: https://stackoverflow.com/questions/43139364/createprocess-weird-behavior-with-files-without-extension
oSSpecificNpm :: String
oSSpecificNpm = "npm" ++ if os /= "mingw32" then "" else ".cmd"

-- | Changes an npm command to a cmd.exe command on Windows only. Calling npm from API causes troubles.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar like above, this is addressing implementation details. I would make sure this comment talks about what function does from the outside, now how it does it, and details can go inside of function. We might not even need this comment if it is clean what it does from the function name + signature.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved comments into implementation

-- The reason and solution exaplined here: https://stackoverflow.com/a/44820337
buildNpmCmdWithArgs :: String -> [String] -> (String, [String])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We said this is to be used only for calling npm. But in that case, we don't need command as input, right? Since we know it is npm?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

buildNpmCmdWithArgs command arguments =
if os /= "mingw32"
then (command, arguments)
else ("cmd.exe", [intercalate " " (["/c", command] ++ arguments)])
27 changes: 18 additions & 9 deletions waspc/src/Wasp/Generator/DbGenerator/Jobs.hs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ 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, oSSpecificNpm, 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 (runCommandThatRequiresNodeAsJob)
import Wasp.Generator.ServerGenerator.Common (serverRootDirInProjectRootDir)

-- `--no-install` is the magic that causes this command to fail if npx cannot find it locally
Expand Down Expand Up @@ -44,13 +44,22 @@ migrateDev projectDir maybeMigrationName = do
-- 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.
-- NOTE: This won't work on Windows, unless they have `script` command installed via cygwin, in which case
-- it will work since it same as on Linux then (Posix).
-- But maybe on Windows it doesn't even need `script`? We haven't tested it yet.
case System.Info.os of
"darwin" -> osxScriptArgs
_ -> posixScriptArgs
where
osxScriptArgs =
-- 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.
posixScriptArgs =
-- NOTE(martin): On Linux, command that `script` should execute is treated as one argument.
-- This should also work on Windows, if `script` command is installed via Cygwin.
["-feqc", unwords npxPrismaMigrateCmd, "/dev/null"]

let job = runNodeCommandAsJob serverDir "script" scriptArgs J.Db
let job = runCommandThatRequiresNodeAsJob serverDir "script" scriptArgs J.Db

retryJobOnErrorWith job (npmInstall projectDir) ForwardEverything

Expand All @@ -61,7 +70,7 @@ runStudio projectDir = do
let schemaFile = projectDir </> dbSchemaFileInProjectRootDir

let npxPrismaStudioCmd = npxPrismaCmd ++ ["studio", "--schema", SP.toFilePath schemaFile]
let job = runNodeCommandAsJob serverDir (head npxPrismaStudioCmd) (tail npxPrismaStudioCmd) J.Db
let job = runCommandThatRequiresNodeAsJob serverDir (head npxPrismaStudioCmd) (tail npxPrismaStudioCmd) J.Db

retryJobOnErrorWith job (npmInstall projectDir) ForwardEverything

Expand All @@ -71,7 +80,7 @@ generatePrismaClient projectDir = do
let schemaFile = projectDir </> dbSchemaFileInProjectRootDir

let npxPrismaGenerateCmd = npxPrismaCmd ++ ["generate", "--schema", SP.toFilePath schemaFile]
let job = runNodeCommandAsJob serverDir (head npxPrismaGenerateCmd) (tail npxPrismaGenerateCmd) J.Db
let job = runCommandThatRequiresNodeAsJob serverDir (head npxPrismaGenerateCmd) (tail npxPrismaGenerateCmd) J.Db

retryJobOnErrorWith job (npmInstall projectDir) ForwardOnlyRetryErrors

Expand All @@ -80,7 +89,7 @@ generatePrismaClient projectDir = do
npmInstall :: Path' Abs (Dir ProjectRootDir) -> J.Job
npmInstall projectDir = do
let serverDir = projectDir </> serverRootDirInProjectRootDir
runNodeCommandAsJob serverDir "npm" ["install"] J.Db
runCommandThatRequiresNodeAsJob serverDir oSSpecificNpm ["install"] J.Db

data JobMessageForwardingStrategy = ForwardEverything | ForwardOnlyRetryErrors

Expand Down
68 changes: 40 additions & 28 deletions waspc/src/Wasp/Generator/Job/Process.hs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@

module Wasp.Generator.Job.Process
( runProcessAsJob,
runNodeCommandAsJob,
runCommandThatRequiresNodeAsJob,
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 Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8)
import Data.Text.Encoding (decodeLatin1, decodeUtf8With)
import GHC.IO.Encoding (TextEncoding, textEncodingName)
import StrongPath (Abs, Dir, Path')
import qualified StrongPath as SP
import System.Exit (ExitCode (..))
import System.IO (latin1, localeEncoding, utf8)
import System.IO.Error (catchIOError, isDoesNotExistError)
import qualified System.Info
import qualified System.Process as P
Expand All @@ -42,29 +46,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 $
Expand All @@ -79,6 +62,20 @@ 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 =
writeChan chan $
J.JobMessage
{ -- Since this is output of a command that was supposed to be shown in the terminal,
-- it is our safest bet to assume it is using locale encoding (default encoding on the machine),
-- instead of assuming it is utf8 (like we do for text files).
-- Take a look at https://serokell.io/blog/haskell-with-utf8 for detailed reasoning.
J._data = J.JobOutput (decodeLocaleEncoding bs) jobOutputType,
J._jobType = jobType
}

-- 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.
Expand All @@ -94,15 +91,30 @@ 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
-- | Decodes given byte string while assuming it is using locale encoding (which is system's default encoding).
decodeLocaleEncoding :: ByteString -> Text
decodeLocaleEncoding = decodeEncoding localeEncoding

-- | Decodes given byte string while assuming it is using provided text encoding.
decodeEncoding :: TextEncoding -> ByteString -> Text
decodeEncoding enc
| textEncodingName enc == textEncodingName latin1 = decodeLatin1
-- This will replace any invalid characters with \xfffd.
| textEncodingName enc == textEncodingName utf8 = decodeUtf8With onErrorUseReplacementChar
| otherwise = error $ "Encoding " ++ textEncodingName localeEncoding ++ " is not supported."
where
onErrorUseReplacementChar _ _ = Just '\xfffd'

runCommandThatRequiresNodeAsJob :: Path' Abs (Dir a) -> String -> [String] -> J.JobType -> J.Job
runCommandThatRequiresNodeAsJob fromDir command args jobType chan = do
errorOrNodeVersion <- getNodeVersion
case errorOrNodeVersion of
Left errorMsg -> exitWithError (ExitFailure 1) (T.pack errorMsg)
Right nodeVersion ->
if SV.isVersionInRange nodeVersion C.nodeVersionRange
then do
let process = (P.proc command args) {P.cwd = Just $ SP.fromAbsDir fromDir}
let (specificCommand, specificArgs) = C.buildNpmCmdWithArgs command args
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we renamed the function to buildNpmCmdWithArgs, and that it is used only for running npm + some args, I don't think we want to call it here anymore, since here we are dealing with any command that requires node installed, which can also be other stuff that is not npm, for example npx.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done)

let process = (P.proc specificCommand specificArgs) {P.cwd = Just $ SP.fromAbsDir fromDir}
runProcessAsJob process jobType chan
else
exitWithError
Expand Down
3 changes: 2 additions & 1 deletion waspc/src/Wasp/Generator/ServerGenerator/JobGenerator.hs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import StrongPath
Posix,
Rel,
parseRelFile,
relFileToPosix,
reldir,
reldirP,
relfile,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions waspc/src/Wasp/Generator/ServerGenerator/Setup.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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, oSSpecificNpm)
import qualified Wasp.Generator.Job as J
import Wasp.Generator.Job.Process (runNodeCommandAsJob)
import Wasp.Generator.Job.Process (runCommandThatRequiresNodeAsJob)
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
runCommandThatRequiresNodeAsJob serverDir oSSpecificNpm ["install"] J.Server
6 changes: 3 additions & 3 deletions waspc/src/Wasp/Generator/ServerGenerator/Start.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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, oSSpecificNpm)
import qualified Wasp.Generator.Job as J
import Wasp.Generator.Job.Process (runNodeCommandAsJob)
import Wasp.Generator.Job.Process (runCommandThatRequiresNodeAsJob)
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
runCommandThatRequiresNodeAsJob serverDir oSSpecificNpm ["start"] J.Server
6 changes: 3 additions & 3 deletions waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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, oSSpecificNpm)
import qualified Wasp.Generator.Job as J
import Wasp.Generator.Job.Process (runNodeCommandAsJob)
import Wasp.Generator.Job.Process (runCommandThatRequiresNodeAsJob)
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
runCommandThatRequiresNodeAsJob webAppDir oSSpecificNpm ["install"] J.WebApp
6 changes: 3 additions & 3 deletions waspc/src/Wasp/Generator/WebAppGenerator/Start.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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, oSSpecificNpm)
import qualified Wasp.Generator.Job as J
import Wasp.Generator.Job.Process (runNodeCommandAsJob)
import Wasp.Generator.Job.Process (runCommandThatRequiresNodeAsJob)
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
runCommandThatRequiresNodeAsJob webAppDir oSSpecificNpm ["start"] J.WebApp