feat(cli): add bots and docker compose commands

This commit is contained in:
晨苒
2026-04-14 01:35:07 +08:00
parent d2449cd345
commit 793562be03
7 changed files with 950 additions and 0 deletions
+154
View File
@@ -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
+165
View File
@@ -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 <bot-id>",
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
}
+146
View File
@@ -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...)
}
+292
View File
@@ -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"
+180
View File
@@ -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()
}
+3
View File
@@ -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",
+10
View File
@@ -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"`