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.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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user