diff --git a/cmd/prepare_worker.go b/cmd/prepare_worker.go index dca38a5..5501698 100644 --- a/cmd/prepare_worker.go +++ b/cmd/prepare_worker.go @@ -10,8 +10,8 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/temporalio/features/sdkbuild" "github.com/temporalio/omes/cmd/cmdoptions" + "github.com/temporalio/omes/sdkbuild" "go.uber.org/zap" ) diff --git a/cmd/run_worker.go b/cmd/run_worker.go index 8c9c7e5..a672012 100644 --- a/cmd/run_worker.go +++ b/cmd/run_worker.go @@ -14,9 +14,9 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/temporalio/features/sdkbuild" "github.com/temporalio/omes/cmd/cmdoptions" "github.com/temporalio/omes/loadgen" + "github.com/temporalio/omes/sdkbuild" "go.temporal.io/sdk/client" "go.temporal.io/sdk/testsuite" ) diff --git a/go.mod b/go.mod index 9bf8222..3513995 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,18 @@ go 1.20 require ( github.com/gogo/protobuf v1.3.2 github.com/golang/protobuf v1.5.3 + github.com/otiai10/copy v1.14.0 github.com/prometheus/client_golang v1.16.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 - github.com/temporalio/features v0.0.0-20231218231852-27c681667dae go.temporal.io/api v1.26.1 - go.temporal.io/sdk v1.25.2-0.20231129171107-288a04f72145 + go.temporal.io/sdk v1.25.2-0.20240109200522-5ca9a4dfd4c3 go.uber.org/zap v1.25.0 - golang.org/x/mod v0.12.0 + golang.org/x/mod v0.14.0 golang.org/x/sync v0.5.0 golang.org/x/sys v0.15.0 - google.golang.org/protobuf v1.31.0 + google.golang.org/protobuf v1.32.0 ) require ( @@ -36,7 +36,6 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/otiai10/copy v1.14.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/robfig/cron v1.2.0 // indirect @@ -44,19 +43,13 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.uber.org/goleak v1.2.1 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/grpc v1.59.0 // indirect + google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 // indirect + google.golang.org/grpc v1.60.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -// This is dumb, but necesary because Go (for some commands) can't figure out the transitive -// local-replace inside of the features module itself, so we have to help it. -replace ( - github.com/temporalio/features/features => github.com/temporalio/features/features v0.0.0-20231218231852-27c681667dae - github.com/temporalio/features/harness/go => github.com/temporalio/features/harness/go v0.0.0-20231218231852-27c681667dae -) diff --git a/go.sum b/go.sum index 107fd44..cba8055 100644 --- a/go.sum +++ b/go.sum @@ -98,15 +98,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/temporalio/features v0.0.0-20231218231852-27c681667dae h1:d5LK3X10VZEWpLhZ5dIPcirvELKVtT4rEV+8wzfgBRM= -github.com/temporalio/features v0.0.0-20231218231852-27c681667dae/go.mod h1:Jm0Yq8DKEkSzcQ1YbZ5yeqrD6iyyWzQMcsXF0G1ylM4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.temporal.io/api v1.26.1 h1:YqGQsOr/Tx4nVdA8wCv74AxesaIzCRHWb3KkHrYqI8k= go.temporal.io/api v1.26.1/go.mod h1:Y/rALXTprFO+bvAlAfLFoJj7KpQIcL4GDQVN6fhYIa4= -go.temporal.io/sdk v1.25.2-0.20231129171107-288a04f72145 h1:aV7tRpzB3tr9LGs4/SN7MSWSbVx+bgDYfOoGMjk4oEM= -go.temporal.io/sdk v1.25.2-0.20231129171107-288a04f72145/go.mod h1:MHw8PEOVmOJC1yduTVxYq1GsM5kkQg0sIwRST7cRHoo= +go.temporal.io/sdk v1.25.2-0.20240109200522-5ca9a4dfd4c3 h1:XzkvOc0UATBM0SC2SO/GKaXq3JkJwe2rDeRhW2u11zM= +go.temporal.io/sdk v1.25.2-0.20240109200522-5ca9a4dfd4c3/go.mod h1:nRT6pheoo7UXrrgMh26r7t4IuJFZmu277SkaiVp/tZE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= @@ -123,6 +121,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= +golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -130,8 +130,8 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -190,23 +190,23 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 h1:W12Pwm4urIbRdGhMEg2NM9O3TWKjNcxQhs46V0ypf/k= -google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic= -google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 h1:ZcOkrmX74HbKFYnpPY8Qsw93fC29TbJXspYKaBkSXDQ= -google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= +google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917 h1:nz5NESFLZbJGPFxDT/HCn+V1mZ8JGNoY4nUpmW/Y2eg= +google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917/go.mod h1:pZqR+glSb11aJ+JQcczCvgf47+duRuzNSKqE8YAQnV0= +google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM= +google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU= +google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/sdkbuild/dotnet.go b/sdkbuild/dotnet.go new file mode 100644 index 0000000..8e10137 --- /dev/null +++ b/sdkbuild/dotnet.go @@ -0,0 +1,144 @@ +package sdkbuild + +import ( + "context" + "fmt" + "html" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// BuildDotNetProgramOptions are options for BuildDotNetProgram. +type BuildDotNetProgramOptions struct { + // Directory that will have a temporary directory created underneath. + BaseDir string + // Required version. If it contains a slash, it is assumed to be a path to the + // base of the repo (and will have a src/Temporalio/Temporalio.csproj child). + // Otherwise it is a NuGet version. + Version string + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + // Required Program.cs content. If not set, no Program.cs is created (so it) + ProgramContents string + // Required csproj content. This should not contain a dependency on Temporalio + // because this adds a package/project reference near the end. + CsprojContents string +} + +// DotNetProgram is a .NET-specific implementation of Program. +type DotNetProgram struct { + dir string +} + +var _ Program = (*DotNetProgram)(nil) + +func BuildDotNetProgram(ctx context.Context, options BuildDotNetProgramOptions) (*DotNetProgram, error) { + if options.BaseDir == "" { + return nil, fmt.Errorf("base dir required") + } else if options.Version == "" { + return nil, fmt.Errorf("version required") + } else if options.ProgramContents == "" { + return nil, fmt.Errorf("program contents required") + } else if options.CsprojContents == "" { + return nil, fmt.Errorf("csproj contents required") + } + + // Create temp dir if needed that we will remove if creating is unsuccessful + success := false + var dir string + if options.DirName != "" { + dir = filepath.Join(options.BaseDir, options.DirName) + } else { + var err error + dir, err = os.MkdirTemp(options.BaseDir, "program-") + if err != nil { + return nil, fmt.Errorf("failed making temp dir: %w", err) + } + defer func() { + if !success { + // Intentionally swallow error + _ = os.RemoveAll(dir) + } + }() + } + + // Create program.csproj + var depLine string + // Slash means it is a path + if strings.ContainsAny(options.Version, `/\`) { + // Get absolute path of csproj file + absCsproj, err := filepath.Abs(filepath.Join(options.Version, "src/Temporalio/Temporalio.csproj")) + if err != nil { + return nil, fmt.Errorf("cannot make absolute path from version: %w", err) + } else if _, err := os.Stat(absCsproj); err != nil { + return nil, fmt.Errorf("cannot find version path of %v: %w", absCsproj, err) + } + depLine = `` + // Need to build this csproj first + cmd := exec.CommandContext(ctx, "dotnet", "build", absCsproj) + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed dotnet build of csproj in version: %w", err) + } + } else { + depLine = `` + } + // Add the item group for the Temporalio dep just before the ending project tag + endProjectTag := strings.LastIndex(options.CsprojContents, "") + if endProjectTag == -1 { + return nil, fmt.Errorf("no ending project tag found in csproj contents") + } + csproj := options.CsprojContents[:endProjectTag] + "\n \n " + depLine + + "\n \n" + options.CsprojContents[endProjectTag:] + if err := os.WriteFile(filepath.Join(dir, "program.csproj"), []byte(csproj), 0644); err != nil { + return nil, fmt.Errorf("failed writing program.csproj: %w", err) + } + + // Create Program.cs + if err := os.WriteFile(filepath.Join(dir, "Program.cs"), []byte(options.ProgramContents), 0644); err != nil { + return nil, fmt.Errorf("failed writing Program.cs: %w", err) + } + + // Build it into build folder + cmd := exec.CommandContext(ctx, "dotnet", "build", "--output", "build") + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed dotnet build: %w", err) + } + + // All good + success = true + return &DotNetProgram{dir}, nil +} + +// DotNetProgramFromDir recreates the Go program from a Dir() result of a +// BuildDotNetProgram(). +func DotNetProgramFromDir(dir string) (*DotNetProgram, error) { + // Quick sanity check on the presence of program.csproj + if _, err := os.Stat(filepath.Join(dir, "program.csproj")); err != nil { + return nil, fmt.Errorf("failed finding program.csproj in dir: %w", err) + } + return &DotNetProgram{dir}, nil +} + +// Dir is the directory to run in. +func (d *DotNetProgram) Dir() string { return d.dir } + +// NewCommand makes a new command for the given args. +func (d *DotNetProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + exe := "./build/program" + if runtime.GOOS == "windows" { + exe += ".exe" + } + cmd := exec.CommandContext(ctx, exe, args...) + cmd.Dir = d.dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd, nil +} diff --git a/sdkbuild/go.go b/sdkbuild/go.go new file mode 100644 index 0000000..e7c9874 --- /dev/null +++ b/sdkbuild/go.go @@ -0,0 +1,173 @@ +package sdkbuild + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const sdkImport = "go.temporal.io/sdk" + +// BuildGoProgramOptions are options for BuildGoProgram. +type BuildGoProgramOptions struct { + // Directory that will have a temporary directory created underneath + BaseDir string + // If not set, not put in go.mod which means go mod tidy will automatically + // use latest. If set and does not start with a "v", it is assumed to be a + // path, otherwise it is a specific version. + Version string + // The SDK Repository import to use. If unspecified we default to go.temporal.io/sdk + // If specified version must also be provided + SDKRepository string + // Required go.mod contents + GoModContents string + // Required main.go contents + GoMainContents string + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + // Optional set of tags to build with + GoBuildTags []string + // If present, applied to build commands before run. May be called multiple + // times for a single build. + ApplyToCommand func(context.Context, *exec.Cmd) error +} + +// GoProgram is a Go-specific implementation of Program. +type GoProgram struct { + dir string +} + +var _ Program = (*GoProgram)(nil) + +// BuildGoProgram builds a Go program. If completed successfully, this can be +// stored and re-obtained via GoProgramFromDir() with the Dir() value. +func BuildGoProgram(ctx context.Context, options BuildGoProgramOptions) (*GoProgram, error) { + if options.BaseDir == "" { + return nil, fmt.Errorf("base dir required") + } else if options.GoModContents == "" { + return nil, fmt.Errorf("go.mod contents required") + } else if options.GoMainContents == "" { + return nil, fmt.Errorf("main.go contents required") + } + + // Create temp dir if needed that we will remove if creating is unsuccessful + success := false + var dir string + if options.DirName != "" { + dir = filepath.Join(options.BaseDir, options.DirName) + } else { + var err error + dir, err = os.MkdirTemp(options.BaseDir, "program-") + if err != nil { + return nil, fmt.Errorf("failed making temp dir: %w", err) + } + defer func() { + if !success { + // Intentionally swallow error + _ = os.RemoveAll(dir) + } + }() + } + + // Create go.mod + goMod := options.GoModContents + // If a version is specified, overwrite the SDK to use that + if options.Version != "" || options.SDKRepository != "" { + // If version does not start with a "v" we assume path unless the SDK repository is provided + if options.SDKRepository != "" { + if options.Version == "" { + return nil, errors.New("Version must be provided alongside SDKRepository") + } + goMod += fmt.Sprintf("\nreplace %s => %s %s", sdkImport, options.SDKRepository, options.Version) + } else if strings.HasPrefix(options.Version, "v") { + goMod += fmt.Sprintf("\nreplace %s => %s %s", sdkImport, sdkImport, options.Version) + } else { + absVersion, err := filepath.Abs(options.Version) + if err != nil { + return nil, fmt.Errorf("version does not start with 'v' and cannot get abs dir: %w", err) + } + relVersion, err := filepath.Rel(dir, absVersion) + if err != nil { + return nil, fmt.Errorf("version does not start with 'v' and unable to relativize: %w", err) + } + goMod += fmt.Sprintf("\nreplace %s => %s", sdkImport, filepath.ToSlash(relVersion)) + } + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644); err != nil { + return nil, fmt.Errorf("failed writing go.mod: %w", err) + } + + // Create main.go + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(options.GoMainContents), 0644); err != nil { + return nil, fmt.Errorf("failed writing main.go: %w", err) + } + + // Tidy it + cmd := exec.CommandContext(ctx, "go", "mod", "tidy") + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if options.ApplyToCommand != nil { + if err := options.ApplyToCommand(ctx, cmd); err != nil { + return nil, err + } + } + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed go mod tidy: %w", err) + } + + // Build it + exe := "program" + if runtime.GOOS == "windows" { + exe += ".exe" + } + cmdArgs := []string{"build", "-o", exe} + for _, tag := range options.GoBuildTags { + cmdArgs = append(cmdArgs, "-tags", tag) + } + cmd = exec.CommandContext(ctx, "go", cmdArgs...) + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if options.ApplyToCommand != nil { + if err := options.ApplyToCommand(ctx, cmd); err != nil { + return nil, err + } + } + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed go build: %w", err) + } + + // All good + success = true + return &GoProgram{dir}, nil +} + +// GoProgramFromDir recreates the Go program from a Dir() result of a +// BuildGoProgram(). +func GoProgramFromDir(dir string) (*GoProgram, error) { + // Quick sanity check on the presence of go.mod + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { + return nil, fmt.Errorf("failed finding go.mod in dir: %w", err) + } + return &GoProgram{dir}, nil +} + +// Dir is the directory to run in. +func (g *GoProgram) Dir() string { return g.dir } + +// NewCommand makes a new command for the given args. +func (g *GoProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + exe := "./program" + if runtime.GOOS == "windows" { + exe += ".exe" + } + cmd := exec.CommandContext(ctx, exe, args...) + cmd.Dir = g.dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd, nil +} diff --git a/sdkbuild/java.go b/sdkbuild/java.go new file mode 100644 index 0000000..d814a4f --- /dev/null +++ b/sdkbuild/java.go @@ -0,0 +1,212 @@ +package sdkbuild + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/otiai10/copy" +) + +// BuildJavaProgramOptions are options for BuildJavaProgram. +type BuildJavaProgramOptions struct { + // Directory that will have a temporary directory created underneath. This + // should be a gradle project with a build.gradle, a gradlew executable, etc. + BaseDir string + // If not set, not put in build.gradle which means gradle will automatically + // use latest. If set and contains a slash it is assumed to be a path, + // otherwise it is a specific version (with leading "v" is trimmed if + // present). + Version string + // Required Gradle "implementation" dependency name of BaseDir. This is + // usually "::" with each value replaced with + // proper values. + HarnessDependency string + // Required fully-qualified class name for main. + MainClass string + // If true, performs an eager build. This is often just to prime system-level + // caches and do extra validation, the build won't be used by NewCommand. + Build bool + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + // If present, applied to build commands before run. May be called multiple + // times for a single build. + ApplyToCommand func(context.Context, *exec.Cmd) error +} + +// JavaProgram is a Java-specific implementation of Program. +type JavaProgram struct { + dir string +} + +var _ Program = (*JavaProgram)(nil) + +// BuildJavaProgram builds a Java program. If completed successfully, this can +// be stored and re-obtained via JavaProgramFromDir() with the Dir() value (but +// the entire BaseDir must be present too). +func BuildJavaProgram(ctx context.Context, options BuildJavaProgramOptions) (*JavaProgram, error) { + if options.BaseDir == "" { + return nil, fmt.Errorf("base dir required") + } else if _, err := os.Stat(filepath.Join(options.BaseDir, "build.gradle")); err != nil { + return nil, fmt.Errorf("failed finding build.gradle in base dir: %w", err) + } else if options.HarnessDependency == "" { + return nil, fmt.Errorf("harness dependency required") + } else if options.MainClass == "" { + return nil, fmt.Errorf("main class required") + } + + // Create temp dir if needed that we will remove if creating is unsuccessful + success := false + var dir string + if options.DirName != "" { + dir = filepath.Join(options.BaseDir, options.DirName) + } else { + var err error + dir, err = os.MkdirTemp(options.BaseDir, "program-") + if err != nil { + return nil, fmt.Errorf("failed making temp dir: %w", err) + } + defer func() { + if !success { + // Intentionally swallow error + _ = os.RemoveAll(dir) + } + }() + } + j := &JavaProgram{dir} + + // If we depend on SDK via path, built it and get the JAR + isPathDep := strings.ContainsAny(options.Version, `/\`) + if isPathDep { + cmd := j.buildGradleCommand(ctx, options.Version, false, options.ApplyToCommand, "jar", "gatherRuntimeDeps") + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed building Java SDK: %w", err) + } + // Copy JARs to sdkjars dir + sdkJarsDir := filepath.Join(dir, "sdkjars") + if err := copy.Copy(filepath.Join(options.Version, "temporal-sdk/build/libs"), sdkJarsDir); err != nil { + return nil, fmt.Errorf("failed copying lib JARs: %w", err) + } + if err := copy.Copy(filepath.Join(options.Version, "temporal-sdk/build/runtimeDeps"), sdkJarsDir); err != nil { + return nil, fmt.Errorf("failed copying runtime JARs: %w", err) + } + } + + // Create build.gradle and settings.gradle + temporalSDKDependency := "" + if isPathDep { + temporalSDKDependency = "implementation fileTree(dir: 'sdkjars', include: ['*.jar'])" + } else if options.Version != "" { + temporalSDKDependency = fmt.Sprintf("implementation 'io.temporal:temporal-sdk:%v'", + strings.TrimPrefix(options.Version, "v")) + } + buildGradle := ` +plugins { + id 'application' +} + +repositories { + maven { + url "https://oss.sonatype.org/content/repositories/snapshots/" + } + mavenCentral() +} + +dependencies { + implementation '` + options.HarnessDependency + `' + ` + temporalSDKDependency + ` +} + +application { + mainClass = '` + options.MainClass + `' +}` + if err := os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(buildGradle), 0644); err != nil { + return nil, fmt.Errorf("failed writing build.gradle: %w", err) + } + settingsGradle := fmt.Sprintf("rootProject.name = '%v'", filepath.Base(dir)) + if err := os.WriteFile(filepath.Join(dir, "settings.gradle"), []byte(settingsGradle), 0644); err != nil { + return nil, fmt.Errorf("failed writing settings.gradle: %w", err) + } + + // Build if wanted + if options.Build { + // This is really only to prime the system-level caches. The build won't be + // used by run. + cmd := j.buildGradleCommand(ctx, dir, true, + options.ApplyToCommand, "--no-daemon", "--include-build", "../", "build") + if err := cmd.Run(); err != nil { + return nil, err + } + } + + success = true + return j, nil +} + +// JavaProgramFromDir recreates the Java program from a Dir() result of a +// BuildJavaProgram(). Note, the base directory of dir when it was built must +// also be present. +func JavaProgramFromDir(dir string) (*JavaProgram, error) { + // Quick sanity check on the presence of build.gradle here _and_ in base + if _, err := os.Stat(filepath.Join(dir, "build.gradle")); err != nil { + return nil, fmt.Errorf("failed finding build.gradle in dir: %w", err) + } else if _, err := os.Stat(filepath.Join(dir, "../build.gradle")); err != nil { + return nil, fmt.Errorf("failed finding build.gradle in base dir: %w", err) + } + return &JavaProgram{dir}, nil +} + +// Dir is the directory to run in. +func (j *JavaProgram) Dir() string { return j.dir } + +// NewCommand makes a new command for the given args. +func (j *JavaProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + // Since args have to be a string, we disallow quotes + var argsStr string + for _, arg := range args { + if strings.ContainsAny(arg, `"'`) { + return nil, fmt.Errorf("java argument cannot contain single or double quote") + } + if argsStr != "" { + argsStr += " " + } + argsStr += "'" + arg + "'" + } + return j.buildGradleCommand(ctx, j.dir, true, nil, "--include-build", "../", "run", "--args", argsStr), nil +} + +func (j *JavaProgram) buildGradleCommand( + ctx context.Context, + dir string, + gradleInParentDir bool, + applyToCommand func(context.Context, *exec.Cmd) error, + args ...string, +) *exec.Cmd { + // Prepare exe whether windows or not + var exe string + if runtime.GOOS == "windows" { + exe = "cmd.exe" + if gradleInParentDir { + args = append([]string{"/C", "..\\gradlew"}, args...) + } else { + args = append([]string{"/C", "gradlew"}, args...) + } + } else { + exe = "/bin/sh" + if gradleInParentDir { + args = append([]string{"../gradlew"}, args...) + } else { + args = append([]string{"gradlew"}, args...) + } + } + + cmd := exec.CommandContext(ctx, exe, args...) + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd +} diff --git a/sdkbuild/python.go b/sdkbuild/python.go new file mode 100644 index 0000000..37ca72e --- /dev/null +++ b/sdkbuild/python.go @@ -0,0 +1,154 @@ +package sdkbuild + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +// BuildPythonProgramOptions are options for BuildPythonProgram. +type BuildPythonProgramOptions struct { + // Directory that will have a temporary directory created underneath. This + // should be a Poetry project with a pyproject.toml. + BaseDir string + // Required version. If it contains a slash it is assumed to be a path with + // a single wheel in the dist directory. Otherwise it is a specific version + // (with leading "v" is trimmed if present). + Version string + // Required Poetry dependency name of BaseDir. + DependencyName string + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + // If present, applied to build commands before run. May be called multiple + // times for a single build. + ApplyToCommand func(context.Context, *exec.Cmd) error +} + +// PythonProgram is a Python-specific implementation of Program. +type PythonProgram struct { + dir string +} + +var _ Program = (*PythonProgram)(nil) + +// BuildPythonProgram builds a Python program. If completed successfully, this +// can be stored and re-obtained via PythonProgramFromDir() with the Dir() value +// (but the entire BaseDir must be present too). +func BuildPythonProgram(ctx context.Context, options BuildPythonProgramOptions) (*PythonProgram, error) { + if options.BaseDir == "" { + return nil, fmt.Errorf("base dir required") + } else if options.Version == "" { + return nil, fmt.Errorf("version required") + } else if _, err := os.Stat(filepath.Join(options.BaseDir, "pyproject.toml")); err != nil { + return nil, fmt.Errorf("failed finding pyproject.toml in base dir: %w", err) + } else if options.DependencyName == "" { + return nil, fmt.Errorf("dependency name required") + } + + // Create temp dir if needed that we will remove if creating is unsuccessful + success := false + var dir string + if options.DirName != "" { + dir = filepath.Join(options.BaseDir, options.DirName) + } else { + var err error + dir, err = os.MkdirTemp(options.BaseDir, "program-") + if err != nil { + return nil, fmt.Errorf("failed making temp dir: %w", err) + } + defer func() { + if !success { + // Intentionally swallow error + _ = os.RemoveAll(dir) + } + }() + } + + // Use semantic version or path if it's a path + versionStr := strconv.Quote(strings.TrimPrefix(options.Version, "v")) + if strings.ContainsAny(options.Version, `/\`) { + // We expect a dist/ directory with a single whl file present + wheels, err := filepath.Glob(filepath.Join(options.Version, "dist/*.whl")) + if err != nil { + return nil, fmt.Errorf("failed glob wheel lookup: %w", err) + } else if len(wheels) != 1 { + return nil, fmt.Errorf("expected single dist wheel, found %v", wheels) + } + absWheel, err := filepath.Abs(wheels[0]) + if err != nil { + return nil, fmt.Errorf("unable to make wheel path absolute: %w", err) + } + // There's a strange bug in Poetry or somewhere deeper where, on Windows, + // the single drive letter has to be capitalized + if runtime.GOOS == "windows" && absWheel[1] == ':' { + absWheel = strings.ToUpper(absWheel[:1]) + absWheel[1:] + } + versionStr = "{ path = " + strconv.Quote(absWheel) + " }" + } + pyProjectTOML := ` +[tool.poetry] +name = "python-program-` + filepath.Base(dir) + `" +version = "0.1.0" +description = "Temporal SDK Python Test" +authors = ["Temporal Technologies Inc "] + +[tool.poetry.dependencies] +python = "^3.8" +temporalio = ` + versionStr + ` +` + options.DependencyName + ` = { path = "../" } + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"` + if err := os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(pyProjectTOML), 0644); err != nil { + return nil, fmt.Errorf("failed writing pyproject.toml: %w", err) + } + + // Install + cmd := exec.CommandContext(ctx, "poetry", "install", "--no-dev", "--no-root", "-v") + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if options.ApplyToCommand != nil { + if err := options.ApplyToCommand(ctx, cmd); err != nil { + return nil, err + } + } + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed installing: %w", err) + } + + success = true + return &PythonProgram{dir}, nil +} + +// PythonProgramFromDir recreates the Python program from a Dir() result of a +// BuildPythonProgram(). Note, the base directory of dir when it was built must +// also be present. +func PythonProgramFromDir(dir string) (*PythonProgram, error) { + // Quick sanity check on the presence of pyproject.toml here _and_ in base + if _, err := os.Stat(filepath.Join(dir, "pyproject.toml")); err != nil { + return nil, fmt.Errorf("failed finding pyproject.toml in dir: %w", err) + } else if _, err := os.Stat(filepath.Join(dir, "../pyproject.toml")); err != nil { + return nil, fmt.Errorf("failed finding pyproject.toml in base dir: %w", err) + } + return &PythonProgram{dir}, nil +} + +// Dir is the directory to run in. +func (p *PythonProgram) Dir() string { return p.dir } + +// NewCommand makes a new Poetry command. The first argument needs to be the +// name of the module. +func (p *PythonProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + args = append([]string{"run", "python", "-m"}, args...) + cmd := exec.CommandContext(ctx, "poetry", args...) + cmd.Dir = p.dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd, nil +} diff --git a/sdkbuild/sdkbuild.go b/sdkbuild/sdkbuild.go new file mode 100644 index 0000000..e03efbd --- /dev/null +++ b/sdkbuild/sdkbuild.go @@ -0,0 +1,19 @@ +// Package sdkbuild provides helpers to build and run projects with SDKs across +// languages and versions. +package sdkbuild + +import ( + "context" + "os/exec" +) + +// Program is a built SDK program that can be run. +type Program interface { + // Dir is the directory the program is in. If created on the fly, usually this + // temporary directory is deleted after use. + Dir() string + + // NewCommand creates a new command for the program with given args and with + // stdio set as the current stdio. + NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) +} diff --git a/sdkbuild/typescript.go b/sdkbuild/typescript.go new file mode 100644 index 0000000..a49b116 --- /dev/null +++ b/sdkbuild/typescript.go @@ -0,0 +1,263 @@ +package sdkbuild + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// BuildTypeScriptProgramOptions are options for BuildTypeScriptProgram. +type BuildTypeScriptProgramOptions struct { + // Directory that will have a temporary directory created underneath. + BaseDir string + // Required version. If it contains a slash it is assumed to be a path with a + // package.json. Otherwise it is a specific version (with leading "v" is + // trimmed if present). + Version string + // Required set of paths to include in tsconfig.json paths for the project. + // The paths should be relative to one-directory beneath BaseDir. + TSConfigPaths map[string][]string + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + // If present, applied to build commands before run. May be called multiple + // times for a single build. + ApplyToCommand func(context.Context, *exec.Cmd) error + // If present, overrides the default "include" array in tsconfig.json. + Includes []string + // If present, overrides the default "exclude" array in tsconfig.json. + Excludes []string + // If present, add additional dependencies -> version string to package.json. + MoreDependencies map[string]string +} + +// TypeScriptProgram is a TypeScript-specific implementation of Program. +type TypeScriptProgram struct { + dir string +} + +var _ Program = (*TypeScriptProgram)(nil) + +// BuildTypeScriptProgram builds a TypeScript program. If completed +// successfully, this can be stored and re-obtained via +// TypeScriptProgramFromDir() with the Dir() value (but the entire BaseDir must +// be present too). +func BuildTypeScriptProgram(ctx context.Context, options BuildTypeScriptProgramOptions) (*TypeScriptProgram, error) { + if options.BaseDir == "" { + return nil, fmt.Errorf("base dir required") + } else if options.Version == "" { + return nil, fmt.Errorf("version required") + } else if len(options.TSConfigPaths) == 0 { + return nil, fmt.Errorf("at least one tsconfig path required") + } + + // Create temp dir if needed that we will remove if creating is unsuccessful + success := false + var dir string + if options.DirName != "" { + dir = filepath.Join(options.BaseDir, options.DirName) + } else { + var err error + dir, err = os.MkdirTemp(options.BaseDir, "program-") + if err != nil { + return nil, fmt.Errorf("failed making temp dir: %w", err) + } + defer func() { + if !success { + // Intentionally swallow error + _ = os.RemoveAll(dir) + } + }() + } + + // Create package JSON + var packageJSONDepStr string + if strings.ContainsAny(options.Version, `/\`) { + if _, err := os.Stat(filepath.Join(options.Version, "package.json")); err != nil { + return nil, fmt.Errorf("failed finding package.json in version dir: %w", err) + } + + // Have to build the local repo + if st, err := os.Stat(filepath.Join(options.Version, "node_modules")); err != nil || !st.IsDir() { + // Only install dependencies, avoid triggerring any post install build scripts + cmd := exec.CommandContext(ctx, "npm", "ci", "--ignore-scripts") + cmd.Dir = options.Version + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed installing SDK deps: %w", err) + } + + // Build the SDK, ignore the unused `create` package as a mostly insignificant micro optimisation. + cmd = exec.CommandContext(ctx, "npm", "run", "build", "--", "--ignore", "@temporalio/create") + cmd.Dir = options.Version + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed building SDK: %w", err) + } + } + + // Create package.json updates + localPath, err := filepath.Abs(options.Version) + if err != nil { + return nil, fmt.Errorf("cannot get absolute path from version path: %w", err) + } + pkgs := []string{"activity", "client", "common", "internal-workflow-common", + "internal-non-workflow-common", "proto", "worker", "workflow"} + for _, pkg := range pkgs { + pkgPath := "file:" + filepath.Join(localPath, "packages", pkg) + packageJSONDepStr += fmt.Sprintf(`"@temporalio/%v": %q,`, pkg, pkgPath) + packageJSONDepStr += "\n " + } + } else { + version := strings.TrimPrefix(options.Version, "v") + pkgs := []string{"activity", "client", "common", "worker", "workflow"} + for _, pkg := range pkgs { + packageJSONDepStr += fmt.Sprintf(` "@temporalio/%v": %q,`, pkg, version) + "\n" + } + } + moreDeps := "" + for dep, version := range options.MoreDependencies { + moreDeps += fmt.Sprintf(` "%v": "%v",`, dep, version) + "\n" + } + + packageJSON := `{ + "name": "program", + "private": true, + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + ` + packageJSONDepStr + ` + ` + moreDeps + ` + "commander": "^8.3.0", + "ms": "^3.0.0-canary.1", + "proto3-json-serializer": "^1.1.1", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@tsconfig/node16": "^1.0.0", + "@types/node": "^16.11.59", + "@types/uuid": "^8.3.4", + "tsconfig-paths": "^3.12.0", + "typescript": "^4.4.2" + } +}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644); err != nil { + return nil, fmt.Errorf("failed writing package.json: %w", err) + } + + // Create tsconfig + var tsConfigPathStr string + for name, paths := range options.TSConfigPaths { + if len(paths) == 0 { + return nil, fmt.Errorf("harness path slice is empty") + } + tsConfigPathStr += fmt.Sprintf("%q: [", name) + for i, path := range paths { + if i > 0 { + tsConfigPathStr += ", " + } + tsConfigPathStr += strconv.Quote(path) + } + tsConfigPathStr += "],\n " + } + includes := []string{"../features/**/*.ts", "../harness/ts/**/*.ts"} + if len(options.Includes) > 0 { + includes = options.Includes + } + excludes := []string{"../node_modules", "../harness/go", "../harness/java"} + if len(options.Excludes) > 0 { + excludes = options.Excludes + } + quotedIncludes := make([]string, len(includes)) + for i, include := range includes { + quotedIncludes[i] = strconv.Quote(include) + } + quotedExcludes := make([]string, len(excludes)) + for i, exclude := range excludes { + quotedExcludes[i] = strconv.Quote(exclude) + } + tsConfig := `{ + "extends": "@tsconfig/node16/tsconfig.json", + "version": "4.4.2", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./tslib", + "rootDirs": ["../", "."], + "paths": { + ` + tsConfigPathStr + ` + "*": ["node_modules/*", "node_modules/@types/*"] + }, + "typeRoots": ["node_modules/@types"], + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "allowJs": true + }, + "include": [` + strings.Join(quotedIncludes, ", ") + `], + "exclude": [` + strings.Join(quotedExcludes, ", ") + `] +}` + if err := os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte(tsConfig), 0644); err != nil { + return nil, fmt.Errorf("failed writing tsconfig.json: %w", err) + } + + // Install + cmd := exec.CommandContext(ctx, "npm", "install") + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if options.ApplyToCommand != nil { + if err := options.ApplyToCommand(ctx, cmd); err != nil { + return nil, err + } + } + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed installing: %w", err) + } + + // Compile + cmd = exec.CommandContext(ctx, "npm", "run", "build") + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if options.ApplyToCommand != nil { + if err := options.ApplyToCommand(ctx, cmd); err != nil { + return nil, err + } + } + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed compiling: %w", err) + } + + success = true + return &TypeScriptProgram{dir}, nil +} + +// TypeScriptProgramFromDir recreates the TypeScript program from a Dir() result +// of a BuildTypeScriptProgram(). Note, the base directory of dir when it was +// built must also be present. +func TypeScriptProgramFromDir(dir string) (*TypeScriptProgram, error) { + // Quick sanity check on the presence of package.json here + if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil { + return nil, fmt.Errorf("failed finding package.json in dir: %w", err) + } + return &TypeScriptProgram{dir}, nil +} + +// Dir is the directory to run in. +func (t *TypeScriptProgram) Dir() string { return t.dir } + +// NewCommand makes a new Node command. The first argument needs to be the name +// of the script. +func (t *TypeScriptProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + args = append([]string{"-r", "tsconfig-paths/register"}, args...) + cmd := exec.CommandContext(ctx, "node", args...) + cmd.Dir = t.dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd, nil +}