commit 7140397a13a694ede1f678ab54bf9d29d3ecd905 Author: Victor Abrell Date: Fri Jun 12 14:31:58 2026 +0200 Initial commit: MyDev dev-environment TUI CLI + live dashboard wrapping aws/docker/git/awsmfa for local dev: AWS MFA auth status + expiry, dev compose stack control, container drift vs the dev repo, git repo status, prod-data and DB-access tools. Config-driven (~/.config/mydev/config.yaml). Dashboard runs commands via a command palette with captured/interactive modes. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c5ea4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +mydev diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dc7628 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# MyDev + +Local dev-environment monitor. AWS auth state, docker containers, git repo status. +Two modes: one-shot commands, or a live refreshing TUI dashboard. + +## Build + +```sh +go build -o mydev . +# optional: put it on PATH +mv mydev ~/go/bin/ # or /usr/local/bin +``` + +## Configure (required for `status` / `dash`) + +Repo locations live in a config file — no default, no flag. First run: + +```sh +mydev config init # writes a starter config +mydev config path # prints its location +``` + +Config lives at `~/.config/mydev/config.yaml` (honors `$XDG_CONFIG_HOME`): + +```yaml +repos_roots: # scanned one level deep for git repos + - /Users/you/code + - /Users/you/work # multiple roots allowed +dev_dir: /Users/you/code/dev # commands/awsmfa, docker-compose.yml, bin/*.sh +legacy_dir: /Users/you/code/legacy # rundev.sh for prod-data tools +``` + +If the file is missing, commands that need it exit with an error telling you to +run `config init`. New settings get added as keys here later. + +## Use + +```sh +# AWS MFA (via dev/commands/awsmfa — the real session, not `aws sts`) +mydev auth # status: account + expiry +mydev auth login # re-authenticate (MFA prompt) + +# Dev docker-compose stack (dev/docker-compose.yml) +mydev dev start # auths first, then up -d --force-recreate +mydev dev stop | restart | remove +mydev dev status # are running containers current with the dev repo? +mydev dev status --fetch=false # skip the git fetch (no network) + +# Prod data via legacy rundev.sh (mystore:devtool:*) +mydev data table +mydev data snapshot +mydev data userfiles + +# DB access requests (dev/bin/*.sh) +mydev db bast # request_bast_access.sh +mydev db grant # grant_request.sh +mydev db list [arg] # grant_list.sh + +# Misc +mydev port 3306 # what's listening on a port (lsof) +mydev docker # container table +mydev status # one-shot overview (auth + docker + repos) +mydev dash [--interval 5] # live TUI dashboard +``` + +In the dashboard you can run actions without leaving it: + +| key | action | +|-----|--------| +| `r` | refresh now | +| `a` | `auth login` (MFA prompt) | +| `s` / `x` / `R` | dev `start` / `stop` / `restart` | +| `:` | command palette — run **any** subcommand, with args | +| `q` / `ctrl+c` | quit | + +Quick keys cover the common no-arg actions and show a `y`/`n` confirm first. +For anything that takes arguments, press `:` to open the command palette and +type a full command line, e.g. `data table mytenant`, `db grant 1234`, +`port 3306` — Enter runs it, Esc cancels. + +The palette browses the live command tree as you type: it lists the root +commands first, the subcommands once you pick a group (`dev ` → start/stop/…), +and the usage hint for a leaf command (`port ` → `port `). `Tab` +completes the first match. The list is generated from cobra itself, so it always +matches the real commands — no separate help text to keep in sync. + +Commands whose output is already on the dashboard are hidden and refused: the +top-level `status` and the bare `auth` status. `auth login`, `docker`, and +`dev status` stay (actions, or more detail than the panels show). + +Commands run in one of two modes, chosen automatically: + +- **Interactive** (`auth login`, `dev start`/`restart`, all `data …` fetches) — + need stdin or produce long live logs/prompts. The dashboard suspends and hands + over the full terminal, then resumes. +- **Captured** (everything else: `status`, `db grant`, `db list`, `port`, …) — + the dashboard stays up showing `running … [c]ancel`. While it runs, other + launches are blocked (so you can't stack commands); `c` cancels it (kills the + process and returns to the menu, no result shown). When it finishes normally, + the output shows in a scrollable result pane. + `↑/↓` scroll, `esc`/`enter` close. Long lines wrap to the terminal width (so + things like a generated password aren't cut off), and re-wrap when you resize. + +Either way it re-invokes this same binary's subcommands, so behavior matches the +CLI exactly. The terminal is cleared when the dashboard starts, before each +interactive command, and on exit — so you never see stale output from a previous +command (or after quitting). + +The AWS panel turns red +(`⚠ expiring`) when the session has under 30 min left. The DOCKER header shows a +container-freshness badge (`✓ all N current` / `⚠ N/M stale`) — computed locally +each refresh, no network. `dev status` adds a git-fetch check on top (is the dev +repo itself behind origin); the dashboard deliberately omits that to stay fast. + +## How it works + +Shells out to the real `aws`, `docker`, `git` CLIs — no SDKs, no creds handling +of its own. All probe logic lives in `internal/checks/` as plain functions; both +the one-shot commands and the TUI call the same functions, so behavior never drifts. + +``` +main.go entrypoint +cmd/ cobra subcommands (auth, dev, data, db, port, docker, status, dash, config) +internal/checks/ auth.go, docker.go, repos.go — read-only probes (capture output) +internal/config/ config.go — load/init the YAML config + path accessors +internal/run/ run.go — stream a command's stdio to the terminal (auth/compose/data) +``` + +Probes (`internal/checks`) capture output for display; actions that prompt or +stream (MFA, compose, data fetch) go through `internal/run` so they reach the +terminal directly. The `dev start`/`restart` commands auto re-auth first, +mirroring the old `myd` shell function. + +## Extend + +Add a probe: write a func in `internal/checks/`, then either a new file in `cmd/` +for a one-shot subcommand, or wire it into `dashModel` in `cmd/dash.go` (add a +field, a `fetchX` command, a message type, and a panel). + +Ideas: ECS/EKS context, VPN state, kubectl context, disk space, brew outdated. diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..7245be0 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "mydev/internal/checks" + "mydev/internal/config" + "mydev/internal/run" + + "github.com/spf13/cobra" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Check AWS MFA auth (awsmfa status)", + Run: func(cmd *cobra.Command, args []string) { + awsmfa := mustAWSMFA() + s := checks.AWSAuth(context.Background(), awsmfa) + if s.Authed { + fmt.Printf("✓ AWS authed %s\n", s.Detail) + return + } + if s.Err != "" { + fmt.Printf("✗ awsmfa error: %s\n", s.Err) + os.Exit(1) + } + fmt.Println("✗ AWS NOT authed — run 'mydev auth login'") + os.Exit(1) + }, +} + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Re-authenticate (awsmfa auth) — prompts for MFA", + Run: func(cmd *cobra.Command, args []string) { + awsmfa := mustAWSMFA() + if err := run.Stream("", awsmfa, "auth"); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, +} + +// mustAWSMFA loads config and resolves the awsmfa path, exiting on error. +func mustAWSMFA() string { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + awsmfa, err := cfg.AWSMFA() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return awsmfa +} + +func init() { + authCmd.AddCommand(authLoginCmd) + rootCmd.AddCommand(authCmd) +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..f5b13d1 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + "os" + + "mydev/internal/config" + + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage the config file", +} + +var configPathCmd = &cobra.Command{ + Use: "path", + Short: "Print the config file path", + Run: func(cmd *cobra.Command, args []string) { + p, err := config.Path() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(p) + }, +} + +var configInitCmd = &cobra.Command{ + Use: "init", + Short: "Write a starter config file", + Run: func(cmd *cobra.Command, args []string) { + p, err := config.Init() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("wrote %s — edit repos_roots to your paths\n", p) + }, +} + +func init() { + configCmd.AddCommand(configPathCmd, configInitCmd) + rootCmd.AddCommand(configCmd) +} diff --git a/cmd/dash.go b/cmd/dash.go new file mode 100644 index 0000000..e80cfd3 --- /dev/null +++ b/cmd/dash.go @@ -0,0 +1,667 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "mydev/internal/checks" + + "mydev/internal/config" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/wordwrap" + "github.com/muesli/reflow/wrap" + "github.com/spf13/cobra" +) + +var dashInterval int + +var dashCmd = &cobra.Command{ + Use: "dash", + Short: "Live refreshing TUI dashboard", + Run: func(cmd *cobra.Command, args []string) { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + awsmfa, _ := cfg.AWSMFA() // empty if dev_dir unset; panel shows "not configured" + composeFile, _ := cfg.ComposeFile() // empty if dev_dir unset; drift check skipped + clearTerminal() // start clean (no prior scrollback) + p := tea.NewProgram(newDash(cfg.ReposRoots, awsmfa, composeFile, time.Duration(dashInterval)*time.Second), tea.WithAltScreen()) + _, err = p.Run() + clearTerminal() // leave clean on exit (hide any interactive-command output) + if err != nil { + fmt.Println(err) + } + }, +} + +func init() { + dashCmd.Flags().IntVar(&dashInterval, "interval", 10, "refresh interval in seconds") + rootCmd.AddCommand(dashCmd) +} + +// ---- messages emitted by background fetches ---- + +type authMsg checks.AuthStatus +type dockerMsg struct { + list []checks.Container + err string +} +type driftMsg struct { + stale int + total int + err string +} +type repoMsg struct { + list []checks.Repo + err string +} +type tickMsg time.Time +type execDoneMsg struct{ err error } + +// cmdResultMsg carries the captured output of a non-interactive command. +type cmdResultMsg struct { + title string + body string + err error +} + +// pendingAction is a command awaiting y/n confirmation before it runs. +type pendingAction struct { + label string // shown in the confirm prompt + args []string // args passed to this binary (e.g. ["dev","restart"]) +} + +// ---- model ---- + +type dashModel struct { + reposRoots []string + awsmfa string + composeFile string + interval time.Duration + self string // path to this binary, for re-invoking subcommands + + auth checks.AuthStatus + docker dockerMsg + drift driftMsg + repos repoMsg + + pending *pendingAction // non-nil while awaiting y/n confirmation + cmdline textinput.Model + cmdActive bool // true while the command palette is open + paletteNote string // transient message shown in the palette (e.g. blocked cmd) + running string // label of a captured command currently executing + cancel context.CancelFunc // cancels the running captured command + cancelled bool // set when the user cancelled the running command + result *cmdResultMsg + viewport viewport.Model + lastRefresh time.Time + width int + height int +} + +func newDash(reposRoots []string, awsmfa, composeFile string, interval time.Duration) dashModel { + if interval <= 0 { + interval = 10 * time.Second + } + self, err := os.Executable() + if err != nil { + self = os.Args[0] + } + ti := textinput.New() + ti.Prompt = "mydev ❯ " + ti.Placeholder = "data table db grant port 3306 …" + return dashModel{reposRoots: reposRoots, awsmfa: awsmfa, composeFile: composeFile, interval: interval, self: self, cmdline: ti} +} + +// interactive commands need the live terminal (stdin prompts / long log streams) +// so they run via ExecProcess; everything else is captured into a result pane. +func interactive(args []string) bool { + switch { + case len(args) >= 2 && args[0] == "auth" && args[1] == "login": + return true + case len(args) >= 2 && args[0] == "dev" && (args[1] == "start" || args[1] == "restart"): + return true + case len(args) >= 1 && args[0] == "data": + // Prod-data fetches may prompt and stream progress — run them live. + return true + } + return false +} + +// launch picks the execution mode for a command and returns the updated model +// plus the command to run. +func (m dashModel) launch(args []string) (dashModel, tea.Cmd) { + if interactive(args) { + return m, m.runSelf(args...) + } + ctx, cancel := context.WithCancel(context.Background()) + m.running = strings.Join(args, " ") + m.cancel = cancel + m.cancelled = false + return m, m.runCaptured(ctx, args...) +} + +// clearTerminal wipes the screen and scrollback so the dashboard starts and +// exits without leftover command output showing in the normal buffer. +func clearTerminal() { + fmt.Print("\033[2J\033[3J\033[H") +} + +// runSelf suspends the TUI, runs `this-binary ` with full terminal +// control (so MFA prompts and compose logs work), then resumes and refreshes. +// It clears the revealed terminal (screen + scrollback) first so the user isn't +// looking at leftover output behind the interactive command. The args go +// through "$@" so spaces need no quoting. +func (m dashModel) runSelf(args ...string) tea.Cmd { + shArgs := append([]string{"-c", `printf '\033[2J\033[3J\033[H'; exec "$0" "$@"`, m.self}, args...) + c := exec.Command("sh", shArgs...) + return tea.ExecProcess(c, func(err error) tea.Msg { return execDoneMsg{err} }) +} + +// wrapText fits text to width w: word-wrap at spaces first, then hard-wrap any +// remaining over-long tokens (e.g. a generated password with no spaces) so +// nothing overflows the result pane. +func wrapText(s string, w int) string { + if w <= 0 { + return s + } + return wrap.String(wordwrap.String(s, w), w) +} + +// showResult stores the captured output and loads it into the viewport, wrapped +// to the current width. +func (m *dashModel) showResult(r cmdResultMsg) { + m.result = &r + m.viewport.SetContent(wrapText(r.body, m.viewport.Width)) + m.viewport.GotoTop() +} + +// runCaptured runs the command in the background, capturing its output to show +// in the result pane. The dashboard stays visible (with a "running" note) until +// the result arrives. +func (m dashModel) runCaptured(ctx context.Context, args ...string) tea.Cmd { + self, title := m.self, strings.Join(args, " ") + return func() tea.Msg { + out, err := exec.CommandContext(ctx, self, args...).CombinedOutput() + return cmdResultMsg{title: title, body: string(out), err: err} + } +} + +func (m dashModel) refresh() tea.Cmd { + return tea.Batch(fetchAuth(m.awsmfa), fetchDocker, fetchDrift(m.composeFile), fetchRepos(m.reposRoots)) +} + +func (m dashModel) Init() tea.Cmd { + return tea.Batch(m.refresh(), tick(m.interval)) +} + +func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + k := msg.String() + + // Result pane open: esc/q/enter closes it; other keys scroll. + if m.result != nil { + switch k { + case "esc", "q", "enter": + m.result = nil + return m, nil + case "ctrl+c": + return m, tea.Quit + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + // Command palette open: route typing to the input; Enter runs it. + if m.cmdActive { + switch k { + case "enter": + args := strings.Fields(m.cmdline.Value()) + if len(args) > 0 && dashboardCovered(args) { + // Already on the dashboard — refuse, keep palette open. + m.cmdline.Reset() + m.paletteNote = strings.Join(args, " ") + " is already shown on the dashboard" + return m, nil + } + m.cmdActive = false + m.cmdline.Blur() + m.cmdline.Reset() + m.paletteNote = "" + if len(args) > 0 { + return m.launch(args) + } + return m, nil + case "esc", "ctrl+c": + m.cmdActive = false + m.cmdline.Blur() + m.cmdline.Reset() + m.paletteNote = "" + return m, nil + case "tab": + if _, items, leaf := completions(m.cmdline.Value()); !leaf && len(items) > 0 { + toks := strings.Fields(m.cmdline.Value()) + if !strings.HasSuffix(m.cmdline.Value(), " ") && len(toks) > 0 { + toks = toks[:len(toks)-1] + } + toks = append(toks, items[0].name) + m.cmdline.SetValue(strings.Join(toks, " ") + " ") + m.cmdline.CursorEnd() + } + return m, nil + } + m.paletteNote = "" // clear once the user edits the input + var cmd tea.Cmd + m.cmdline, cmd = m.cmdline.Update(msg) + return m, cmd + } + + // While a confirm prompt is up, only y/n/esc are meaningful. + if m.pending != nil { + switch k { + case "y", "Y": + p := m.pending + m.pending = nil + return m.launch(p.args) + case "n", "N", "esc", "q": + m.pending = nil + } + return m, nil + } + + // A captured command is running: block new launches; allow cancel/quit. + if m.running != "" { + switch k { + case "c", "C": + if m.cancel != nil { + m.cancelled = true + m.cancel() + } + case "ctrl+c", "q": + return m, tea.Quit + } + return m, nil + } + + switch k { + case "q", "ctrl+c": + return m, tea.Quit + case "r": // manual refresh + return m, m.refresh() + case "a": + m.pending = &pendingAction{"auth login", []string{"auth", "login"}} + case "s": + m.pending = &pendingAction{"dev start", []string{"dev", "start"}} + case "x": + m.pending = &pendingAction{"dev stop", []string{"dev", "stop"}} + case "R": + m.pending = &pendingAction{"dev restart", []string{"dev", "restart"}} + case ":": + m.cmdActive = true + m.paletteNote = "" + return m, m.cmdline.Focus() + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.cmdline.Width = msg.Width - len(m.cmdline.Prompt) - 2 + // Result pane leaves room for a title line and a help line. + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 3 + // Re-wrap any visible result to the new width. + if m.result != nil { + m.viewport.SetContent(wrapText(m.result.body, m.viewport.Width)) + } + case authMsg: + m.auth = checks.AuthStatus(msg) + m.lastRefresh = time.Now() + case dockerMsg: + m.docker = msg + case driftMsg: + m.drift = msg + case repoMsg: + m.repos = msg + case tickMsg: + // periodic refresh + reschedule + return m, tea.Batch(m.refresh(), tick(m.interval)) + case execDoneMsg: + // An interactive command finished and the TUI has resumed — refresh. + return m, m.refresh() + case cmdResultMsg: + m.running = "" + m.cancel = nil + // Cancelled by the user: just return to the menu, no result pane. + if m.cancelled { + m.cancelled = false + return m, m.refresh() + } + // Otherwise show the output in the result pane. + body := msg.body + if msg.err != nil { + body = strings.TrimRight(body, "\n") + "\n\n[exit: " + msg.err.Error() + "]" + } + if strings.TrimSpace(body) == "" { + body = "(no output)" + } + r := msg + r.body = body + m.showResult(r) + return m, m.refresh() + } + return m, nil +} + +// ---- commands (run in background, return a msg) ---- + +func fetchAuth(awsmfa string) tea.Cmd { + return func() tea.Msg { + if awsmfa == "" { + return authMsg{Err: "dev_dir not set in config"} + } + return authMsg(checks.AWSAuth(context.Background(), awsmfa)) + } +} + +func fetchDocker() tea.Msg { + list, err := checks.Containers(context.Background()) + return dockerMsg{list: list, err: err} +} + +func fetchDrift(composeFile string) tea.Cmd { + return func() tea.Msg { + if composeFile == "" { + return driftMsg{} + } + drift, err := checks.ComposeDrift(context.Background(), composeFile) + if err != "" { + return driftMsg{err: err} + } + stale := 0 + for _, d := range drift { + if !d.UpToDate() { + stale++ + } + } + return driftMsg{stale: stale, total: len(drift)} + } +} + +func fetchRepos(roots []string) tea.Cmd { + return func() tea.Msg { + list, err := checks.ReposMulti(context.Background(), roots) + return repoMsg{list: list, err: err} + } +} + +func tick(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +// ---- styles ---- + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + panelStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).MarginBottom(1) + headerStyle = lipgloss.NewStyle().Bold(true).Underline(true) +) + +func (m dashModel) View() string { + // Result pane takes over the whole screen until dismissed. + if m.result != nil { + head := titleStyle.Render("$ mydev "+m.result.title) + "\n" + help := "\n" + dimStyle.Render("↑/↓ scroll · esc/enter close") + return head + m.viewport.View() + help + } + + var b strings.Builder + + status := fmt.Sprintf("refreshed %s · every %s", m.lastRefresh.Format("15:04:05"), m.interval) + if m.running != "" { + status += " · " + okStyle.Render("running "+m.running+"…") + } + b.WriteString(titleStyle.Render("MyDev") + " " + dimStyle.Render(status) + "\n\n") + + b.WriteString(panelStyle.Render(m.authPanel()) + "\n") + b.WriteString(panelStyle.Render(m.dockerPanel()) + "\n") + b.WriteString(panelStyle.Render(m.repoPanel()) + "\n") + b.WriteString(m.footer()) + return b.String() +} + +type suggestion struct { + name string + short string +} + +// completions introspects the cobra command tree for the current palette input. +// It descends through completed tokens, then returns the candidates for the +// next token. leaf is true when the matched command takes args rather than +// subcommands — then items holds a single usage hint. +func completions(input string) (label string, items []suggestion, leaf bool) { + tokens := strings.Fields(input) + // A trailing space (or empty input) means we're picking the *next* token, + // so there is no partial filter on the final token. + var partial string + completed := tokens + if !strings.HasSuffix(input, " ") && input != "" && len(tokens) > 0 { + partial = tokens[len(tokens)-1] + completed = tokens[:len(tokens)-1] + } + + cur := rootCmd + for _, tok := range completed { + next := childNamed(cur, tok) + if next == nil { + return strings.Join(completed, " "), nil, false + } + cur = next + } + + var children []suggestion + for _, c := range cur.Commands() { + switch c.Name() { + case "help", "completion", "dash": // not useful from inside the dash + continue + } + // Hide root commands already covered by the dashboard. `status` is shown + // across the panels; bare `auth` is just the AWS panel (and `auth login` + // has the `a` quick key + is reachable by typing `auth `). + // `dev status` is kept — more detail than the drift badge. + if cur == rootCmd && (c.Name() == "status" || c.Name() == "auth") { + continue + } + if c.Hidden { + continue + } + if partial == "" || strings.HasPrefix(c.Name(), partial) { + children = append(children, suggestion{c.Name(), c.Short}) + } + } + + if len(cur.Commands()) == 0 { + // Leaf command: show how to call it. + return cur.CommandPath(), []suggestion{{cur.Use, cur.Short}}, true + } + + label = "commands" + if cur != rootCmd { + label = cur.CommandPath() + } + return label, children, false +} + +// dashboardCovered reports commands whose output is already on the dashboard, +// so the palette hides them and refuses to run them. `auth login` is allowed; +// only the bare `auth` status is blocked. +func dashboardCovered(args []string) bool { + if len(args) == 0 { + return false + } + switch { + case args[0] == "status": + return true + case args[0] == "auth" && len(args) == 1: + return true + } + return false +} + +func childNamed(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + for _, a := range c.Aliases { + if a == name { + return c + } + } + } + return nil +} + +// footer shows the command palette when open, the confirm prompt when an action +// is pending, otherwise the key legend. +func (m dashModel) footer() string { + if m.cmdActive { + var b strings.Builder + b.WriteString(m.cmdline.View() + "\n") + + if m.paletteNote != "" { + b.WriteString(badStyle.Render(m.paletteNote) + "\n") + } + + label, items, leaf := completions(m.cmdline.Value()) + switch { + case leaf: + it := items[0] + line := "usage: " + it.name + if it.short != "" { + line += " — " + it.short + } + b.WriteString(dimStyle.Render(line) + "\n") + case len(items) == 0: + b.WriteString(dimStyle.Render("no matching commands") + "\n") + default: + b.WriteString(dimStyle.Render(label+":") + "\n") + const max = 8 + for i, it := range items { + if i >= max { + b.WriteString(dimStyle.Render(fmt.Sprintf(" … +%d more", len(items)-max)) + "\n") + break + } + b.WriteString(dimStyle.Render(fmt.Sprintf(" %-10s %s", it.name, it.short)) + "\n") + } + } + b.WriteString(dimStyle.Render("tab complete · enter run · esc cancel")) + return b.String() + } + if m.running != "" { + return okStyle.Render("running "+m.running+"…") + dimStyle.Render(" [c]ancel") + } + if m.pending != nil { + return badStyle.Render("Run '"+m.pending.label+"'?") + dimStyle.Render(" [y] yes [n] no") + } + return dimStyle.Render("[r]efresh [a]uth [s]tart [x]stop [R]estart [:]command [q]uit") +} + +func (m dashModel) authPanel() string { + var s strings.Builder + s.WriteString(headerStyle.Render("AWS AUTH") + "\n") + if m.auth.Authed { + mark := okStyle.Render("✓ authed") + // Warn when the session expires within 30 min. + if !m.auth.Expires.IsZero() && time.Until(m.auth.Expires) < 30*time.Minute { + mark = badStyle.Render("⚠ expiring") + } + s.WriteString(mark + dimStyle.Render(" "+m.auth.Detail)) + } else if m.auth.Err != "" { + s.WriteString(badStyle.Render("✗ re-auth needed") + "\n" + dimStyle.Render(truncate(m.auth.Err, 70))) + } else { + s.WriteString(dimStyle.Render("checking…")) + } + return s.String() +} + +func (m dashModel) dockerPanel() string { + var s strings.Builder + s.WriteString(headerStyle.Render("DOCKER") + m.driftBadge() + "\n") + if m.docker.err != "" { + s.WriteString(badStyle.Render("✗ " + truncate(m.docker.err, 70))) + return s.String() + } + if len(m.docker.list) == 0 { + s.WriteString(dimStyle.Render("no containers")) + return s.String() + } + for _, c := range m.docker.list { + mark := badStyle.Render("●") + if c.State == "running" { + mark = okStyle.Render("●") + } + s.WriteString(fmt.Sprintf("%s %-20s %s\n", mark, c.Name, dimStyle.Render(c.Status))) + } + return strings.TrimRight(s.String(), "\n") +} + +// driftBadge summarizes dev-repo container freshness next to the DOCKER header. +func (m dashModel) driftBadge() string { + switch { + case m.composeFile == "": + return "" + case m.drift.err != "": + return " " + dimStyle.Render("(drift: "+truncate(m.drift.err, 30)+")") + case m.drift.total == 0: + return " " + dimStyle.Render("(checking…)") + case m.drift.stale > 0: + return " " + badStyle.Render(fmt.Sprintf("⚠ %d/%d stale — dev restart", m.drift.stale, m.drift.total)) + default: + return " " + okStyle.Render(fmt.Sprintf("✓ all %d current", m.drift.total)) + } +} + +func (m dashModel) repoPanel() string { + var s strings.Builder + s.WriteString(headerStyle.Render("REPOS") + "\n") + if m.repos.err != "" { + s.WriteString(badStyle.Render("✗ " + truncate(m.repos.err, 70))) + return s.String() + } + if len(m.repos.list) == 0 { + s.WriteString(dimStyle.Render("none found")) + return s.String() + } + for _, r := range m.repos.list { + state := okStyle.Render("clean") + if r.Dirty { + state = badStyle.Render("dirty") + } + if r.Err != "" { + state = badStyle.Render("err") + } + s.WriteString(fmt.Sprintf("%-20s %s %s\n", r.Name, dimStyle.Render(r.Branch), state)) + } + return strings.TrimRight(s.String(), "\n") +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > n { + return s[:n] + "…" + } + return s +} diff --git a/cmd/dash_render_test.go b/cmd/dash_render_test.go new file mode 100644 index 0000000..4fc4a2d --- /dev/null +++ b/cmd/dash_render_test.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "context" + "strings" + "testing" + "time" + + "mydev/internal/checks" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestDashViewRenders(t *testing.T) { + m := newDash([]string{"/tmp"}, "/x/awsmfa", "/x/docker-compose.yml", 10*time.Second) + m.auth = checks.AuthStatus{Authed: true, Account: "123", Detail: "account 123 · expires 21:25"} + m.docker = dockerMsg{list: []checks.Container{{Name: "db", State: "running", Status: "Up 1h"}}} + m.drift = driftMsg{stale: 2, total: 13} + m.repos = repoMsg{list: []checks.Repo{{Name: "legacy", Branch: "main", Dirty: true}}} + + out := m.View() + for _, want := range []string{"MyDev", "AWS AUTH", "DOCKER", "2/13 stale", "REPOS", "legacy"} { + if !strings.Contains(out, want) { + t.Errorf("View() missing %q\n---\n%s", want, out) + } + } + + // Default footer shows the key legend. + if !strings.Contains(out, "[R]estart") { + t.Errorf("footer missing key legend\n---\n%s", out) + } + + // With a pending action, the footer becomes a confirm prompt instead. + m.pending = &pendingAction{label: "dev restart", args: []string{"dev", "restart"}} + confirm := m.View() + if !strings.Contains(confirm, "Run 'dev restart'?") || !strings.Contains(confirm, "[y] yes") { + t.Errorf("confirm prompt not rendered\n---\n%s", confirm) + } +} + +func names(items []suggestion) string { + var b strings.Builder + for _, it := range items { + b.WriteString(it.name + " ") + } + return b.String() +} + +func TestCompletions(t *testing.T) { + // Empty input → root commands, excluding dash/help/completion. + label, items, leaf := completions("") + if leaf { + t.Fatal("root should not be a leaf") + } + got := names(items) + for _, want := range []string{"dev", "db", "data", "port"} { + if !strings.Contains(got, want) { + t.Errorf("root completions missing %q (got: %s)", want, got) + } + } + // Hidden at root: status + bare auth (dashboard-covered), and dash/help/completion. + for _, no := range []string{"status", "auth", "dash", "help", "completion"} { + if strings.Contains(got, no) { + t.Errorf("root completions should not include %q (got: %s)", no, got) + } + } + // But `auth login` is still reachable by typing `auth `. + if !strings.Contains(names(mustItems(t, "auth ")), "login") { + t.Error("auth login should still be reachable") + } + + // Prefix filter. + if got := names(mustItems(t, "d")); !strings.Contains(got, "dev") || !strings.Contains(got, "docker") || strings.Contains(got, "auth") { + t.Errorf("prefix 'd' filter wrong: %s", got) + } + + // Descend into a subcommand group. + if got := names(mustItems(t, "dev ")); !strings.Contains(got, "start") || !strings.Contains(got, "restart") || !strings.Contains(got, "status") { + t.Errorf("'dev ' subcommands wrong: %s", got) + } + + // Leaf command shows usage. + _, items, leaf = completions("port ") + if !leaf || len(items) == 0 || !strings.Contains(items[0].name, "port") { + t.Errorf("'port ' should be a leaf with usage, got leaf=%v items=%v", leaf, items) + } + _ = label +} + +func mustItems(t *testing.T, in string) []suggestion { + t.Helper() + _, items, _ := completions(in) + return items +} + +func TestInteractiveClassification(t *testing.T) { + cases := []struct { + args []string + want bool + }{ + {[]string{"auth", "login"}, true}, + {[]string{"dev", "start"}, true}, + {[]string{"dev", "restart"}, true}, + {[]string{"dev", "stop"}, false}, // quick, capture output + {[]string{"dev", "status"}, false}, // read-only + {[]string{"data", "table", "x"}, true}, // may prompt / stream + {[]string{"data", "snapshot"}, true}, + {[]string{"db", "grant", "1234"}, false}, // captured → result pane + {[]string{"port", "3306"}, false}, + {[]string{"auth"}, false}, + } + for _, c := range cases { + if got := interactive(c.args); got != c.want { + t.Errorf("interactive(%v) = %v, want %v", c.args, got, c.want) + } + } +} + +func TestDashboardCovered(t *testing.T) { + cases := []struct { + args []string + want bool + }{ + {[]string{"status"}, true}, + {[]string{"auth"}, true}, // bare auth = status + {[]string{"auth", "login"}, false}, // login is an action + {[]string{"docker"}, false}, // kept (more detail than panel) + {[]string{"dev", "status"}, false}, // kept (per-service detail) + {[]string{"port", "3306"}, false}, + } + for _, c := range cases { + if got := dashboardCovered(c.args); got != c.want { + t.Errorf("dashboardCovered(%v) = %v, want %v", c.args, got, c.want) + } + } +} + +func TestPaletteRefusesCoveredCommand(t *testing.T) { + m := newDash(nil, "/x/awsmfa", "", 10*time.Second) + um, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}}) + m = um.(dashModel) + // Type "status" char by char. + for _, r := range "status" { + um, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = um.(dashModel) + } + um, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = um.(dashModel) + if !m.cmdActive { + t.Error("palette should stay open after refusing a covered command") + } + if m.running != "" || m.result != nil { + t.Error("covered command should not run") + } + if !strings.Contains(m.View(), "already shown on the dashboard") { + t.Errorf("expected refusal note\n---\n%s", m.View()) + } +} + +func TestLaunchSetsRunningForCaptured(t *testing.T) { + m := newDash(nil, "/x/awsmfa", "", 10*time.Second) + m2, cmd := m.launch([]string{"status"}) + if m2.running != "status" { + t.Errorf("captured launch should set running=status, got %q", m2.running) + } + if cmd == nil { + t.Error("launch returned nil command") + } + // Interactive launch should NOT set running (it suspends the TUI). + m3, _ := m.launch([]string{"auth", "login"}) + if m3.running != "" { + t.Errorf("interactive launch should not set running, got %q", m3.running) + } +} + +func TestWrapTextBreaksLongTokens(t *testing.T) { + // A generated password has no spaces — must still be hard-wrapped. + pw := "temp password: " + strings.Repeat("Xy9$", 30) // ~135 chars, mostly one token + const w = 40 + out := wrapText(pw, w) + for _, line := range strings.Split(out, "\n") { + if len(line) > w { + t.Errorf("line exceeds width %d: %q (len %d)", w, line, len(line)) + } + } + // Content is preserved (ignoring inserted newlines). + if strings.ReplaceAll(out, "\n", "") != strings.ReplaceAll(pw, " ", " ") && + !strings.Contains(strings.ReplaceAll(out, "\n", ""), strings.Repeat("Xy9$", 30)) { + t.Errorf("wrap lost content:\n%s", out) + } +} + +func TestRunningBlocksLaunchAndCancels(t *testing.T) { + m := newDash(nil, "/x/awsmfa", "", 10*time.Second) + m, _ = m.launch([]string{"status"}) + if m.running != "status" || m.cancel == nil { + t.Fatalf("captured launch should set running+cancel, got running=%q cancel=%v", m.running, m.cancel != nil) + } + + // Footer shows the running state with a cancel hint. + if !strings.Contains(m.View(), "[c]ancel") { + t.Errorf("footer missing cancel hint\n---\n%s", m.View()) + } + + // ":" must NOT open the palette while a command runs. + um, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}}) + m = um.(dashModel) + if m.cmdActive { + t.Error("palette opened while a command was running") + } + + // "c" cancels. + um, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + m = um.(dashModel) + if !m.cancelled { + t.Error("'c' did not mark the command cancelled") + } + + // The result message clears the running state and returns to the menu — + // a cancelled command shows NO result pane. + um, _ = m.Update(cmdResultMsg{title: "status", body: "partial", err: context.Canceled}) + m = um.(dashModel) + if m.running != "" || m.cancel != nil { + t.Error("running state not cleared after result") + } + if m.result != nil { + t.Errorf("cancelled command should not open a result pane, got %+v", m.result) + } + if m.cancelled { + t.Error("cancelled flag not reset") + } +} + +func TestResultPaneRenders(t *testing.T) { + m := newDash(nil, "/x/awsmfa", "", 10*time.Second) + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = sized.(dashModel) + updated, _ := m.Update(cmdResultMsg{title: "db list", body: "tenant-a\ntenant-b", err: nil}) + m = updated.(dashModel) + + if m.result == nil { + t.Fatal("cmdResultMsg did not open the result pane") + } + out := m.View() + if !strings.Contains(out, "mydev db list") || !strings.Contains(out, "tenant-a") { + t.Errorf("result pane missing title/body\n---\n%s", out) + } + + // esc closes it. + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if updated.(dashModel).result != nil { + t.Error("esc did not close the result pane") + } +} + +func TestDashCommandPalette(t *testing.T) { + m := newDash([]string{"/tmp"}, "/x/awsmfa", "/x/docker-compose.yml", 10*time.Second) + + // ":" opens the palette. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}}) + m = updated.(dashModel) + if !m.cmdActive { + t.Fatal("':' did not open the command palette") + } + if !strings.Contains(m.View(), "mydev ❯") { + t.Errorf("palette prompt not shown\n---\n%s", m.View()) + } + + // esc closes it without running anything. + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(dashModel) + if m.cmdActive { + t.Error("esc did not close the palette") + } +} diff --git a/cmd/data.go b/cmd/data.go new file mode 100644 index 0000000..b24324d --- /dev/null +++ b/cmd/data.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "os" + + "mydev/internal/config" + "mydev/internal/run" + + "github.com/spf13/cobra" +) + +// devtool runs `rundev.sh ./bin/console mystore:devtool: ` from +// inside the legacy repo (mirrors the shell `myl table/snapshot/userfiles`). +func devtool(task string, args ...string) { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + rundev, err := cfg.LegacyRunDev() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + full := append([]string{"./bin/console", "mystore:devtool:" + task}, args...) + // cwd = legacy repo so ./bin/console resolves. + if err := run.Stream(cfg.LegacyDir, rundev, full...); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var dataCmd = &cobra.Command{ + Use: "data", + Short: "Fetch prod data via legacy devtool (rundev.sh)", +} + +var dataTableCmd = &cobra.Command{ + Use: "table [args...]", + Short: "Get a table (mystore:devtool:gettable)", + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { devtool("gettable", args...) }, +} + +var dataSnapshotCmd = &cobra.Command{ + Use: "snapshot [args...]", + Short: "Get a snapshot (mystore:devtool:getsnapshot)", + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { devtool("getsnapshot", args...) }, +} + +var dataUserfilesCmd = &cobra.Command{ + Use: "userfiles [args...]", + Short: "Get user files (mystore:devtool:getuserfiles)", + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { devtool("getuserfiles", args...) }, +} + +func init() { + dataCmd.AddCommand(dataTableCmd, dataSnapshotCmd, dataUserfilesCmd) + rootCmd.AddCommand(dataCmd) +} diff --git a/cmd/db.go b/cmd/db.go new file mode 100644 index 0000000..914bbe8 --- /dev/null +++ b/cmd/db.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "os" + + "mydev/internal/config" + "mydev/internal/run" + + "github.com/spf13/cobra" +) + +// devBin resolves a script under dev_dir/bin, exiting on config error. +func devBin(script string) string { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + p, err := cfg.DevBin(script) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return p +} + +func runScript(script string, args ...string) { + if err := run.Stream("", devBin(script), args...); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var dbCmd = &cobra.Command{ + Use: "db", + Short: "Database access requests (dev/bin scripts)", +} + +var dbBastCmd = &cobra.Command{ + Use: "bast", + Short: "Request bastion access (request_bast_access.sh)", + Run: func(cmd *cobra.Command, args []string) { runScript("request_bast_access.sh") }, +} + +var dbGrantCmd = &cobra.Command{ + Use: "grant [arg]", + Short: "Request a grant (grant_request.sh)", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { runScript("grant_request.sh", args...) }, +} + +var dbListCmd = &cobra.Command{ + Use: "list [arg]", + Short: "List grants (grant_list.sh)", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { runScript("grant_list.sh", args...) }, +} + +func init() { + dbCmd.AddCommand(dbBastCmd, dbGrantCmd, dbListCmd) + rootCmd.AddCommand(dbCmd) +} diff --git a/cmd/dev.go b/cmd/dev.go new file mode 100644 index 0000000..e7b4f27 --- /dev/null +++ b/cmd/dev.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "text/tabwriter" + + "mydev/internal/checks" + "mydev/internal/config" + "mydev/internal/run" + + "github.com/spf13/cobra" +) + +// devCompose loads config and returns the compose file path, exiting on error. +func devCompose() string { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + file, err := cfg.ComposeFile() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return file +} + +// compose runs `docker compose --file ` streamed to terminal. +func compose(file string, args ...string) { + full := append([]string{"compose", "--file", file}, args...) + if err := run.Stream("", "docker", full...); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// ensureAuthed re-auths first if the AWS session is gone (mirrors `myd auth`). +func ensureAuthed() { + cfg, err := config.Load() + if err != nil { + return + } + awsmfa, err := cfg.AWSMFA() + if err != nil { + return + } + if s := checks.AWSAuth(context.Background(), awsmfa); !s.Authed { + fmt.Println("AWS session expired — re-authenticating…") + _ = run.Stream("", awsmfa, "auth") + } +} + +var devCmd = &cobra.Command{ + Use: "dev", + Short: "Control the dev docker-compose stack", +} + +var devStartCmd = &cobra.Command{ + Use: "start", + Short: "Auth, then start the stack (up -d --force-recreate)", + Run: func(cmd *cobra.Command, args []string) { + ensureAuthed() + compose(devCompose(), "up", "-d", "--force-recreate") + }, +} + +var devStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the stack", + Run: func(cmd *cobra.Command, args []string) { compose(devCompose(), "stop") }, +} + +var devRestartCmd = &cobra.Command{ + Use: "restart", + Short: "Stop, auth, then start again", + Run: func(cmd *cobra.Command, args []string) { + file := devCompose() + compose(file, "stop") + ensureAuthed() + compose(file, "up", "-d", "--force-recreate") + }, +} + +var devRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Tear down the stack (down)", + Run: func(cmd *cobra.Command, args []string) { compose(devCompose(), "down") }, +} + +var devStatusFetch bool + +var devStatusCmd = &cobra.Command{ + Use: "status", + Short: "Check if running containers match the current dev repo", + Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + + // Git freshness: is the dev repo itself behind origin? + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if cfg.DevDir != "" { + if devStatusFetch { + fmt.Println("fetching dev repo…") + } + fr := checks.GitFreshness(ctx, cfg.DevDir, devStatusFetch) + switch { + case fr.Err != "": + fmt.Printf("dev repo: ? (%s)\n\n", fr.Err) + case fr.NoUpstream: + fmt.Printf("dev repo: %s (no upstream)\n\n", fr.Branch) + case fr.Behind > 0: + fmt.Printf("dev repo: %s ✗ behind origin by %d — git pull to get latest defs\n\n", fr.Branch, fr.Behind) + default: + fmt.Printf("dev repo: %s ✓ up to date with origin\n\n", fr.Branch) + } + } + + file := devCompose() + drift, errMsg := checks.ComposeDrift(ctx, file) + if errMsg != "" { + fmt.Fprintln(os.Stderr, errMsg) + os.Exit(1) + } + sort.Slice(drift, func(i, j int) bool { return drift[i].Service < drift[j].Service }) + + stale := 0 + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + for _, d := range drift { + mark := "✓" + if !d.UpToDate() { + mark = "✗" + stale++ + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", mark, d.Service, d.State, d.Reason()) + } + w.Flush() + + if stale > 0 { + fmt.Printf("\n%d service(s) out of date — run 'mydev dev restart' to recreate\n", stale) + os.Exit(1) + } + fmt.Println("\nall services up to date") + }, +} + +func init() { + devStatusCmd.Flags().BoolVar(&devStatusFetch, "fetch", true, "git fetch the dev repo first (set --fetch=false to skip network)") + devCmd.AddCommand(devStartCmd, devStopCmd, devRestartCmd, devRemoveCmd, devStatusCmd) + rootCmd.AddCommand(devCmd) +} diff --git a/cmd/docker.go b/cmd/docker.go new file mode 100644 index 0000000..016f4b1 --- /dev/null +++ b/cmd/docker.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + + "mydev/internal/checks" + + "github.com/spf13/cobra" +) + +var dockerCmd = &cobra.Command{ + Use: "docker", + Short: "List docker containers and their status", + Run: func(cmd *cobra.Command, args []string) { + list, errMsg := checks.Containers(context.Background()) + if errMsg != "" { + fmt.Printf("✗ docker error: %s\n", errMsg) + os.Exit(1) + } + if len(list) == 0 { + fmt.Println("no containers") + return + } + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATE\tSTATUS\tIMAGE") + for _, c := range list { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Name, c.State, c.Status, c.Image) + } + w.Flush() + }, +} + +func init() { + rootCmd.AddCommand(dockerCmd) +} diff --git a/cmd/port.go b/cmd/port.go new file mode 100644 index 0000000..9920259 --- /dev/null +++ b/cmd/port.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + "os" + + "mydev/internal/run" + + "github.com/spf13/cobra" +) + +// port wraps `lsof -i :` (the shell `portcheck`). No config needed. +var portCmd = &cobra.Command{ + Use: "port ", + Short: "Show what is listening on a port (lsof -i :PORT)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // lsof exits 1 when nothing is on the port — report that plainly. + if err := run.Stream("", "lsof", "-i", ":"+args[0]); err != nil { + fmt.Printf("nothing listening on :%s\n", args[0]) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(portCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..af8e765 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "mydev", + Short: "Dev environment monitor: AWS auth, docker, repos", + Long: `MyDev watches your local dev setup. + +Run a subcommand for a one-shot check, or 'mydev dash' +for a live refreshing TUI dashboard.`, +} + +// Execute runs the root command. Called by main. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..f1b9ec0 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + + "mydev/internal/checks" + "mydev/internal/config" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "One-shot overview: AWS auth + docker + repos", + Run: func(cmd *cobra.Command, args []string) { + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + ctx := context.Background() + + // AWS + if awsmfa, err := cfg.AWSMFA(); err != nil { + fmt.Printf("AWS – %s\n", err) + } else { + a := checks.AWSAuth(ctx, awsmfa) + switch { + case a.Authed: + fmt.Printf("AWS ✓ authed %s\n", a.Detail) + case a.Err != "": + fmt.Printf("AWS ✗ awsmfa error: %s\n", a.Err) + default: + fmt.Printf("AWS ✗ re-auth needed — run 'mydev auth login'\n") + } + } + + // Docker + cs, derr := checks.Containers(ctx) + if derr != "" { + fmt.Printf("DOCKER ✗ %s\n", derr) + } else { + running := 0 + for _, c := range cs { + if c.State == "running" { + running++ + } + } + fmt.Printf("DOCKER %d running / %d total\n", running, len(cs)) + } + + // Repos + rs, rerr := checks.ReposMulti(ctx, cfg.ReposRoots) + if rerr != "" { + fmt.Printf("REPOS ✗ %s\n", rerr) + } else { + fmt.Println("REPOS") + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + for _, r := range rs { + flag := "clean" + if r.Dirty { + flag = "dirty" + } + if r.Err != "" { + flag = "err: " + r.Err + } + fmt.Fprintf(w, " %s\t%s\t%s\n", r.Name, r.Branch, flag) + } + w.Flush() + } + }, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..92948d1 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module mydev + +go 1.25.1 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/muesli/reflow v0.3.0 + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e0196ea --- /dev/null +++ b/go.sum @@ -0,0 +1,70 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/checks/auth.go b/internal/checks/auth.go new file mode 100644 index 0000000..7e77c0a --- /dev/null +++ b/internal/checks/auth.go @@ -0,0 +1,83 @@ +package checks + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + "time" +) + +// AuthStatus holds the result of an AWS auth check via the awsmfa tool. +type AuthStatus struct { + Authed bool + Account string + Expires time.Time // zero if unknown; session expiry from awsmfa + Detail string // human summary line + Err string // populated when awsmfa itself failed to run +} + +// notAuthedMarker is the exact string awsmfa prints when the session is gone. +const notAuthedMarker = "Not authenticated." + +// AWSAuth runs ` status` and decides auth state from its output. +// This mirrors the shell `myd auth` check — awsmfa is the real MFA session, +// not `aws sts`. awsmfaPath is the full path to the awsmfa command. +// +// Authed output looks like: +// +// Success. +// { "UserId": ..., "Account": "0482...", "Arn": ... } +// Expire: 2026-06-12T19:25:19+00:00 +func AWSAuth(ctx context.Context, awsmfaPath string) AuthStatus { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, awsmfaPath, "status").CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil && text == "" { + return AuthStatus{Authed: false, Err: err.Error()} + } + if strings.Contains(text, notAuthedMarker) { + return AuthStatus{Authed: false, Detail: notAuthedMarker} + } + + s := AuthStatus{Authed: true} + + // Account: decode the JSON identity block. Use a Decoder so the trailing + // "Expire:" line after the closing brace doesn't fail parsing. + if i := strings.IndexByte(text, '{'); i >= 0 { + var id struct { + Account string `json:"Account"` + } + if json.NewDecoder(strings.NewReader(text[i:])).Decode(&id) == nil { + s.Account = id.Account + } + } + + // Expiry: the "Expire: " line. + for _, line := range strings.Split(text, "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "Expire:"); ok { + if t, perr := time.Parse(time.RFC3339, strings.TrimSpace(rest)); perr == nil { + s.Expires = t + } + } + } + + s.Detail = summarize(s.Account, s.Expires) + return s +} + +func summarize(account string, expires time.Time) string { + var parts []string + if account != "" { + parts = append(parts, "account "+account) + } + if !expires.IsZero() { + parts = append(parts, "expires "+expires.Local().Format("15:04")) + } + if len(parts) == 0 { + return "authed" + } + return strings.Join(parts, " · ") +} diff --git a/internal/checks/compose.go b/internal/checks/compose.go new file mode 100644 index 0000000..03e31a6 --- /dev/null +++ b/internal/checks/compose.go @@ -0,0 +1,178 @@ +package checks + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + "time" +) + +// ServiceDrift reports whether one compose service's running container still +// matches the dev repo: same compose config and same image as defined now. +type ServiceDrift struct { + Service string + State string // running, exited, created, ... + Missing bool // no container created for this service yet + ConfigChanged bool // docker-compose.yml changed since container start + ImageRebuilt bool // image tag now points to a different (newer) build +} + +// UpToDate is true when the running container reflects the current dev repo. +func (d ServiceDrift) UpToDate() bool { + return !d.Missing && !d.ConfigChanged && !d.ImageRebuilt +} + +// Reason is a short human explanation of the drift state. +func (d ServiceDrift) Reason() string { + switch { + case d.Missing: + return "not created" + case d.ConfigChanged && d.ImageRebuilt: + return "config changed + image rebuilt" + case d.ConfigChanged: + return "compose config changed" + case d.ImageRebuilt: + return "image rebuilt — recreate to pick up" + default: + return "up to date" + } +} + +// ComposeDrift compares every service in the compose file against its running +// container. composeFile is the path to docker-compose.yml. +func ComposeDrift(ctx context.Context, composeFile string) ([]ServiceDrift, string) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + // 1. Current per-service config hashes from the compose file. + wantHash, errMsg := composeHashes(ctx, composeFile) + if errMsg != "" { + return nil, errMsg + } + + // 2. Existing containers for the project, keyed by service name. + conts, errMsg := composeContainers(ctx, composeFile) + if errMsg != "" { + return nil, errMsg + } + + // 3. Cache of "current image ID for this ref" to avoid duplicate inspects. + curImageID := map[string]string{} + + var out []ServiceDrift + for service := range wantHash { + d := ServiceDrift{Service: service} + c, ok := conts[service] + if !ok { + d.Missing = true + out = append(out, d) + continue + } + d.State = c.State + d.ConfigChanged = c.ConfigHash != "" && c.ConfigHash != wantHash[service] + + if c.Ref != "" { + id, seen := curImageID[c.Ref] + if !seen { + id = imageID(ctx, c.Ref) // "" if the tag no longer exists + curImageID[c.Ref] = id + } + // Different (or vanished) current image vs what the container pinned. + d.ImageRebuilt = id != c.PinnedImage + } + out = append(out, d) + } + return out, "" +} + +// composeHashes runs `docker compose config --hash '*'` → map[service]hash. +func composeHashes(ctx context.Context, composeFile string) (map[string]string, string) { + out, err := exec.CommandContext(ctx, "docker", "compose", "--file", composeFile, "config", "--hash", "*").Output() + if err != nil { + return nil, dockerErr(err) + } + m := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 { + m[fields[0]] = fields[1] + } + } + return m, "" +} + +type containerInfo struct { + Service string + State string + ConfigHash string + PinnedImage string // image ID the container actually runs + Ref string // image tag/ref, e.g. mystore5-local-dev:latest +} + +// composeContainers lists project containers and inspects their compose labels. +func composeContainers(ctx context.Context, composeFile string) (map[string]containerInfo, string) { + psOut, err := exec.CommandContext(ctx, "docker", "compose", "--file", composeFile, "ps", "-a", "--format", "json").Output() + if err != nil { + return nil, dockerErr(err) + } + + var names []string + for _, line := range strings.Split(strings.TrimSpace(string(psOut)), "\n") { + if line == "" { + continue + } + var row struct { + Name string `json:"Name"` + } + if json.Unmarshal([]byte(line), &row) == nil && row.Name != "" { + names = append(names, row.Name) + } + } + if len(names) == 0 { + return map[string]containerInfo{}, "" + } + + // One inspect call, one pipe-delimited line per container. A pipe can't + // appear in service names, hashes, or image IDs, so it's a safe separator. + const f = `{{index .Config.Labels "com.docker.compose.service"}}` + "|" + + `{{.State.Status}}` + "|" + + `{{index .Config.Labels "com.docker.compose.config-hash"}}` + "|" + + `{{.Image}}` + "|" + `{{.Config.Image}}` + args := append([]string{"inspect", "--format", f}, names...) + insOut, err := exec.CommandContext(ctx, "docker", args...).Output() + if err != nil { + return nil, dockerErr(err) + } + + m := map[string]containerInfo{} + for _, line := range strings.Split(strings.TrimSpace(string(insOut)), "\n") { + p := strings.Split(line, "|") + if len(p) != 5 || p[0] == "" { + continue + } + m[p[0]] = containerInfo{ + Service: p[0], + State: p[1], + ConfigHash: p[2], + PinnedImage: p[3], + Ref: p[4], + } + } + return m, "" +} + +func imageID(ctx context.Context, ref string) string { + out, err := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", ref).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func dockerErr(err error) string { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + return strings.TrimSpace(string(ee.Stderr)) + } + return err.Error() +} diff --git a/internal/checks/docker.go b/internal/checks/docker.go new file mode 100644 index 0000000..4709c02 --- /dev/null +++ b/internal/checks/docker.go @@ -0,0 +1,57 @@ +package checks + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + "time" +) + +// Container is one docker container's summary state. +type Container struct { + Name string + Image string + State string // running, exited, ... + Status string // human string, e.g. "Up 3 hours" +} + +// Containers runs `docker ps -a` and parses the line-delimited JSON output. +// Returns ([], "") when docker works but no containers exist. +func Containers(ctx context.Context) ([]Container, string) { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + // docker emits one JSON object per line with --format json. + out, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "json").Output() + if err != nil { + msg := err.Error() + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + msg = strings.TrimSpace(string(ee.Stderr)) + } + return nil, msg + } + + var list []Container + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + var row struct { + Names string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + } + if err := json.Unmarshal([]byte(line), &row); err != nil { + continue + } + list = append(list, Container{ + Name: row.Names, + Image: row.Image, + State: row.State, + Status: row.Status, + }) + } + return list, "" +} diff --git a/internal/checks/repos.go b/internal/checks/repos.go new file mode 100644 index 0000000..5b461f8 --- /dev/null +++ b/internal/checks/repos.go @@ -0,0 +1,154 @@ +package checks + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Repo is one git repo's working-tree state. +type Repo struct { + Name string + Branch string + Dirty bool + Ahead int + Err string +} + +// ReposMulti scans several roots and concatenates the results. The first +// root that errors stops the scan and returns that error message. +func ReposMulti(ctx context.Context, roots []string) ([]Repo, string) { + var all []Repo + for _, root := range roots { + rs, errMsg := Repos(ctx, root) + if errMsg != "" { + return all, errMsg + } + all = append(all, rs...) + } + return all, "" +} + +// Repos scans the immediate subdirectories of root for git repos and +// reports branch + dirty state for each. Shallow scan (one level) on +// purpose — point it at the parent of your cloned repos. +func Repos(ctx context.Context, root string) ([]Repo, string) { + entries, err := os.ReadDir(root) + if err != nil { + return nil, err.Error() + } + + var repos []Repo + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(root, e.Name()) + if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { + continue // not a repo + } + repos = append(repos, repoStatus(ctx, dir, e.Name())) + } + return repos, "" +} + +func repoStatus(ctx context.Context, dir, name string) Repo { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + r := Repo{Name: name} + + branch, err := git(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + r.Err = err.Error() + return r + } + r.Branch = branch + + // Porcelain status: any output => dirty working tree. + status, err := git(ctx, dir, "status", "--porcelain") + if err != nil { + r.Err = err.Error() + return r + } + r.Dirty = strings.TrimSpace(status) != "" + + return r +} + +// Freshness reports how a repo's checked-out branch compares to its upstream. +type Freshness struct { + Branch string + Behind int // commits on upstream not in HEAD (you need to pull) + Ahead int // commits in HEAD not on upstream (unpushed) + NoUpstream bool // branch has no tracking remote + Err string +} + +// UpToDate is true when the branch is level with its upstream. +func (f Freshness) UpToDate() bool { + return f.Err == "" && !f.NoUpstream && f.Behind == 0 && f.Ahead == 0 +} + +// GitFreshness checks one repo against its upstream. When fetch is true it runs +// `git fetch` first (network) so "behind" reflects the real remote, not a stale +// local tracking ref. +func GitFreshness(ctx context.Context, dir string, fetch bool) Freshness { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + f := Freshness{} + branch, err := git(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + f.Err = err.Error() + return f + } + f.Branch = branch + + if fetch { + if _, err := git(ctx, dir, "fetch", "--quiet"); err != nil { + f.Err = "fetch: " + err.Error() + return f + } + } + + // No upstream configured → nothing to compare against. + if _, err := git(ctx, dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); err != nil { + f.NoUpstream = true + return f + } + + // left-right count: "\t" relative to upstream...HEAD. + out, err := git(ctx, dir, "rev-list", "--left-right", "--count", "@{u}...HEAD") + if err != nil { + f.Err = err.Error() + return f + } + parts := strings.Fields(out) + if len(parts) == 2 { + f.Behind = atoi(parts[0]) + f.Ahead = atoi(parts[1]) + } + return f +} + +func atoi(s string) int { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return n + } + n = n*10 + int(r-'0') + } + return n +} + +func git(ctx context.Context, dir string, args ...string) (string, error) { + c := exec.CommandContext(ctx, "git", args...) + c.Dir = dir + out, err := c.Output() + return strings.TrimSpace(string(out)), err +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8f02a49 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,146 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config is the on-disk settings file. Add fields here as the tool grows. +type Config struct { + // ReposRoots are parent directories scanned (one level deep) for git repos. + ReposRoots []string `yaml:"repos_roots"` + // DevDir is the `dev` repo: holds commands/awsmfa, docker-compose.yml, bin/*.sh. + DevDir string `yaml:"dev_dir"` + // LegacyDir is the `legacy` repo: holds rundev.sh for prod-data console tools. + LegacyDir string `yaml:"legacy_dir"` +} + +// ErrNotSet means the config file does not exist yet. +var ErrNotSet = errors.New("config not set") + +// Path returns the config file location: $XDG_CONFIG_HOME/mydev/config.yaml, +// falling back to ~/.config/mydev/config.yaml on all platforms. +func Path() (string, error) { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "mydev", "config.yaml"), nil +} + +// Load reads and parses the config file. Returns ErrNotSet (wrapped) if the +// file does not exist — callers should surface that as a clear "run config init". +func Load() (*Config, error) { + path, err := Path() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: no config at %s — run 'mydev config init'", ErrNotSet, path) + } + return nil, err + } + var c Config + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &c, nil +} + +// --- validated accessors: each errors clearly when its setting is missing --- + +func (c *Config) requireDev() (string, error) { + if c.DevDir == "" { + return "", errors.New("dev_dir not set in config — add it and retry") + } + return c.DevDir, nil +} + +func (c *Config) requireLegacy() (string, error) { + if c.LegacyDir == "" { + return "", errors.New("legacy_dir not set in config — add it and retry") + } + return c.LegacyDir, nil +} + +// AWSMFA returns the path to the awsmfa command inside dev_dir. +func (c *Config) AWSMFA() (string, error) { + dev, err := c.requireDev() + if err != nil { + return "", err + } + return filepath.Join(dev, "commands", "awsmfa"), nil +} + +// ComposeFile returns the dev docker-compose.yml path. +func (c *Config) ComposeFile() (string, error) { + dev, err := c.requireDev() + if err != nil { + return "", err + } + return filepath.Join(dev, "docker-compose.yml"), nil +} + +// DevBin returns the path to a script under dev_dir/bin. +func (c *Config) DevBin(script string) (string, error) { + dev, err := c.requireDev() + if err != nil { + return "", err + } + return filepath.Join(dev, "bin", script), nil +} + +// LegacyRunDev returns the legacy rundev.sh path (runs cmds in the container). +func (c *Config) LegacyRunDev() (string, error) { + legacy, err := c.requireLegacy() + if err != nil { + return "", err + } + return filepath.Join(legacy, "rundev.sh"), nil +} + +const sample = `# MyDev config + +# Parent directories scanned one level deep for git repos. +repos_roots: + - %[1]s/code + +# The 'dev' repo — provides commands/awsmfa, docker-compose.yml, bin/*.sh. +dev_dir: %[1]s/code/dev + +# The 'legacy' repo — provides rundev.sh for prod-data console tools. +legacy_dir: %[1]s/code/legacy +` + +// Init writes a starter config file if none exists. Returns the path written. +// Refuses to overwrite an existing file. +func Init() (string, error) { + path, err := Path() + if err != nil { + return "", err + } + if _, err := os.Stat(path); err == nil { + return path, fmt.Errorf("config already exists at %s", path) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + home, _ := os.UserHomeDir() + if home == "" { + home = "/Users/you" + } + if err := os.WriteFile(path, []byte(fmt.Sprintf(sample, home)), 0o644); err != nil { + return "", err + } + return path, nil +} diff --git a/internal/run/run.go b/internal/run/run.go new file mode 100644 index 0000000..e62a5e7 --- /dev/null +++ b/internal/run/run.go @@ -0,0 +1,21 @@ +// Package run executes external commands wired straight to the terminal — +// stdin/stdout/stderr passed through — for interactive or long-running tools +// (MFA prompts, docker compose, prod-data fetches). No timeout: these are +// foreground commands the user is watching. +package run + +import ( + "os" + "os/exec" +) + +// Stream runs name+args with the process's own stdio attached, so prompts and +// live output reach the user directly. dir sets the working directory (""=cwd). +func Stream(dir, name string, args ...string) error { + c := exec.Command(name, args...) + c.Dir = dir + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..527a568 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "mydev/cmd" + +func main() { + cmd.Execute() +}