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:
commit
7140397a13
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
bin
|
||||||
|
mydev
|
||||||
140
README.md
Normal file
140
README.md
Normal 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
64
cmd/auth.go
Normal 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
46
cmd/config.go
Normal 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
667
cmd/dash.go
Normal 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
275
cmd/dash_render_test.go
Normal 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
63
cmd/data.go
Normal 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
63
cmd/db.go
Normal 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
157
cmd/dev.go
Normal 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
38
cmd/docker.go
Normal 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
28
cmd/port.go
Normal 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
25
cmd/root.go
Normal 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
79
cmd/status.go
Normal 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
38
go.mod
Normal 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
70
go.sum
Normal 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
83
internal/checks/auth.go
Normal 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
178
internal/checks/compose.go
Normal 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
57
internal/checks/docker.go
Normal 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
154
internal/checks/repos.go
Normal 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
146
internal/config/config.go
Normal 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
21
internal/run/run.go
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user