diff --git a/Justfile b/Justfile index 19c2928bcb..2ec5a77973 100644 --- a/Justfile +++ b/Justfile @@ -9,7 +9,7 @@ KT_RUNTIME_RUNNER_TEMPLATE_OUT := "build/template/ftl/jars/ftl-runtime.jar" RUNNER_TEMPLATE_ZIP := "backend/controller/scaling/localscaling/template.zip" TIMESTAMP := `date +%s` SCHEMA_OUT := "backend/protos/xyz/block/ftl/v1/schema/schema.proto" -ZIP_DIRS := "go-runtime/compile/build-template go-runtime/compile/external-module-template go-runtime/scaffolding kotlin-runtime/scaffolding kotlin-runtime/external-module-template" +ZIP_DIRS := "go-runtime/compile/build-template go-runtime/compile/external-module-template common-runtime/scaffolding go-runtime/scaffolding kotlin-runtime/scaffolding kotlin-runtime/external-module-template" FRONTEND_OUT := "frontend/dist/index.html" EXTENSION_OUT := "extensions/vscode/dist/extension.js" PROTOS_IN := "backend/protos/xyz/block/ftl/v1/schema/schema.proto backend/protos/xyz/block/ftl/v1/console/console.proto backend/protos/xyz/block/ftl/v1/ftl.proto backend/protos/xyz/block/ftl/v1/schema/runtime.proto" diff --git a/README.md b/README.md index 3294f4e5ce..a48d866271 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ diff -u <( ```sh file=test.sh region=init mkdir myproject cd myproject -ftl init go . alice +ftl init . +ftl new go . alice ``` ### Build and deploy the module diff --git a/buildengine/watch_test.go b/buildengine/watch_test.go index 5d32d2e845..ac975a8fed 100644 --- a/buildengine/watch_test.go +++ b/buildengine/watch_test.go @@ -34,9 +34,11 @@ func TestWatch(t *testing.T) { // Initiate two modules err := gitInit(dir) assert.NoError(t, err) - err = ftl("init", "go", dir, "one") + err = ftl("init", dir) assert.NoError(t, err) - err = ftl("init", "go", dir, "two") + err = ftl("new", "go", dir, "one") + assert.NoError(t, err) + err = ftl("new", "go", dir, "two") assert.NoError(t, err) one := loadModule(t, dir, "one") @@ -74,7 +76,9 @@ func TestWatchWithBuildModifyingFiles(t *testing.T) { // Initiate a module err := gitInit(dir) assert.NoError(t, err) - err = ftl("init", "go", dir, "one") + err = ftl("init", dir) + assert.NoError(t, err) + err = ftl("new", "go", dir, "one") assert.NoError(t, err) events, topic := startWatching(ctx, t, w, dir) @@ -109,7 +113,9 @@ func TestWatchWithBuildAndUserModifyingFiles(t *testing.T) { // Initiate a module err := gitInit(dir) assert.NoError(t, err) - err = ftl("init", "go", dir, "one") + err = ftl("init", dir) + assert.NoError(t, err) + err = ftl("new", "go", dir, "one") assert.NoError(t, err) one := loadModule(t, dir, "one") diff --git a/cmd/ftl/cmd_init.go b/cmd/ftl/cmd_init.go index 6e3c0076e9..852912a11b 100644 --- a/cmd/ftl/cmd_init.go +++ b/cmd/ftl/cmd_init.go @@ -1,157 +1,83 @@ package main import ( - "archive/zip" "bufio" "context" "fmt" - "go/token" - "html/template" "os" "path" - "path/filepath" - "regexp" "strings" - "github.com/TBD54566975/scaffolder" - - "github.com/TBD54566975/ftl/backend/schema" - "github.com/TBD54566975/ftl/backend/schema/strcase" - "github.com/TBD54566975/ftl/buildengine" + "github.com/TBD54566975/ftl" + commonruntime "github.com/TBD54566975/ftl/common-runtime" "github.com/TBD54566975/ftl/common/projectconfig" - goruntime "github.com/TBD54566975/ftl/go-runtime" "github.com/TBD54566975/ftl/internal" "github.com/TBD54566975/ftl/internal/exec" "github.com/TBD54566975/ftl/internal/log" - kotlinruntime "github.com/TBD54566975/ftl/kotlin-runtime" ) type initCmd struct { - Hermit bool `help:"Include Hermit language-specific toolchain binaries in the module." negatable:""` - Go initGoCmd `cmd:"" help:"Initialize a new FTL Go module."` - Kotlin initKotlinCmd `cmd:"" help:"Initialize a new FTL Kotlin module."` -} - -type initGoCmd struct { - Replace map[string]string `short:"r" help:"Replace a module import path with a local path in the initialised FTL module." placeholder:"OLD=NEW,..." env:"FTL_INIT_GO_REPLACE"` - Dir string `arg:"" help:"Directory to initialize the module in."` - Name string `arg:"" help:"Name of the FTL module to create underneath the base directory."` + Hermit bool `help:"Include Hermit language-specific toolchain binaries." negatable:""` + Dir string `arg:"" help:"Directory to initialize the project in."` + ExternalDirs []string `help:"Directories of existing external modules."` + ModuleDirs []string `help:"Child directories of existing modules."` + NoGit bool `help:"Don't add files to the git repository."` + Startup string `help:"Command to run on startup."` } -func isValidModuleName(name string) bool { - validNamePattern := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`) - if !validNamePattern.MatchString(name) { - return false - } - if token.Lookup(name).IsKeyword() { - return false - } - return true -} - -func (i initGoCmd) Run(ctx context.Context, parent *initCmd) error { - if i.Name == "" { - i.Name = filepath.Base(i.Dir) - } - - // Validate the module name with custom validation - if !isValidModuleName(i.Name) { - return fmt.Errorf("module name %q must be a valid Go module name and not a reserved keyword", i.Name) - } - - if !schema.ValidateName(i.Name) { - return fmt.Errorf("module name %q is invalid", i.Name) - } - - if _, ok := internal.GitRoot(i.Dir).Get(); !ok { - return fmt.Errorf("directory %s is not in a git repository, run 'git init' at the root of your project", i.Dir) +func (i initCmd) Run(ctx context.Context) error { + if i.Dir == "" { + return fmt.Errorf("directory is required") } logger := log.FromContext(ctx) - logger.Debugf("Initializing FTL Go module %s in %s", i.Name, i.Dir) - if err := scaffold(parent.Hermit, goruntime.Files(), i.Dir, i, scaffolder.Exclude("^go.mod$")); err != nil { - return err - } - if err := updateGitIgnore(i.Dir); err != nil { - return err - } - if err := projectconfig.MaybeCreateDefault(ctx); err != nil { + logger.Debugf("Initializing FTL project in %s", i.Dir) + if err := scaffold(ctx, i.Hermit, commonruntime.Files(), i.Dir, i); err != nil { return err } - logger.Debugf("Running go mod tidy") - return exec.Command(ctx, log.Debug, filepath.Join(i.Dir, i.Name), "go", "mod", "tidy").RunBuffered(ctx) -} - -type initKotlinCmd struct { - GroupID string `short:"g" help:"Base Maven group ID (defaults to \"ftl\")." default:"ftl"` - ArtifactID string `short:"a" help:"Base Maven artifact ID (defaults to \"ftl\")." default:"ftl"` - Dir string `arg:"" help:"Directory to initialize the module in."` - Name string `arg:"" help:"Name of the FTL module to create underneath the base directory."` -} - -func (i initKotlinCmd) Run(ctx context.Context, parent *initCmd) error { - if i.Name == "" { - i.Name = filepath.Base(i.Dir) - } - - if !schema.ValidateName(i.Name) { - return fmt.Errorf("module name %q is invalid", i.Name) - } - moduleDir := filepath.Join(i.Dir, i.Name) - if _, err := os.Stat(moduleDir); err == nil { - return fmt.Errorf("module directory %s already exists", filepath.Join(i.Dir, i.Name)) + config := projectconfig.Config{ + Hermit: i.Hermit, + NoGit: i.NoGit, + FTLMinVersion: ftl.Version, + ExternalDirs: i.ExternalDirs, + ModuleDirs: i.ModuleDirs, + Commands: projectconfig.Commands{ + Startup: []string{i.Startup}, + }, } - - if err := scaffold(parent.Hermit, kotlinruntime.Files(), i.Dir, i); err != nil { + if err := projectconfig.Create(ctx, config, i.Dir); err != nil { return err } - return buildengine.SetPOMProperties(ctx, moduleDir) -} - -func unzipToTmpDir(reader *zip.Reader) (string, error) { - tmpDir, err := os.MkdirTemp("", "ftl-init-*") - if err != nil { - return "", err - } - err = internal.UnzipDir(reader, tmpDir) - if err != nil { - return "", err + gitRoot, ok := internal.GitRoot(i.Dir).Get() + if !i.NoGit && ok { + logger.Debugf("Updating .gitignore") + if err := updateGitIgnore(ctx, gitRoot); err != nil { + return err + } + logger.Debugf("Adding files to git") + if i.Hermit { + if err := maybeGitAdd(ctx, i.Dir, "bin/*"); err != nil { + return err + } + } + if err := maybeGitAdd(ctx, i.Dir, "ftl-project.toml"); err != nil { + return err + } } - return tmpDir, nil + return nil } -func scaffold(hermit bool, source *zip.Reader, destination string, ctx any, options ...scaffolder.Option) error { - opts := []scaffolder.Option{scaffolder.Functions(scaffoldFuncs), scaffolder.Exclude("^go.mod$")} - if !hermit { - opts = append(opts, scaffolder.Exclude("^bin")) - } - opts = append(opts, options...) - if err := internal.ScaffoldZip(source, destination, ctx, opts...); err != nil { - return fmt.Errorf("failed to scaffold: %w", err) +func maybeGitAdd(ctx context.Context, dir string, paths ...string) error { + args := append([]string{"add"}, paths...) + if err := exec.Command(ctx, log.Debug, dir, "git", args...).RunBuffered(ctx); err != nil { + return err } return nil } -var scaffoldFuncs = template.FuncMap{ - "snake": strcase.ToLowerSnake, - "screamingSnake": strcase.ToUpperSnake, - "camel": strcase.ToUpperCamel, - "lowerCamel": strcase.ToLowerCamel, - "kebab": strcase.ToLowerKebab, - "screamingKebab": strcase.ToUpperKebab, - "upper": strings.ToUpper, - "lower": strings.ToLower, - "title": strings.Title, - "typename": schema.TypeName, -} - -func updateGitIgnore(dir string) error { - gitRoot, ok := internal.GitRoot(dir).Get() - if !ok { - return nil - } +func updateGitIgnore(ctx context.Context, gitRoot string) error { f, err := os.OpenFile(path.Join(gitRoot, ".gitignore"), os.O_RDWR|os.O_CREATE, 0644) //nolint:gosec if err != nil { return err @@ -170,6 +96,10 @@ func updateGitIgnore(dir string) error { } // append if not already present - _, err = f.WriteString("**/_ftl\n") - return err + if _, err = f.WriteString("**/_ftl\n"); err != nil { + return err + } + + // Add .gitignore to git + return maybeGitAdd(ctx, gitRoot, ".gitignore") } diff --git a/cmd/ftl/cmd_new.go b/cmd/ftl/cmd_new.go new file mode 100644 index 0000000000..75596ef4ad --- /dev/null +++ b/cmd/ftl/cmd_new.go @@ -0,0 +1,172 @@ +package main + +import ( + "archive/zip" + "context" + "fmt" + "go/token" + "html/template" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/TBD54566975/scaffolder" + + "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/backend/schema/strcase" + "github.com/TBD54566975/ftl/buildengine" + "github.com/TBD54566975/ftl/common/projectconfig" + goruntime "github.com/TBD54566975/ftl/go-runtime" + "github.com/TBD54566975/ftl/internal" + "github.com/TBD54566975/ftl/internal/exec" + "github.com/TBD54566975/ftl/internal/log" + kotlinruntime "github.com/TBD54566975/ftl/kotlin-runtime" +) + +type newCmd struct { + Go newGoCmd `cmd:"" help:"Initialize a new FTL Go module."` + Kotlin newKotlinCmd `cmd:"" help:"Initialize a new FTL Kotlin module."` +} + +type newGoCmd struct { + Replace map[string]string `short:"r" help:"Replace a module import path with a local path in the initialised FTL module." placeholder:"OLD=NEW,..." env:"FTL_INIT_GO_REPLACE"` + Dir string `arg:"" help:"Directory to initialize the module in."` + Name string `arg:"" help:"Name of the FTL module to create underneath the base directory."` +} + +type newKotlinCmd struct { + Dir string `arg:"" help:"Directory to initialize the module in."` + Name string `arg:"" help:"Name of the FTL module to create underneath the base directory."` +} + +func (i newGoCmd) Run(ctx context.Context) error { + name, path, err := validateModule(i.Dir, i.Name) + if err != nil { + return err + } + + // Validate the module name with custom validation + if !isValidGoModuleName(name) { + return fmt.Errorf("module name %q must be a valid Go module name and not a reserved keyword", name) + } + + config, err := projectconfig.Load(ctx, "") + if err != nil { + return fmt.Errorf("failed to load project config: %w", err) + } + + logger := log.FromContext(ctx) + logger.Debugf("Creating FTL Go module %q in %s", name, path) + if err := scaffold(ctx, config.Hermit, goruntime.Files(), i.Dir, i, scaffolder.Exclude("^go.mod$")); err != nil { + return err + } + + logger.Debugf("Running go mod tidy") + if err := exec.Command(ctx, log.Debug, path, "go", "mod", "tidy").RunBuffered(ctx); err != nil { + return err + } + + logger.Debugf("Adding files to git") + if !config.NoGit { + if config.Hermit { + if err := maybeGitAdd(ctx, i.Dir, "bin/*"); err != nil { + return err + } + } + if err := maybeGitAdd(ctx, i.Dir, filepath.Join(path, "*")); err != nil { + return err + } + } + return nil +} + +func (i newKotlinCmd) Run(ctx context.Context) error { + name, path, err := validateModule(i.Dir, i.Name) + if err != nil { + return err + } + + config, err := projectconfig.Load(ctx, "") + if err != nil { + return fmt.Errorf("failed to load project config: %w", err) + } + + logger := log.FromContext(ctx) + logger.Debugf("Creating FTL Kotlin module %q in %s", name, path) + if err := scaffold(ctx, config.Hermit, kotlinruntime.Files(), i.Dir, i); err != nil { + return err + } + + if err := buildengine.SetPOMProperties(ctx, path); err != nil { + return err + } + + logger.Debugf("Adding files to git") + if !config.NoGit { + if config.Hermit { + if err := maybeGitAdd(ctx, i.Dir, "bin/*"); err != nil { + return err + } + } + if err := maybeGitAdd(ctx, i.Dir, filepath.Join(path, "*")); err != nil { + return err + } + } + return nil +} + +func validateModule(dir string, name string) (string, string, error) { + if dir == "" { + return "", "", fmt.Errorf("directory is required") + } + if name == "" { + name = filepath.Base(dir) + } + if !schema.ValidateName(name) { + return "", "", fmt.Errorf("module name %q is invalid", name) + } + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + return "", "", fmt.Errorf("module directory %s already exists", path) + } + return name, path, nil +} + +func isValidGoModuleName(name string) bool { + validNamePattern := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`) + if !validNamePattern.MatchString(name) { + return false + } + if token.Lookup(name).IsKeyword() { + return false + } + return true +} + +func scaffold(ctx context.Context, includeBinDir bool, source *zip.Reader, destination string, sctx any, options ...scaffolder.Option) error { + logger := log.FromContext(ctx) + opts := []scaffolder.Option{scaffolder.Functions(scaffoldFuncs), scaffolder.Exclude("^go.mod$")} + if !includeBinDir { + logger.Debugf("Excluding bin directory") + opts = append(opts, scaffolder.Exclude("^bin")) + } + opts = append(opts, options...) + if err := internal.ScaffoldZip(source, destination, sctx, opts...); err != nil { + return fmt.Errorf("failed to scaffold: %w", err) + } + return nil +} + +var scaffoldFuncs = template.FuncMap{ + "snake": strcase.ToLowerSnake, + "screamingSnake": strcase.ToUpperSnake, + "camel": strcase.ToUpperCamel, + "lowerCamel": strcase.ToLowerCamel, + "kebab": strcase.ToLowerKebab, + "screamingKebab": strcase.ToUpperKebab, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "title": strings.Title, + "typename": schema.TypeName, +} diff --git a/cmd/ftl/main.go b/cmd/ftl/main.go index e22d58f44d..21819ed41b 100644 --- a/cmd/ftl/main.go +++ b/cmd/ftl/main.go @@ -34,7 +34,8 @@ type CLI struct { Ping pingCmd `cmd:"" help:"Ping the FTL cluster."` Status statusCmd `cmd:"" help:"Show FTL status."` - Init initCmd `cmd:"" help:"Initialize a new FTL module."` + Init initCmd `cmd:"" help:"Initialize a new FTL project."` + New newCmd `cmd:"" help:"Create a new FTL module."` Dev devCmd `cmd:"" help:"Develop FTL modules. Will start the FTL cluster, build and deploy all modules found in the specified directories, and watch for changes."` PS psCmd `cmd:"" help:"List deployments."` Serve serveCmd `cmd:"" help:"Start the FTL server."` diff --git a/common-runtime/devel.go b/common-runtime/devel.go new file mode 100644 index 0000000000..ebdb5eb65d --- /dev/null +++ b/common-runtime/devel.go @@ -0,0 +1,12 @@ +//go:build !release + +package goruntime + +import ( + "archive/zip" + + "github.com/TBD54566975/ftl/internal" +) + +// Files is the FTL Go runtime scaffolding files. +func Files() *zip.Reader { return internal.ZipRelativeToCaller("scaffolding") } diff --git a/common-runtime/release.go b/common-runtime/release.go new file mode 100644 index 0000000000..4857c28c5d --- /dev/null +++ b/common-runtime/release.go @@ -0,0 +1,21 @@ +//go:build release + +package goruntime + +import ( + "archive/zip" + "bytes" + _ "embed" +) + +//go:embed scaffolding.zip +var archive []byte + +// Files is the FTL Go runtime scaffolding files. +func Files() *zip.Reader { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + panic(err) + } + return zr +} diff --git a/common-runtime/scaffolding/bin/.ftl@latest.pkg b/common-runtime/scaffolding/bin/.ftl@latest.pkg new file mode 100755 index 0000000000..7fef769248 --- /dev/null +++ b/common-runtime/scaffolding/bin/.ftl@latest.pkg @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/common-runtime/scaffolding/bin/README.hermit.md b/common-runtime/scaffolding/bin/README.hermit.md new file mode 100644 index 0000000000..e889550ba4 --- /dev/null +++ b/common-runtime/scaffolding/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/common-runtime/scaffolding/bin/activate-hermit b/common-runtime/scaffolding/bin/activate-hermit new file mode 100755 index 0000000000..fe28214d33 --- /dev/null +++ b/common-runtime/scaffolding/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/common-runtime/scaffolding/bin/ftl b/common-runtime/scaffolding/bin/ftl new file mode 100755 index 0000000000..7fef769248 --- /dev/null +++ b/common-runtime/scaffolding/bin/ftl @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/common-runtime/scaffolding/bin/ftl-controller b/common-runtime/scaffolding/bin/ftl-controller new file mode 100755 index 0000000000..7fef769248 --- /dev/null +++ b/common-runtime/scaffolding/bin/ftl-controller @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/common-runtime/scaffolding/bin/ftl-initdb b/common-runtime/scaffolding/bin/ftl-initdb new file mode 100755 index 0000000000..7fef769248 --- /dev/null +++ b/common-runtime/scaffolding/bin/ftl-initdb @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/common-runtime/scaffolding/bin/ftl-runner b/common-runtime/scaffolding/bin/ftl-runner new file mode 100755 index 0000000000..7fef769248 --- /dev/null +++ b/common-runtime/scaffolding/bin/ftl-runner @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/common-runtime/scaffolding/bin/hermit b/common-runtime/scaffolding/bin/hermit new file mode 100755 index 0000000000..7fef769248 --- /dev/null +++ b/common-runtime/scaffolding/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/common-runtime/scaffolding/bin/hermit.hcl b/common-runtime/scaffolding/bin/hermit.hcl new file mode 100644 index 0000000000..624421a07c --- /dev/null +++ b/common-runtime/scaffolding/bin/hermit.hcl @@ -0,0 +1 @@ +sources = ["https://github.com/TBD54566975/hermit-ftl.git", "https://github.com/cashapp/hermit-packages.git"] diff --git a/common-runtime/scaffolding/go.mod b/common-runtime/scaffolding/go.mod new file mode 100644 index 0000000000..2464bc5512 --- /dev/null +++ b/common-runtime/scaffolding/go.mod @@ -0,0 +1,4 @@ +// This needs to exist so that the Go toolchain doesn't include this directory. Annoying. +module exclude + +go 1.22.2 diff --git a/common/projectconfig/projectconfig.go b/common/projectconfig/projectconfig.go index 68ad7a66eb..d015262955 100644 --- a/common/projectconfig/projectconfig.go +++ b/common/projectconfig/projectconfig.go @@ -35,6 +35,8 @@ type Config struct { ExternalDirs []string `toml:"external-dirs"` Commands Commands `toml:"commands"` FTLMinVersion string `toml:"ftl-min-version"` + Hermit bool `toml:"hermit"` + NoGit bool `toml:"no-git"` } // Root directory of the project. @@ -99,24 +101,24 @@ func DefaultConfigPath() optional.Option[string] { return optional.Some(filepath.Join(dir, "ftl-project.toml")) } -// MaybeCreateDefault creates the ftl-project.toml file in the Git root if it -// does not already exist. -func MaybeCreateDefault(ctx context.Context) error { +// Create creates the ftl-project.toml file with the given Config into dir. +func Create(ctx context.Context, config Config, dir string) error { logger := log.FromContext(ctx) - path, ok := DefaultConfigPath().Get() - if !ok { - logger.Warnf("Failed to find Git root, so cannot verify whether an ftl-project.toml file exists there") - return nil + path, err := filepath.Abs(dir) + if err != nil { + return err } - _, err := os.Stat(path) + path = filepath.Join(path, "ftl-project.toml") + _, err = os.Stat(path) if err == nil { - return nil + return fmt.Errorf("project config file already exists at %q", path) } if !errors.Is(err, os.ErrNotExist) { return err } logger.Debugf("Creating a new project config file at %q", path) - return Save(Config{Path: path}) + config.Path = path + return Save(config) } // LoadOrCreate loads or creates the given configuration file. diff --git a/docs/content/docs/getting-started/quick-start/index.md b/docs/content/docs/getting-started/quick-start/index.md index 76a3f48e37..b2a5fb5946 100644 --- a/docs/content/docs/getting-started/quick-start/index.md +++ b/docs/content/docs/getting-started/quick-start/index.md @@ -50,14 +50,24 @@ The [FTL VSCode extension](https://marketplace.visualstudio.com/items?itemName=F ## Development -### Create a new module +### Intitialize an FTL project -Once FTL is installed, create a new module: +Once FTL is installed, initialize an FTL project: ``` mkdir myproject cd myproject -ftl init go . alice +ftl init . --hermit +``` + +This will create an `ftl-project.toml` file, a git repository, and a `bin/` directory with Hermit tooling. + +### Create a new module + +Now that you have an FTL project, create a new module: + +``` +ftl new go . alice ``` This will place the code for the new module `alice` in `myproject/alice/alice.go`: diff --git a/go-runtime/ftl/ftl_integration_test.go b/go-runtime/ftl/ftl_integration_test.go index e1cab001de..75d336bc96 100644 --- a/go-runtime/ftl/ftl_integration_test.go +++ b/go-runtime/ftl/ftl_integration_test.go @@ -15,7 +15,9 @@ import ( func TestLifecycle(t *testing.T) { in.Run(t, "", in.GitInit(), - in.Exec("ftl", "init", "go", ".", "echo"), + in.Exec("rm", "ftl-project.toml"), + in.Exec("ftl", "init", "."), + in.Exec("ftl", "new", "go", ".", "echo"), in.Deploy("echo"), in.Call("echo", "echo", in.Obj{"name": "Bob"}, func(t testing.TB, response in.Obj) { assert.Equal(t, "Hello, Bob!", response["message"])