26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
361 lines
9.5 KiB
Go
361 lines
9.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/kunchenguid/no-mistakes/internal/daemon"
|
|
"github.com/kunchenguid/no-mistakes/internal/ipc"
|
|
"github.com/kunchenguid/no-mistakes/internal/lifecycle"
|
|
"github.com/kunchenguid/no-mistakes/internal/paths"
|
|
"github.com/kunchenguid/no-mistakes/internal/types"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
daemonRun = daemon.Run
|
|
daemonStartFn = daemon.Start
|
|
daemonStopFn = daemon.Stop
|
|
daemonIsRunningFn = daemon.IsRunning
|
|
)
|
|
|
|
func newDaemonCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "daemon",
|
|
Short: "Manage the no-mistakes daemon",
|
|
}
|
|
|
|
cmd.AddCommand(newDaemonStartCmd())
|
|
cmd.AddCommand(newDaemonStopCmd())
|
|
cmd.AddCommand(newDaemonRestartCmd())
|
|
cmd.AddCommand(newDaemonStatusCmd())
|
|
cmd.AddCommand(newDaemonRunCmd())
|
|
cmd.AddCommand(newDaemonNotifyPushCmd())
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newDaemonNotifyPushCmd() *cobra.Command {
|
|
var gate string
|
|
var ref string
|
|
var oldSHA string
|
|
var newSHA string
|
|
var pushOptions []string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "notify-push",
|
|
Short: "Notify daemon about a git push",
|
|
Hidden: true,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
skipSteps, err := parseSkipPushOptions(pushOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
intent, err := parseIntentPushOptions(pushOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gatePath, err := normalizeNotifyGatePath(gate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p, err := paths.New()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := ipc.Dial(p.Socket())
|
|
if err != nil {
|
|
return fmt.Errorf("connect to daemon: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
var result ipc.PushReceivedResult
|
|
return client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{
|
|
Gate: gatePath,
|
|
Ref: ref,
|
|
Old: oldSHA,
|
|
New: newSHA,
|
|
SkipSteps: skipSteps,
|
|
Intent: intent,
|
|
}, &result)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&gate, "gate", "", "bare repo path that received the push")
|
|
cmd.Flags().StringVar(&ref, "ref", "", "git ref name")
|
|
cmd.Flags().StringVar(&oldSHA, "old", "", "previous commit SHA")
|
|
cmd.Flags().StringVar(&newSHA, "new", "", "new commit SHA")
|
|
cmd.Flags().StringArrayVar(&pushOptions, "push-option", nil, "git push option")
|
|
_ = cmd.MarkFlagRequired("gate")
|
|
_ = cmd.MarkFlagRequired("ref")
|
|
_ = cmd.MarkFlagRequired("old")
|
|
_ = cmd.MarkFlagRequired("new")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func normalizeNotifyGatePath(gate string) (string, error) {
|
|
if strings.TrimSpace(gate) == "" {
|
|
return "", fmt.Errorf("gate path is required")
|
|
}
|
|
abs, err := filepath.Abs(gate)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve gate path: %w", err)
|
|
}
|
|
return filepath.Clean(abs), nil
|
|
}
|
|
|
|
func parseSkipPushOptions(options []string) ([]types.StepName, error) {
|
|
var steps []types.StepName
|
|
for _, option := range options {
|
|
value, ok := strings.CutPrefix(option, "no-mistakes.skip=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
parsed, err := parseSkipSteps(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
steps = append(steps, parsed...)
|
|
}
|
|
return dedupeSteps(steps), nil
|
|
}
|
|
|
|
func parseSkipSteps(value string) ([]types.StepName, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil, nil
|
|
}
|
|
var steps []types.StepName
|
|
for _, part := range strings.Split(value, ",") {
|
|
step := types.StepName(strings.TrimSpace(part))
|
|
if !validStep(step) {
|
|
return nil, fmt.Errorf("unknown step %q", step)
|
|
}
|
|
steps = append(steps, step)
|
|
}
|
|
return dedupeSteps(steps), nil
|
|
}
|
|
|
|
// intentPushOptionPrefix carries an agent-supplied intent through a git push.
|
|
// The value is base64-encoded so multi-line or special-character intents
|
|
// survive the push-option transport (which is line-oriented).
|
|
const intentPushOptionPrefix = "no-mistakes.intent="
|
|
|
|
// formatIntentPushOption encodes intent as a single push option, or returns ""
|
|
// when there is no intent to carry.
|
|
func formatIntentPushOption(intent string) string {
|
|
if strings.TrimSpace(intent) == "" {
|
|
return ""
|
|
}
|
|
return intentPushOptionPrefix + base64.StdEncoding.EncodeToString([]byte(intent))
|
|
}
|
|
|
|
// parseIntentPushOptions extracts and decodes the intent push option, if any.
|
|
// The last occurrence wins.
|
|
func parseIntentPushOptions(options []string) (string, error) {
|
|
intent := ""
|
|
for _, option := range options {
|
|
encoded, ok := strings.CutPrefix(option, intentPushOptionPrefix)
|
|
if !ok {
|
|
continue
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode intent push option: %w", err)
|
|
}
|
|
intent = string(decoded)
|
|
}
|
|
return intent, nil
|
|
}
|
|
|
|
func formatSkipPushOptions(steps []types.StepName) []string {
|
|
if len(steps) == 0 {
|
|
return nil
|
|
}
|
|
parts := make([]string, 0, len(steps))
|
|
for _, step := range dedupeSteps(steps) {
|
|
parts = append(parts, string(step))
|
|
}
|
|
return []string{"no-mistakes.skip=" + strings.Join(parts, ",")}
|
|
}
|
|
|
|
func validStep(step types.StepName) bool {
|
|
for _, known := range types.AllSteps() {
|
|
if step == known {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func dedupeSteps(steps []types.StepName) []types.StepName {
|
|
seen := make(map[types.StepName]bool, len(steps))
|
|
out := make([]types.StepName, 0, len(steps))
|
|
for _, step := range steps {
|
|
if seen[step] {
|
|
continue
|
|
}
|
|
seen[step] = true
|
|
out = append(out, step)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newDaemonStartCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "start",
|
|
Short: "Install or refresh the managed daemon service and start it",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return trackCommand("daemon.start", func() error {
|
|
p, err := paths.New()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := p.EnsureDirs(); err != nil {
|
|
return err
|
|
}
|
|
if err := daemonStartFn(p); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), " %s daemon started\n", sGreen.Render("✓"))
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
func newDaemonStopCmd() *cobra.Command {
|
|
var force bool
|
|
cmd := &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop the running daemon",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
logLifecycleInvocation("daemon.stop", force)
|
|
return trackCommand("daemon.stop", func() error {
|
|
p, err := paths.New()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := guardDestructiveDaemonLifecycle(p, cmd.ErrOrStderr(), "daemon stop", force); err != nil {
|
|
return err
|
|
}
|
|
if err := daemonStopFn(p); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), " %s daemon stopped\n", sGreen.Render("✓"))
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&force, "force", false, "stop the daemon even when pipeline runs are active")
|
|
return cmd
|
|
}
|
|
|
|
func newDaemonRestartCmd() *cobra.Command {
|
|
var force bool
|
|
cmd := &cobra.Command{
|
|
Use: "restart",
|
|
Short: "Restart the daemon (stop if running, then start)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
logLifecycleInvocation("daemon.restart", force)
|
|
return trackCommand("daemon.restart", func() error {
|
|
p, err := paths.New()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := p.EnsureDirs(); err != nil {
|
|
return err
|
|
}
|
|
if err := guardDestructiveDaemonLifecycle(p, cmd.ErrOrStderr(), "daemon restart", force); err != nil {
|
|
return err
|
|
}
|
|
if err := daemonStopFn(p); err != nil {
|
|
return fmt.Errorf("stop daemon: %w", err)
|
|
}
|
|
if err := daemonStartFn(p); err != nil {
|
|
return fmt.Errorf("start daemon: %w", err)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), " %s daemon restarted\n", sGreen.Render("✓"))
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&force, "force", false, "restart the daemon even when pipeline runs are active")
|
|
return cmd
|
|
}
|
|
|
|
func guardDestructiveDaemonLifecycle(p *paths.Paths, stderr io.Writer, action string, force bool) error {
|
|
runs, err := lifecycle.ActiveRuns(p)
|
|
if err != nil {
|
|
return fmt.Errorf("check active pipeline runs: %w", err)
|
|
}
|
|
if len(runs) == 0 {
|
|
return nil
|
|
}
|
|
if force {
|
|
fmt.Fprintf(stderr, "FORCE: %s will stop/restart the daemon while %d active pipeline runs are in progress\n", action, len(runs))
|
|
fmt.Fprint(stderr, lifecycle.RunList(runs))
|
|
return nil
|
|
}
|
|
return fmt.Errorf("refusing %s because %d active pipeline runs are in progress; pass --force to stop/restart the daemon anyway\n%s", action, len(runs), lifecycle.RunList(runs))
|
|
}
|
|
|
|
func newDaemonStatusCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "status",
|
|
Short: "Check if the daemon is running",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return trackCommand("daemon.status", func() error {
|
|
p, err := paths.New()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
alive, err := daemonIsRunningFn(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if alive {
|
|
pid, _ := daemon.ReadPID(p)
|
|
if pid > 0 {
|
|
fmt.Fprintf(cmd.OutOrStdout(), " %s daemon running %s\n", sGreen.Render("●"), sDim.Render(fmt.Sprintf("(pid %d)", pid)))
|
|
} else {
|
|
fmt.Fprintf(cmd.OutOrStdout(), " %s daemon running\n", sGreen.Render("●"))
|
|
}
|
|
} else {
|
|
fmt.Fprintf(cmd.OutOrStdout(), " %s daemon not running\n", sDim.Render("○"))
|
|
}
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
func newDaemonRunCmd() *cobra.Command {
|
|
var root string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "run",
|
|
Short: "Run the daemon in the foreground",
|
|
Hidden: true,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if root != "" {
|
|
if err := os.Setenv("NM_HOME", root); err != nil {
|
|
return fmt.Errorf("set NM_HOME: %w", err)
|
|
}
|
|
}
|
|
return daemonRun()
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&root, "root", "", "override no-mistakes data directory")
|
|
return cmd
|
|
}
|