mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
const defaultInstallScriptURL = "https://memoh.sh"
|
|
|
|
func newInstallCommand() *cobra.Command {
|
|
var version string
|
|
var yes bool
|
|
var force bool
|
|
var scriptURL string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "install",
|
|
Short: "Download and run the Memoh install script",
|
|
Long: `Download the official Memoh install script and run it when the
|
|
current directory is not already a Docker Compose deployment.`,
|
|
Example: ` memoh install
|
|
memoh install --yes
|
|
memoh install --version v0.6.0
|
|
USE_CN_MIRROR=true memoh install --yes`,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return runInstall(cmd.Context(), installOptions{
|
|
version: version,
|
|
yes: yes,
|
|
force: force,
|
|
scriptURL: scriptURL,
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&version, "version", "", "Memoh version to install, for example v0.6.0")
|
|
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Run the installer in non-interactive mode")
|
|
cmd.Flags().BoolVar(&force, "force", false, "Run even if the current directory already contains a compose file")
|
|
cmd.Flags().StringVar(&scriptURL, "script-url", defaultInstallScriptURL, "Install script URL")
|
|
_ = cmd.Flags().MarkHidden("script-url")
|
|
|
|
return cmd
|
|
}
|
|
|
|
type installOptions struct {
|
|
version string
|
|
yes bool
|
|
force bool
|
|
scriptURL string
|
|
}
|
|
|
|
func runInstall(ctx context.Context, opts installOptions) error {
|
|
if !opts.force {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve current directory: %w", err)
|
|
}
|
|
|
|
if composeFile, ok, err := findComposeFile(cwd); err != nil {
|
|
return err
|
|
} else if ok {
|
|
return fmt.Errorf("found existing compose file %q in %s; use `memoh start` or rerun with --force", filepath.Base(composeFile), cwd)
|
|
}
|
|
}
|
|
|
|
scriptPath, err := downloadInstallScript(ctx, opts.scriptURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer removeFile(scriptPath)
|
|
|
|
args := buildInstallScriptArgs(scriptPath, opts)
|
|
cmd := exec.CommandContext(ctx, "sh", args...) //nolint:gosec // trusted script downloaded from the official URL
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func buildInstallScriptArgs(scriptPath string, opts installOptions) []string {
|
|
args := []string{scriptPath}
|
|
if opts.yes {
|
|
args = append(args, "--yes")
|
|
}
|
|
if opts.version != "" {
|
|
args = append(args, "--version", opts.version)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func findComposeFile(dir string) (string, bool, error) {
|
|
for _, name := range []string{
|
|
"docker-compose.yml",
|
|
"docker-compose.yaml",
|
|
"compose.yml",
|
|
"compose.yaml",
|
|
} {
|
|
path := filepath.Join(dir, name)
|
|
info, err := os.Stat(path)
|
|
if err == nil && !info.IsDir() {
|
|
return path, true, nil
|
|
}
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return "", false, fmt.Errorf("check compose file %s: %w", path, err)
|
|
}
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func downloadInstallScript(ctx context.Context, scriptURL string) (string, error) {
|
|
parsedURL, err := validateInstallScriptURL(scriptURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create install script request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", "memoh-cli")
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req) //nolint:gosec // URL is restricted to https://memoh.sh by validateInstallScriptURL
|
|
if err != nil {
|
|
return "", fmt.Errorf("download install script: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("download install script: unexpected HTTP status %s", resp.Status)
|
|
}
|
|
|
|
file, err := os.CreateTemp("", "memoh-install-*.sh")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create temp install script: %w", err)
|
|
}
|
|
tempPath := file.Name()
|
|
defer closeFile(file)
|
|
|
|
if _, err := io.Copy(file, resp.Body); err != nil {
|
|
removeFile(tempPath)
|
|
return "", fmt.Errorf("write temp install script: %w", err)
|
|
}
|
|
if err := file.Chmod(0o700); err != nil {
|
|
removeFile(tempPath)
|
|
return "", fmt.Errorf("chmod temp install script: %w", err)
|
|
}
|
|
|
|
return tempPath, nil
|
|
}
|
|
|
|
func validateInstallScriptURL(rawURL string) (*url.URL, error) {
|
|
parsedURL, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse install script url: %w", err)
|
|
}
|
|
if parsedURL.Scheme != "https" {
|
|
return nil, errors.New("install script URL must use https")
|
|
}
|
|
if parsedURL.Host != "memoh.sh" {
|
|
return nil, errors.New("install script URL host must be memoh.sh")
|
|
}
|
|
return parsedURL, nil
|
|
}
|
|
|
|
func removeFile(path string) {
|
|
_ = os.Remove(path) //nolint:gosec // path comes from CreateTemp or internal cleanup targets, not user-controlled traversal input
|
|
}
|
|
|
|
func closeFile(file *os.File) {
|
|
_ = file.Close()
|
|
}
|