diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 00000000..4226096a --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,154 @@ +name: CLI Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Release version, e.g. 1.2.3 or v1.2.3" + required: false + publish: + description: "Create or update the GitHub release" + required: false + default: "true" + +permissions: + contents: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name || github.run_id }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Resolve release metadata + id: meta + shell: bash + run: | + set -euo pipefail + RAW_VERSION="${{ github.event.inputs.version }}" + if [[ "${{ github.event_name }}" == "workflow_dispatch" && -z "$RAW_VERSION" ]]; then + echo "workflow_dispatch requires the version input" >&2 + exit 1 + fi + if [[ -z "$RAW_VERSION" ]]; then + RAW_VERSION="${GITHUB_REF_NAME}" + fi + VERSION="${RAW_VERSION#v}" + TAG_NAME="${RAW_VERSION}" + if [[ "$TAG_NAME" != v* ]]; then + TAG_NAME="v${TAG_NAME}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile + + - name: Build release archive + shell: bash + env: + VERSION: ${{ steps.meta.outputs.version }} + COMMIT_HASH: ${{ github.sha }} + TARGET_OS: ${{ matrix.goos }} + TARGET_ARCH: ${{ matrix.goarch }} + run: | + set -euo pipefail + bash scripts/release.sh \ + --version "$VERSION" \ + --commit-hash "$COMMIT_HASH" \ + --os "$TARGET_OS" \ + --arch "$TARGET_ARCH" \ + --output-dir dist + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: memoh-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + dist/*.tar.gz + dist/*.zip + if-no-files-found: error + + release: + name: Publish GitHub release + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true' + steps: + - name: Resolve release metadata + id: meta + shell: bash + run: | + set -euo pipefail + RAW_VERSION="${{ github.event.inputs.version }}" + if [[ "${{ github.event_name }}" == "workflow_dispatch" && -z "$RAW_VERSION" ]]; then + echo "workflow_dispatch requires the version input" >&2 + exit 1 + fi + if [[ -z "$RAW_VERSION" ]]; then + RAW_VERSION="${GITHUB_REF_NAME}" + fi + VERSION="${RAW_VERSION#v}" + TAG_NAME="${RAW_VERSION}" + if [[ "$TAG_NAME" != v* ]]; then + TAG_NAME="v${TAG_NAME}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + path: release-artifacts + pattern: memoh-* + merge-multiple: true + + - name: Publish release assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.meta.outputs.tag_name }} + name: Memoh ${{ steps.meta.outputs.tag_name }} + generate_release_notes: true + files: | + release-artifacts/*.tar.gz + release-artifacts/*.zip diff --git a/cmd/memoh/bots.go b/cmd/memoh/bots.go new file mode 100644 index 00000000..59ca8f4b --- /dev/null +++ b/cmd/memoh/bots.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/memohai/memoh/internal/bots" + "github.com/memohai/memoh/internal/tui" +) + +func newBotsCommand(ctx *cliContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "bots", + Short: "Manage bots and their workspace containers", + } + + cmd.AddCommand(newBotsCreateCommand(ctx)) + cmd.AddCommand(newBotsDeleteCommand(ctx)) + cmd.AddCommand(newBotsContainerCommand()) + + return cmd +} + +func newBotsCreateCommand(ctx *cliContext) *cobra.Command { + var displayName string + var avatarURL string + var timezone string + var inactive bool + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a bot", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + client, err := authenticatedClient(ctx) + if err != nil { + return err + } + + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req := buildCreateBotRequest(displayName, avatarURL, timezone, inactive) + + bot, err := client.CreateBot(requestCtx, req) + if err != nil { + return err + } + + fmt.Printf("Created bot %s (%s)\n", bot.DisplayName, bot.ID) + return nil + }, + } + + cmd.Flags().StringVar(&displayName, "name", "", "Bot display name") + cmd.Flags().StringVar(&avatarURL, "avatar-url", "", "Bot avatar URL") + cmd.Flags().StringVar(&timezone, "timezone", "", "Bot timezone") + cmd.Flags().BoolVar(&inactive, "inactive", false, "Create the bot in inactive state") + _ = cmd.MarkFlagRequired("name") + + return cmd +} + +func newBotsDeleteCommand(ctx *cliContext) *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a bot", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + botID := strings.TrimSpace(args[0]) + if botID == "" { + return errors.New("bot id is required") + } + if !yes { + return fmt.Errorf("refusing to delete bot %s without --yes", botID) + } + + client, err := authenticatedClient(ctx) + if err != nil { + return err + } + + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := client.DeleteBot(requestCtx, botID); err != nil { + return err + } + + fmt.Printf("Deleted bot %s\n", botID) + return nil + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Confirm bot deletion") + + return cmd +} + +func newBotsContainerCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "ctr [ctr args]", + Aliases: []string{"container"}, + Short: "Manage the nested containerd inside the server container", + DisableFlagParsing: true, + SilenceUsage: true, + Long: `Run ctr inside the Docker Compose server service so you can inspect and +manage the nested containerd that Memoh uses for workspace containers. + +By default this command injects the containerd namespace from config.toml. +Pass --no-namespace or provide your own ctr -n/--namespace flag to override it.`, + Example: ` memoh bots ctr images ls + memoh bots ctr containers ls + memoh bots ctr --namespace default tasks ls + memoh bots ctr --server-service server -- snapshots ls`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || isHelpArg(args[0]) { + return cmd.Help() + } + + opts, ctrArgs, err := parseContainerdOptions(args) + if err != nil { + return err + } + if len(ctrArgs) == 0 { + return errors.New("missing ctr arguments") + } + + return runServerContainerd(cmd.Context(), opts, ctrArgs) + }, + } + + return cmd +} + +func authenticatedClient(ctx *cliContext) (*tui.Client, error) { + token := strings.TrimSpace(ctx.state.Token) + if token == "" { + return nil, errors.New("missing access token, please run `memoh login` first") + } + return tui.NewClient(ctx.state.ServerURL, token), nil +} + +func buildCreateBotRequest(displayName, avatarURL, timezone string, inactive bool) bots.CreateBotRequest { + req := bots.CreateBotRequest{ + DisplayName: strings.TrimSpace(displayName), + AvatarURL: strings.TrimSpace(avatarURL), + } + if strings.TrimSpace(timezone) != "" { + tz := strings.TrimSpace(timezone) + req.Timezone = &tz + } + if inactive { + active := false + req.IsActive = &active + } + return req +} diff --git a/cmd/memoh/compose.go b/cmd/memoh/compose.go new file mode 100644 index 00000000..a5a860f6 --- /dev/null +++ b/cmd/memoh/compose.go @@ -0,0 +1,146 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +func newComposeCommands() []*cobra.Command { + return []*cobra.Command{ + newComposeStartCommand(), + newComposeStopCommand(), + newComposeRestartCommand(), + newComposeStatusCommand(), + newComposeLogsCommand(), + newComposeUpdateCommand(), + } +} + +func isHelpArg(arg string) bool { + return arg == "-h" || arg == "--help" || arg == "help" +} + +func addComposeFlags(cmd *cobra.Command, opts *dockerComposeOptions) { + cmd.Flags().StringVar(&opts.projectDir, "project-dir", "", "Docker Compose project directory") + cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil, "Additional compose file") + cmd.Flags().StringSliceVar(&opts.profiles, "profile", nil, "Compose profile to enable") + cmd.Flags().StringVar(&opts.envFile, "env-file", "", "Compose env file") +} + +func newComposeStartCommand() *cobra.Command { + opts := dockerComposeOptions{} + var build bool + + cmd := &cobra.Command{ + Use: "start [service...]", + Short: "Start the Memoh stack", + RunE: func(cmd *cobra.Command, args []string) error { + composeArgs := []string{"up", "-d"} + if build { + composeArgs = append(composeArgs, "--build") + } + composeArgs = append(composeArgs, args...) + return runDockerCompose(cmd.Context(), opts, composeArgs) + }, + } + + addComposeFlags(cmd, &opts) + cmd.Flags().BoolVar(&build, "build", false, "Build images before starting") + return cmd +} + +func newComposeStopCommand() *cobra.Command { + opts := dockerComposeOptions{} + var volumes bool + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the Memoh stack", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + composeArgs := []string{"down"} + if volumes { + composeArgs = append(composeArgs, "--volumes") + } + return runDockerCompose(cmd.Context(), opts, composeArgs) + }, + } + + addComposeFlags(cmd, &opts) + cmd.Flags().BoolVar(&volumes, "volumes", false, "Also remove named volumes") + return cmd +} + +func newComposeRestartCommand() *cobra.Command { + opts := dockerComposeOptions{} + cmd := &cobra.Command{ + Use: "restart [service...]", + Short: "Restart the Memoh stack or selected services", + RunE: func(cmd *cobra.Command, args []string) error { + return runDockerCompose(cmd.Context(), opts, append([]string{"restart"}, args...)) + }, + } + addComposeFlags(cmd, &opts) + return cmd +} + +func newComposeStatusCommand() *cobra.Command { + opts := dockerComposeOptions{} + cmd := &cobra.Command{ + Use: "status", + Short: "Show Memoh service status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runDockerCompose(cmd.Context(), opts, []string{"ps"}) + }, + } + addComposeFlags(cmd, &opts) + return cmd +} + +func newComposeLogsCommand() *cobra.Command { + opts := dockerComposeOptions{} + var follow bool + var tail string + + cmd := &cobra.Command{ + Use: "logs [service...]", + Short: "Show Memoh service logs", + RunE: func(cmd *cobra.Command, args []string) error { + composeArgs := []string{"logs"} + if follow { + composeArgs = append(composeArgs, "-f") + } + if tail != "" { + composeArgs = append(composeArgs, "--tail", tail) + } + composeArgs = append(composeArgs, args...) + return runDockerCompose(cmd.Context(), opts, composeArgs) + }, + } + + addComposeFlags(cmd, &opts) + cmd.Flags().BoolVar(&follow, "follow", false, "Follow log output") + cmd.Flags().StringVar(&tail, "tail", "200", "Number of lines to show from the end of the logs") + return cmd +} + +func newComposeUpdateCommand() *cobra.Command { + opts := dockerComposeOptions{} + cmd := &cobra.Command{ + Use: "update [service...]", + Short: "Update the Memoh stack in one step", + RunE: func(cmd *cobra.Command, args []string) error { + pullArgs, upArgs := buildComposeUpdateSteps(args) + if err := runDockerCompose(cmd.Context(), opts, pullArgs); err != nil { + return err + } + return runDockerCompose(cmd.Context(), opts, upArgs) + }, + } + addComposeFlags(cmd, &opts) + return cmd +} + +func buildComposeUpdateSteps(services []string) ([]string, []string) { + return append([]string{"pull"}, services...), append([]string{"up", "-d"}, services...) +} diff --git a/cmd/memoh/docker.go b/cmd/memoh/docker.go new file mode 100644 index 00000000..bfb84536 --- /dev/null +++ b/cmd/memoh/docker.go @@ -0,0 +1,292 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "golang.org/x/term" +) + +type dockerComposeOptions struct { + projectDir string + files []string + profiles []string + envFile string +} + +type serverContainerdOptions struct { + dockerComposeOptions + service string + namespace string + noNamespace bool + noTTY bool +} + +func runDockerCompose(ctx context.Context, opts dockerComposeOptions, composeArgs []string) error { + dockerCmd, err := resolveDockerCommand(ctx) + if err != nil { + return err + } + + args := append(buildComposeBaseArgs(opts), composeArgs...) + if err := runExternalCommand(ctx, opts.projectDir, dockerCmd, args); err != nil { + return fmt.Errorf("run docker compose: %w", err) + } + return nil +} + +func runServerContainerd(ctx context.Context, opts serverContainerdOptions, ctrArgs []string) error { + dockerCmd, err := resolveDockerCommand(ctx) + if err != nil { + return err + } + + args := buildContainerdExecArgs(opts, ctrArgs) + if err := runExternalCommand(ctx, opts.projectDir, dockerCmd, args); err != nil { + return fmt.Errorf("run ctr in %s service: %w", opts.service, err) + } + return nil +} + +func resolveDockerCommand(ctx context.Context) ([]string, error) { + if _, err := exec.LookPath("docker"); err != nil { + return nil, errors.New("docker is not installed") + } + + dockerCmd := []string{"docker"} + if !canRun(ctx, dockerCmd, "info") { + if _, err := exec.LookPath("sudo"); err == nil && canRun(ctx, []string{"sudo", "docker"}, "info") { + dockerCmd = []string{"sudo", "docker"} + } else { + return nil, errors.New("cannot connect to the Docker daemon") + } + } + + if !canRun(ctx, dockerCmd, "compose", "version") { + return nil, errors.New("docker compose v2 is required") + } + + return dockerCmd, nil +} + +func canRun(ctx context.Context, command []string, args ...string) bool { + if len(command) == 0 { + return false + } + cmd := exec.CommandContext(ctx, command[0], append(command[1:], args...)...) //nolint:gosec // trusted local tooling + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + cmd.Stdin = nil + return cmd.Run() == nil +} + +func runExternalCommand(ctx context.Context, dir string, base []string, args []string) error { + if len(base) == 0 { + return errors.New("missing command") + } + + cmd := exec.CommandContext(ctx, base[0], append(base[1:], args...)...) //nolint:gosec // trusted local tooling + cmd.Dir = dir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func buildComposeBaseArgs(opts dockerComposeOptions) []string { + args := []string{"compose"} + if opts.projectDir != "" { + args = append(args, "--project-directory", opts.projectDir) + } + for _, file := range opts.files { + args = append(args, "-f", file) + } + for _, profile := range opts.profiles { + args = append(args, "--profile", profile) + } + if opts.envFile != "" { + args = append(args, "--env-file", opts.envFile) + } + return args +} + +func buildContainerdExecArgs(opts serverContainerdOptions, ctrArgs []string) []string { + args := append(buildComposeBaseArgs(opts.dockerComposeOptions), "exec") + if opts.noTTY || shouldDisableTTY() { + args = append(args, "-T") + } + args = append(args, opts.service, "ctr") + if !opts.noNamespace && opts.namespace != "" && !containsCtrNamespaceArg(ctrArgs) { + args = append(args, "-n", opts.namespace) + } + return append(args, ctrArgs...) +} + +func containsCtrNamespaceArg(args []string) bool { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "-n", arg == "--namespace": + return true + case strings.HasPrefix(arg, "--namespace="), strings.HasPrefix(arg, "-n="): + return true + } + } + return false +} + +func shouldDisableTTY() bool { + return !isTerminalFile(os.Stdin) || !isTerminalFile(os.Stdout) +} + +func isTerminalFile(file *os.File) bool { + fd := file.Fd() + const maxInt = int(^uint(0) >> 1) + if fd > uintptr(maxInt) { + return false + } + return term.IsTerminal(int(fd)) +} + +func parseComposeOptions(args []string) (dockerComposeOptions, []string, error) { + opts := dockerComposeOptions{} + i := 0 + for i < len(args) { + arg := args[i] + if arg == "--" { + i++ + break + } + + switch { + case arg == "--project-dir": + value, next, err := requireNextValue(args, i, arg) + if err != nil { + return opts, nil, err + } + opts.projectDir = value + i = next + case strings.HasPrefix(arg, "--project-dir="): + opts.projectDir = strings.TrimPrefix(arg, "--project-dir=") + i++ + case arg == "-f", arg == "--file": + value, next, err := requireNextValue(args, i, arg) + if err != nil { + return opts, nil, err + } + opts.files = append(opts.files, value) + i = next + case strings.HasPrefix(arg, "--file="): + opts.files = append(opts.files, strings.TrimPrefix(arg, "--file=")) + i++ + case strings.HasPrefix(arg, "-f="): + opts.files = append(opts.files, strings.TrimPrefix(arg, "-f=")) + i++ + case arg == "--profile": + value, next, err := requireNextValue(args, i, arg) + if err != nil { + return opts, nil, err + } + opts.profiles = append(opts.profiles, value) + i = next + case strings.HasPrefix(arg, "--profile="): + opts.profiles = append(opts.profiles, strings.TrimPrefix(arg, "--profile=")) + i++ + case arg == "--env-file": + value, next, err := requireNextValue(args, i, arg) + if err != nil { + return opts, nil, err + } + opts.envFile = value + i = next + case strings.HasPrefix(arg, "--env-file="): + opts.envFile = strings.TrimPrefix(arg, "--env-file=") + i++ + default: + return opts, args[i:], nil + } + } + + return opts, args[i:], nil +} + +func parseContainerdOptions(args []string) (serverContainerdOptions, []string, error) { + opts := serverContainerdOptions{ + service: "server", + namespace: defaultContainerdNamespace(), + } + + i := 0 + for i < len(args) { + arg := args[i] + if arg == "--" { + i++ + break + } + + switch { + case arg == "--server-service": + value, next, err := requireNextValue(args, i, arg) + if err != nil { + return opts, nil, err + } + opts.service = value + i = next + case strings.HasPrefix(arg, "--server-service="): + opts.service = strings.TrimPrefix(arg, "--server-service=") + i++ + case arg == "--namespace": + value, next, err := requireNextValue(args, i, arg) + if err != nil { + return opts, nil, err + } + opts.namespace = value + opts.noNamespace = false + i = next + case strings.HasPrefix(arg, "--namespace="): + opts.namespace = strings.TrimPrefix(arg, "--namespace=") + opts.noNamespace = false + i++ + case arg == "--no-namespace": + opts.noNamespace = true + i++ + case arg == "--no-tty": + opts.noTTY = true + i++ + default: + composeOpts, remaining, err := parseComposeOptions(args[i:]) + if err != nil { + return opts, nil, err + } + opts.dockerComposeOptions = composeOpts + return opts, remaining, nil + } + } + + return opts, args[i:], nil +} + +func requireNextValue(args []string, index int, flagName string) (string, int, error) { + if index+1 >= len(args) { + return "", 0, fmt.Errorf("flag %s requires a value", flagName) + } + return args[index+1], index + 2, nil +} + +func defaultContainerdNamespace() string { + cfg, err := provideConfig() + if err != nil { + return configDefaultContainerdNamespace + } + if strings.TrimSpace(cfg.Containerd.Namespace) == "" { + return configDefaultContainerdNamespace + } + return cfg.Containerd.Namespace +} + +const configDefaultContainerdNamespace = "default" diff --git a/cmd/memoh/install.go b/cmd/memoh/install.go new file mode 100644 index 00000000..05e566e1 --- /dev/null +++ b/cmd/memoh/install.go @@ -0,0 +1,180 @@ +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() +} diff --git a/cmd/memoh/root.go b/cmd/memoh/root.go index 6af5dde5..27874f29 100644 --- a/cmd/memoh/root.go +++ b/cmd/memoh/root.go @@ -39,8 +39,11 @@ func newRootCommand() *cobra.Command { rootCmd.PersistentFlags().StringVar(&ctx.server, "server", "", "Memoh server URL") rootCmd.AddCommand(newMigrateCommand()) + rootCmd.AddCommand(newInstallCommand()) rootCmd.AddCommand(newLoginCommand(ctx)) rootCmd.AddCommand(newChatCommand(ctx)) + rootCmd.AddCommand(newBotsCommand(ctx)) + rootCmd.AddCommand(newComposeCommands()...) rootCmd.AddCommand(&cobra.Command{ Use: "tui", Short: "Open the terminal UI", diff --git a/internal/tui/api.go b/internal/tui/api.go index 064486ca..9e161d9a 100644 --- a/internal/tui/api.go +++ b/internal/tui/api.go @@ -77,6 +77,16 @@ func (c *Client) ListBots(ctx context.Context) ([]bots.Bot, error) { return resp.Items, err } +func (c *Client) CreateBot(ctx context.Context, req bots.CreateBotRequest) (bots.Bot, error) { + var resp bots.Bot + err := c.doJSON(ctx, http.MethodPost, "/bots", req, &resp) + return resp, err +} + +func (c *Client) DeleteBot(ctx context.Context, botID string) error { + return c.doJSON(ctx, http.MethodDelete, fmt.Sprintf("/bots/%s", botID), nil, nil) +} + func (c *Client) ListSessions(ctx context.Context, botID string) ([]session.Session, error) { var resp struct { Items []session.Session `json:"items"`