diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 000000000..9aead6369 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/db" + "github.com/charmbracelet/soft-serve/pkg/hooks" + "github.com/charmbracelet/soft-serve/pkg/store" + "github.com/charmbracelet/soft-serve/pkg/store/database" + "github.com/spf13/cobra" +) + +// InitBackendContext initializes the backend context. +func InitBackendContext(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + cfg := config.FromContext(ctx) + if _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil { + return fmt.Errorf("create data directory: %w", err) + } + } + dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + + ctx = db.WithContext(ctx, dbx) + dbstore := database.New(ctx, dbx) + ctx = store.WithContext(ctx, dbstore) + be := backend.New(ctx, cfg, dbx) + ctx = backend.WithContext(ctx, be) + + cmd.SetContext(ctx) + + return nil +} + +// CloseDBContext closes the database context. +func CloseDBContext(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + dbx := db.FromContext(ctx) + if dbx != nil { + if err := dbx.Close(); err != nil { + return fmt.Errorf("close database: %w", err) + } + } + + return nil +} + +// InitializeHooks initializes the hooks. +func InitializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error { + repos, err := be.Repositories(ctx) + if err != nil { + return err + } + + for _, repo := range repos { + if err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/soft/admin.go b/cmd/soft/admin/admin.go similarity index 61% rename from cmd/soft/admin.go rename to cmd/soft/admin/admin.go index 16d31f278..7138f5754 100644 --- a/cmd/soft/admin.go +++ b/cmd/soft/admin/admin.go @@ -1,17 +1,19 @@ -package main +package admin import ( "fmt" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/migrate" + "github.com/charmbracelet/soft-serve/cmd" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/db" + "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/spf13/cobra" ) var ( - adminCmd = &cobra.Command{ + // Command is the admin command. + Command = &cobra.Command{ Use: "admin", Short: "Administrate the server", } @@ -19,8 +21,8 @@ var ( migrateCmd = &cobra.Command{ Use: "migrate", Short: "Migrate the database to the latest version", - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() db := db.FromContext(ctx) @@ -35,8 +37,8 @@ var ( rollbackCmd = &cobra.Command{ Use: "rollback", Short: "Rollback the database to the previous version", - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() db := db.FromContext(ctx) @@ -51,13 +53,13 @@ var ( syncHooksCmd = &cobra.Command{ Use: "sync-hooks", Short: "Update repository hooks", - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, + RunE: func(c *cobra.Command, _ []string) error { + ctx := c.Context() cfg := config.FromContext(ctx) be := backend.FromContext(ctx) - if err := initializeHooks(ctx, cfg, be); err != nil { + if err := cmd.InitializeHooks(ctx, cfg, be); err != nil { return fmt.Errorf("initialize hooks: %w", err) } @@ -67,7 +69,7 @@ var ( ) func init() { - adminCmd.AddCommand( + Command.AddCommand( syncHooksCmd, migrateCmd, rollbackCmd, diff --git a/cmd/soft/browse.go b/cmd/soft/browse/browse.go similarity index 95% rename from cmd/soft/browse.go rename to cmd/soft/browse/browse.go index 8cb760198..43fa3e612 100644 --- a/cmd/soft/browse.go +++ b/cmd/soft/browse/browse.go @@ -1,4 +1,4 @@ -package main +package browse import ( "fmt" @@ -9,15 +9,16 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/proto" - "github.com/charmbracelet/soft-serve/server/ui/common" - "github.com/charmbracelet/soft-serve/server/ui/components/footer" - "github.com/charmbracelet/soft-serve/server/ui/pages/repo" + "github.com/charmbracelet/soft-serve/pkg/proto" + "github.com/charmbracelet/soft-serve/pkg/ui/common" + "github.com/charmbracelet/soft-serve/pkg/ui/components/footer" + "github.com/charmbracelet/soft-serve/pkg/ui/pages/repo" "github.com/muesli/termenv" "github.com/spf13/cobra" ) -var browseCmd = &cobra.Command{ +// Command is the browse command. +var Command = &cobra.Command{ Use: "browse PATH", Short: "Browse a repository", Args: cobra.MaximumNArgs(1), @@ -72,7 +73,6 @@ func init() { // HACK: This is a hack to hide the clone url // TODO: Make this configurable common.CloneCmd = func(publicURL, name string) string { return "" } - rootCmd.AddCommand(browseCmd) } type state int diff --git a/cmd/soft/hook.go b/cmd/soft/hook/hook.go similarity index 68% rename from cmd/soft/hook.go rename to cmd/soft/hook/hook.go index 1bb32b4ea..43718ed22 100644 --- a/cmd/soft/hook.go +++ b/cmd/soft/hook/hook.go @@ -1,4 +1,4 @@ -package main +package hook import ( "bufio" @@ -13,9 +13,10 @@ import ( "strings" "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/hooks" + "github.com/charmbracelet/soft-serve/cmd" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/hooks" "github.com/spf13/cobra" ) @@ -26,23 +27,24 @@ var ( // Deprecated: this flag is ignored. configPath string - hookCmd = &cobra.Command{ + // Command is the hook command. + Command = &cobra.Command{ Use: "hook", Short: "Run git server hooks", Long: "Handles Soft Serve git server hooks.", Hidden: true, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - logger := log.FromContext(cmd.Context()) - if err := initBackendContext(cmd, args); err != nil { + PersistentPreRunE: func(c *cobra.Command, args []string) error { + logger := log.FromContext(c.Context()) + if err := cmd.InitBackendContext(c, args); err != nil { logger.Error("failed to initialize backend context", "err", err) return ErrInternalServerError } return nil }, - PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - logger := log.FromContext(cmd.Context()) - if err := closeDBContext(cmd, args); err != nil { + PersistentPostRunE: func(c *cobra.Command, args []string) error { + logger := log.FromContext(c.Context()) + if err := cmd.CloseDBContext(c, args); err != nil { logger.Error("failed to close backend", "err", err) return ErrInternalServerError } @@ -147,8 +149,8 @@ var ( ) func init() { - hookCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to config file (deprecated)") - hookCmd.AddCommand( + Command.PersistentFlags().StringVar(&configPath, "config", "", "path to config file (deprecated)") + Command.AddCommand( preReceiveCmd, updateCmd, postReceiveCmd, @@ -163,47 +165,3 @@ func runCommand(ctx context.Context, in io.Reader, out io.Writer, err io.Writer, cmd.Stderr = err return cmd.Run() } - -const updateHookExample = `#!/bin/sh -# -# An example hook script to echo information about the push -# and send it to the client. -# -# To enable this hook, rename this file to "update" and make it executable. - -refname="$1" -oldrev="$2" -newrev="$3" - -# Safety check -if [ -z "$GIT_DIR" ]; then - echo "Don't run this script from the command line." >&2 - echo " (if you want, you could supply GIT_DIR then run" >&2 - echo " $0 )" >&2 - exit 1 -fi - -if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then - echo "usage: $0 " >&2 - exit 1 -fi - -# Check types -# if $newrev is 0000...0000, it's a commit to delete a ref. -zero=$(git hash-object --stdin = 0 { - if err := sb.SetAnonAccess(ctx, anon); err != nil { - fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err) - } - } - - // Copy repos - if reposPath != "" { - logger.Info("Copying repos...") - if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), os.ModePerm); err != nil { - return fmt.Errorf("failed to create repos directory: %w", err) - } - - dirs, err := os.ReadDir(reposPath) - if err != nil { - return fmt.Errorf("failed to read repos directory: %w", err) - } - - for _, dir := range dirs { - if !dir.IsDir() || dir.Name() == "config" { - continue - } - - if !isGitDir(filepath.Join(reposPath, dir.Name())) { - continue - } - - logger.Infof(" Copying repo %s", dir.Name()) - src := filepath.Join(reposPath, utils.SanitizeRepo(dir.Name())) - dst := filepath.Join(cfg.DataPath, "repos", utils.SanitizeRepo(dir.Name())) + ".git" - if err := os.MkdirAll(dst, os.ModePerm); err != nil { - return fmt.Errorf("failed to create repo directory: %w", err) - } - - if err := copyDir(src, dst); err != nil { - return fmt.Errorf("failed to copy repo: %w", err) - } - - if _, err := sb.CreateRepository(ctx, dir.Name(), nil, proto.RepositoryOptions{}); err != nil { - fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err) - } - } - - if hasReadme { - logger.Infof(" Copying readme from \"config\" to \".soft-serve\"") - - // Switch to main branch - bcmd := git.NewCommand("branch", "-M", "main") - - rp := filepath.Join(cfg.DataPath, "repos", ".soft-serve.git") - nr, err := git.Init(rp, true) - if err != nil { - return fmt.Errorf("failed to init repo: %w", err) - } - - if _, err := nr.SymbolicRef("HEAD", gitm.RefsHeads+"main"); err != nil { - return fmt.Errorf("failed to set HEAD: %w", err) - } - - tmpDir, err := os.MkdirTemp("", "soft-serve") - if err != nil { - return fmt.Errorf("failed to create temp dir: %w", err) - } - - r, err := git.Init(tmpDir, false) - if err != nil { - return fmt.Errorf("failed to clone repo: %w", err) - } - - if _, err := bcmd.RunInDir(tmpDir); err != nil { - return fmt.Errorf("failed to create main branch: %w", err) - } - - if err := os.WriteFile(filepath.Join(tmpDir, readmePath), []byte(readme), 0o644); err != nil { // nolint: gosec - return fmt.Errorf("failed to write readme: %w", err) - } - - if err := r.Add(gitm.AddOptions{ - All: true, - }); err != nil { - return fmt.Errorf("failed to add readme: %w", err) - } - - if err := r.Commit(&gitm.Signature{ - Name: "Soft Serve", - Email: "vt100@charm.sh", - When: time.Now(), - }, "Add readme"); err != nil { - return fmt.Errorf("failed to commit readme: %w", err) - } - - if err := r.RemoteAdd("origin", "file://"+rp); err != nil { - return fmt.Errorf("failed to add remote: %w", err) - } - - if err := r.Push("origin", "main"); err != nil { - return fmt.Errorf("failed to push readme: %w", err) - } - - // Create `.soft-serve` repository and add readme - if _, err := sb.CreateRepository(ctx, ".soft-serve", nil, proto.RepositoryOptions{ - ProjectName: "Home", - Description: "Soft Serve home repository", - Hidden: true, - Private: false, - }); err != nil { - fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err) - } - } - } - - // Set repos metadata & collabs - logger.Info("Setting repos metadata & collabs...") - for _, r := range ocfg.Repos { - repo, name := r.Repo, r.Name - // Special case for config repo - if repo == "config" { - repo = ".soft-serve" - r.Private = false - } - - if err := sb.SetProjectName(ctx, repo, name); err != nil { - logger.Errorf("failed to set repo name to %s: %s", repo, err) - } - - if err := sb.SetDescription(ctx, repo, r.Note); err != nil { - logger.Errorf("failed to set repo description to %s: %s", repo, err) - } - - if err := sb.SetPrivate(ctx, repo, r.Private); err != nil { - logger.Errorf("failed to set repo private to %s: %s", repo, err) - } - - for _, collab := range r.Collabs { - if err := sb.AddCollaborator(ctx, repo, collab, access.ReadWriteAccess); err != nil { - logger.Errorf("failed to add repo collab to %s: %s", repo, err) - } - } - } - - // Create users & collabs - logger.Info("Creating users & collabs...") - for _, user := range ocfg.Users { - keys := make(map[string]ssh.PublicKey) - for _, key := range user.PublicKeys { - pk, _, err := sshutils.ParseAuthorizedKey(key) - if err != nil { - continue - } - ak := sshutils.MarshalAuthorizedKey(pk) - keys[ak] = pk - } - - pubkeys := make([]ssh.PublicKey, 0) - for _, pk := range keys { - pubkeys = append(pubkeys, pk) - } - - username := strings.ToLower(user.Name) - username = strings.ReplaceAll(username, " ", "-") - logger.Infof("Creating user %q", username) - if _, err := sb.CreateUser(ctx, username, proto.UserOptions{ - Admin: user.Admin, - PublicKeys: pubkeys, - }); err != nil { - logger.Errorf("failed to create user: %s", err) - } - - for _, repo := range user.CollabRepos { - if err := sb.AddCollaborator(ctx, repo, username, access.ReadWriteAccess); err != nil { - logger.Errorf("failed to add user collab to %s: %s\n", repo, err) - } - } - } - - logger.Info("Writing config...") - defer logger.Info("Done!") - return cfg.WriteConfig() - }, -} - -// Returns true if path is a directory containing an `objects` directory and a -// `HEAD` file. -func isGitDir(path string) bool { - stat, err := os.Stat(filepath.Join(path, "objects")) - if err != nil { - return false - } - if !stat.IsDir() { - return false - } - - stat, err = os.Stat(filepath.Join(path, "HEAD")) - if err != nil { - return false - } - if stat.IsDir() { - return false - } - - return true -} - -// copyFile copies a single file from src to dst. -func copyFile(src, dst string) error { - var err error - var srcfd *os.File - var dstfd *os.File - var srcinfo os.FileInfo - - if srcfd, err = os.Open(src); err != nil { - return err - } - defer srcfd.Close() // nolint: errcheck - - if dstfd, err = os.Create(dst); err != nil { - return err - } - defer dstfd.Close() // nolint: errcheck - - if _, err = io.Copy(dstfd, srcfd); err != nil { - return err - } - if srcinfo, err = os.Stat(src); err != nil { - return err - } - return os.Chmod(dst, srcinfo.Mode()) -} - -// copyDir copies a whole directory recursively. -func copyDir(src string, dst string) error { - var err error - var fds []os.DirEntry - var srcinfo os.FileInfo - - if srcinfo, err = os.Stat(src); err != nil { - return err - } - - if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil { - return err - } - - if fds, err = os.ReadDir(src); err != nil { - return err - } - - for _, fd := range fds { - srcfp := filepath.Join(src, fd.Name()) - dstfp := filepath.Join(dst, fd.Name()) - - if fd.IsDir() { - if err = copyDir(srcfp, dstfp); err != nil { - err = errors.Join(err, err) - } - } else { - if err = copyFile(srcfp, dstfp); err != nil { - err = errors.Join(err, err) - } - } - } - - return err -} - -// Config is the configuration for the server. -type Config struct { - Name string `yaml:"name" json:"name"` - Host string `yaml:"host" json:"host"` - Port int `yaml:"port" json:"port"` - AnonAccess string `yaml:"anon-access" json:"anon-access"` - AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"` - Users []User `yaml:"users" json:"users"` - Repos []RepoConfig `yaml:"repos" json:"repos"` -} - -// User contains user-level configuration for a repository. -type User struct { - Name string `yaml:"name" json:"name"` - Admin bool `yaml:"admin" json:"admin"` - PublicKeys []string `yaml:"public-keys" json:"public-keys"` - CollabRepos []string `yaml:"collab-repos" json:"collab-repos"` -} - -// RepoConfig is a repository configuration. -type RepoConfig struct { - Name string `yaml:"name" json:"name"` - Repo string `yaml:"repo" json:"repo"` - Note string `yaml:"note" json:"note"` - Private bool `yaml:"private" json:"private"` - Readme string `yaml:"readme" json:"readme"` - Collabs []string `yaml:"collabs" json:"collabs"` -} diff --git a/cmd/soft/serve.go b/cmd/soft/serve/serve.go similarity index 55% rename from cmd/soft/serve.go rename to cmd/soft/serve/serve.go index 0111f311a..7f81dcf0f 100644 --- a/cmd/soft/serve.go +++ b/cmd/soft/serve/serve.go @@ -1,4 +1,4 @@ -package main +package serve import ( "context" @@ -9,26 +9,26 @@ import ( "syscall" "time" - "github.com/charmbracelet/soft-serve/server" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/soft-serve/server/db" - "github.com/charmbracelet/soft-serve/server/db/migrate" - "github.com/charmbracelet/soft-serve/server/hooks" + "github.com/charmbracelet/soft-serve/cmd" + "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/config" + "github.com/charmbracelet/soft-serve/pkg/db" + "github.com/charmbracelet/soft-serve/pkg/db/migrate" "github.com/spf13/cobra" ) var ( syncHooks bool - serveCmd = &cobra.Command{ + // Command is the serve command. + Command = &cobra.Command{ Use: "serve", Short: "Start the server", Args: cobra.NoArgs, - PersistentPreRunE: initBackendContext, - PersistentPostRunE: closeDBContext, - RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() + PersistentPreRunE: cmd.InitBackendContext, + PersistentPostRunE: cmd.CloseDBContext, + RunE: func(c *cobra.Command, _ []string) error { + ctx := c.Context() cfg := config.DefaultConfig() if cfg.Exist() { if err := cfg.ParseFile(); err != nil { @@ -67,14 +67,14 @@ var ( return fmt.Errorf("migration error: %w", err) } - s, err := server.NewServer(ctx) + s, err := NewServer(ctx) if err != nil { return fmt.Errorf("start server: %w", err) } if syncHooks { be := backend.FromContext(ctx) - if err := initializeHooks(ctx, cfg, be); err != nil { + if err := cmd.InitializeHooks(ctx, cfg, be); err != nil { return fmt.Errorf("initialize hooks: %w", err) } } @@ -103,20 +103,49 @@ var ( ) func init() { - serveCmd.Flags().BoolVarP(&syncHooks, "sync-hooks", "", false, "synchronize hooks for all repositories before running the server") + Command.Flags().BoolVarP(&syncHooks, "sync-hooks", "", false, "synchronize hooks for all repositories before running the server") } -func initializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error { - repos, err := be.Repositories(ctx) - if err != nil { - return err - } - - for _, repo := range repos { - if err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil { - return err - } - } - - return nil -} +const updateHookExample = `#!/bin/sh +# +# An example hook script to echo information about the push +# and send it to the client. +# +# To enable this hook, rename this file to "update" and make it executable. + +refname="$1" +oldrev="$2" +newrev="$3" + +# Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin