mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(cli): add bots and docker compose commands
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -39,8 +39,11 @@ func newRootCommand() *cobra.Command {
|
|||||||
rootCmd.PersistentFlags().StringVar(&ctx.server, "server", "", "Memoh server URL")
|
rootCmd.PersistentFlags().StringVar(&ctx.server, "server", "", "Memoh server URL")
|
||||||
|
|
||||||
rootCmd.AddCommand(newMigrateCommand())
|
rootCmd.AddCommand(newMigrateCommand())
|
||||||
|
rootCmd.AddCommand(newInstallCommand())
|
||||||
rootCmd.AddCommand(newLoginCommand(ctx))
|
rootCmd.AddCommand(newLoginCommand(ctx))
|
||||||
rootCmd.AddCommand(newChatCommand(ctx))
|
rootCmd.AddCommand(newChatCommand(ctx))
|
||||||
|
rootCmd.AddCommand(newBotsCommand(ctx))
|
||||||
|
rootCmd.AddCommand(newComposeCommands()...)
|
||||||
rootCmd.AddCommand(&cobra.Command{
|
rootCmd.AddCommand(&cobra.Command{
|
||||||
Use: "tui",
|
Use: "tui",
|
||||||
Short: "Open the terminal UI",
|
Short: "Open the terminal UI",
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ func (c *Client) ListBots(ctx context.Context) ([]bots.Bot, error) {
|
|||||||
return resp.Items, err
|
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) {
|
func (c *Client) ListSessions(ctx context.Context, botID string) ([]session.Session, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Items []session.Session `json:"items"`
|
Items []session.Session `json:"items"`
|
||||||
|
|||||||
Reference in New Issue
Block a user