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) <noreply@anthropic.com>
This commit is contained in:
Victor Abrell 2026-06-12 14:31:58 +02:00
commit 7140397a13
22 changed files with 2401 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
bin
mydev

140
README.md Normal file
View File

@ -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 <args>
mydev data snapshot <args>
mydev data userfiles <args>
# DB access requests (dev/bin/*.sh)
mydev db bast # request_bast_access.sh
mydev db grant <arg> # 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 <number>`). `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 <cmd>… [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.

64
cmd/auth.go Normal file
View File

@ -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)
}

46
cmd/config.go Normal file
View File

@ -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)
}

667
cmd/dash.go Normal file
View File

@ -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 <name> db grant <id> 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 <args...>` 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
}

275
cmd/dash_render_test.go Normal file
View File

@ -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")
}
}

63
cmd/data.go Normal file
View File

@ -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:<task> <args...>` 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)
}

63
cmd/db.go Normal file
View File

@ -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)
}

157
cmd/dev.go Normal file
View File

@ -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 <file> <args...>` 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)
}

38
cmd/docker.go Normal file
View File

@ -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)
}

28
cmd/port.go Normal file
View File

@ -0,0 +1,28 @@
package cmd
import (
"fmt"
"os"
"mydev/internal/run"
"github.com/spf13/cobra"
)
// port wraps `lsof -i :<port>` (the shell `portcheck`). No config needed.
var portCmd = &cobra.Command{
Use: "port <number>",
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)
}

25
cmd/root.go Normal file
View File

@ -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)
}
}

79
cmd/status.go Normal file
View File

@ -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)
}

38
go.mod Normal file
View File

@ -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
)

70
go.sum Normal file
View File

@ -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=

83
internal/checks/auth.go Normal file
View File

@ -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 `<awsmfa> 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: <RFC3339>" 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, " · ")
}

178
internal/checks/compose.go Normal file
View File

@ -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()
}

57
internal/checks/docker.go Normal file
View File

@ -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, ""
}

154
internal/checks/repos.go Normal file
View File

@ -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: "<behind>\t<ahead>" 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
}

146
internal/config/config.go Normal file
View File

@ -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
}

21
internal/run/run.go Normal file
View File

@ -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()
}

7
main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "mydev/cmd"
func main() {
cmd.Execute()
}