adding init program

This commit is contained in:
lwoodard
2026-04-13 12:45:41 -06:00
parent 639a08a808
commit 91b7028382
12 changed files with 1035 additions and 8 deletions

126
cmd/streamdeck-init/grid.go Normal file
View File

@@ -0,0 +1,126 @@
// grid.go — Renders the 8x4 Stream Deck key grid in the terminal.
//
// Uses lipgloss for styling: free slots are green with [index], occupied slots
// are dimmed with a short label (function name, icon name, or command prefix).
//
// ## Layout
//
// The grid mirrors the physical Stream Deck XL layout:
//
// 0 1 2 3 4 5 6 7
// 8 9 10 11 12 13 14 15
// 16 17 18 19 20 21 22 23
// 24 25 26 27 28 29 30 31
//
// ## Expanding
//
// To support non-XL models (e.g. MK.2 with 15 keys in a 5x3 grid), make
// gridCols and gridRows parameters instead of constants, and derive them
// from the device config's product_id. The device model → key count mapping
// is in internal/device/streamdeck.go.
//
// To add richer labels (e.g. showing params or the icon filename alongside
// the function name), modify keyLabel(). Keep labels under 8 chars to fit
// the cell width.
package main
import (
"fmt"
"strings"
"github.com/WoodardDigital/streamdeck-go/internal/config"
"github.com/charmbracelet/lipgloss"
)
// Grid dimensions — hardcoded for Stream Deck XL (8 columns × 4 rows = 32 keys).
const (
gridCols = 8
gridRows = 4
gridKeys = gridCols * gridRows
)
// Styles for the grid cells. Color numbers are ANSI 256-color palette indices:
//
// 10 = bright green (free slots — draws the eye)
// 8 = dark gray (occupied slots — visually recedes)
// 12 = bright blue (title)
var (
freeStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Bold(true).Foreground(lipgloss.Color("10"))
occupiedStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Foreground(lipgloss.Color("8"))
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8"))
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
)
// renderGrid builds a string containing the full 8x4 key grid with a legend.
// Free slots show [index] in green; occupied slots show a short label in gray.
// The grid is printed to stdout before the key-picking form.
func renderGrid(keys map[int]config.KeyConfig) string {
var b strings.Builder
b.WriteString(titleStyle.Render(" Stream Deck XL — 8×4 grid"))
b.WriteString("\n\n")
for row := 0; row < gridRows; row++ {
cells := make([]string, gridCols)
for col := 0; col < gridCols; col++ {
idx := row*gridCols + col
keyCfg, occupied := keys[idx]
if occupied {
label := keyLabel(keyCfg)
cells[col] = borderStyle.Render(occupiedStyle.Render(label))
} else {
cells[col] = borderStyle.Render(freeStyle.Render(fmt.Sprintf("[%d]", idx)))
}
}
// JoinHorizontal places cells side by side, aligned at the top.
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...))
b.WriteString("\n")
}
// Legend explaining the color coding.
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("[n]"))
b.WriteString(" = free ")
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("dim"))
b.WriteString(" = occupied\n")
return b.String()
}
// keyLabel returns a short display label for an occupied key (max 8 chars).
//
// Priority order:
// 1. Module function name (if module-based key)
// 2. Icon filename without extension (if static key)
// 3. Command prefix (fallback)
// 4. "???" (shouldn't happen with valid config)
func keyLabel(k config.KeyConfig) string {
if k.Module != "" {
label := k.Function
if len(label) > 8 {
label = label[:8]
}
return label
}
if k.Icon != "" {
label := k.Icon
// Strip file extension for brevity (e.g. "firefox.png" → "firefox").
if dot := strings.LastIndex(label, "."); dot > 0 {
label = label[:dot]
}
if len(label) > 8 {
label = label[:8]
}
return label
}
if k.Command != "" {
label := k.Command
if len(label) > 8 {
label = label[:8]
}
return label
}
return "???"
}

518
cmd/streamdeck-init/main.go Normal file
View File

@@ -0,0 +1,518 @@
// streamdeck-init — Interactive TUI for configuring Stream Deck keys.
//
// This is a config builder, not an installer. It reads and appends to
// ~/.config/streamdeck-go/config.yaml (or a custom path via -config).
// It does NOT touch services, binaries, udev rules, or dependencies.
//
// ## How it works
//
// 1. Bootstrap: ensures config.yaml and modules.yaml exist (creates from
// defaults/embedded example if missing).
// 2. Loads both files using the same internal/config and internal/modules
// packages the daemon uses.
// 3. Renders an 8x4 key grid showing which slots are free vs occupied.
// 4. Walks the user through a series of huh forms:
// - Pick a key slot (0-31)
// - Choose action type: module function or raw shell command
// - If module: pick module → function → customize params (with defaults)
// - If command: type a shell command
// - Pick an icon from icons_dir (or type a custom filename)
// - Confirm and append to config.yaml
// 5. Loops: asks "Add another key?" after each addition.
//
// ## Expanding this tool
//
// To add new TUI steps (e.g. toggle key setup with poll config, or a
// "delete key" flow), add a new function following the pattern of
// configureModuleKey/configureCommandKey: build huh forms, collect values
// into a keyEntry, return it for the confirm/append step.
//
// To support new key types in the YAML output, update keyEntry and
// renderKeySnippet() in yaml.go.
//
// The grid rendering is in grid.go — it's hardcoded to 8x4 (XL). To
// support other models, make gridCols/gridRows dynamic based on the
// device config's product_id.
//
// ## Dependencies
//
// - github.com/charmbracelet/huh — form/prompt library (select, input, confirm)
// - github.com/charmbracelet/lipgloss — terminal styling (grid rendering, summary output)
// - internal/config — shared config loader and DefaultConfigPath()
// - internal/modules — shared module registry loader
// - internal/defaults — embedded modules.example.yaml for bootstrapping
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/WoodardDigital/streamdeck-go/internal/config"
"github.com/WoodardDigital/streamdeck-go/internal/defaults"
"github.com/WoodardDigital/streamdeck-go/internal/modules"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
func main() {
cfgPath := flag.String("config", config.DefaultConfigPath(), "path to config file")
flag.Parse()
// Bootstrap: ensure config dir, config.yaml, and modules.yaml exist.
// On a fresh install this creates everything from scratch so the user
// doesn't need to manually create files before running the TUI.
if err := bootstrap(*cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "setup error: %v\n", err)
os.Exit(1)
}
// Main loop — each iteration configures one key, then asks to continue.
for {
if err := runOnce(*cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
var again bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Add another key?").
Value(&again),
),
)
if err := form.Run(); err != nil {
break
}
if !again {
break
}
}
fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render("✓") +
" Done — the daemon will auto-reload your config.")
}
// bootstrap ensures the config directory, config.yaml, and modules.yaml exist.
//
// Creates:
// - ~/.config/streamdeck-go/ (config dir)
// - ~/.config/streamdeck-go/icons/ (icon storage)
// - ~/.config/streamdeck-go/config.yaml (from inline default if missing)
// - ~/.config/streamdeck-go/modules.yaml (from embedded example if missing)
//
// The embedded modules.example.yaml comes from internal/defaults, which uses
// //go:embed so the init binary works standalone without the repo present.
func bootstrap(cfgPath string) error {
dir := filepath.Dir(cfgPath)
iconsDir := filepath.Join(dir, "icons")
if err := os.MkdirAll(iconsDir, 0755); err != nil {
return err
}
// Create a minimal config.yaml if one doesn't exist yet.
// Uses "keys: {}" (empty map) which appendKeyToConfig handles specially —
// it replaces the {} with actual key entries on first append.
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
defaultCfg := fmt.Sprintf(`# streamdeck-go configuration
icons_dir: %s
brightness: 70
device:
vendor_id: 0x0fd9
product_id: 0x00ba
keys: {}
`, iconsDir)
if err := os.WriteFile(cfgPath, []byte(defaultCfg), 0644); err != nil {
return err
}
fmt.Printf("Created %s\n", cfgPath)
}
// Copy the embedded modules.example.yaml so module functions are available
// immediately. Users can edit this file to add custom modules later.
modPath := config.ModulesPath(cfgPath)
if _, err := os.Stat(modPath); os.IsNotExist(err) {
if err := os.WriteFile(modPath, defaults.ModulesExampleYAML, 0644); err != nil {
return err
}
fmt.Printf("Created %s\n", modPath)
}
return nil
}
// runOnce performs one complete key configuration cycle:
//
// load config → show grid → pick slot → pick action → pick icon → confirm → write
//
// Config is re-loaded at the start of each cycle so that keys added in the
// previous iteration show up as occupied in the grid.
func runOnce(cfgPath string) error {
// Re-read config each iteration to pick up keys added in previous cycles.
cfg, err := config.Load(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
// Load the module registry to enumerate available modules/functions.
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))
if err != nil {
return fmt.Errorf("load modules: %w", err)
}
// Show the 8x4 key grid so the user can see which slots are taken.
fmt.Print(renderGrid(cfg.Keys))
// Step 1: Pick a key slot (0-31).
keyIdx, err := pickKeySlot(cfg.Keys)
if err != nil {
return err
}
// Step 2: Choose between module function or raw shell command.
actionType, err := pickActionType()
if err != nil {
return err
}
// Step 3: Configure the key based on action type.
var entry keyEntry
entry.Index = keyIdx
if actionType == "module" {
// Module path: module → function → params (with defaults pre-filled).
entry, err = configureModuleKey(keyIdx, reg)
} else {
// Command path: just a shell command string.
entry, err = configureCommandKey(keyIdx)
}
if err != nil {
return err
}
// Step 4: Pick an icon file.
icon, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.Icon = icon
// Step 5: Show summary and confirm before writing.
fmt.Println()
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
fmt.Printf(" Key: %d\n", entry.Index)
fmt.Printf(" Icon: %s\n", entry.Icon)
if entry.Module != "" {
fmt.Printf(" Module: %s\n", entry.Module)
fmt.Printf(" Function: %s\n", entry.Function)
if len(entry.Params) > 0 {
fmt.Println(" Params:")
for k, v := range entry.Params {
fmt.Printf(" %s: %s\n", k, v)
}
}
} else {
fmt.Printf(" Command: %s\n", entry.Command)
}
fmt.Println()
var confirm bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Write this key to config?").
Value(&confirm),
),
)
if err := form.Run(); err != nil {
return err
}
if !confirm {
fmt.Println(" Skipped.")
return nil
}
// Step 6: Append the YAML snippet to config.yaml.
// This uses raw text append (not marshal/unmarshal) to preserve comments.
if err := appendKeyToConfig(cfgPath, entry); err != nil {
return fmt.Errorf("write config: %w", err)
}
fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render(" ✓") +
fmt.Sprintf(" Key %d added to config.", entry.Index))
return nil
}
// pickKeySlot presents a select list of all 32 key slots, showing which are
// occupied and what they're mapped to. Returns the selected key index.
//
// FUTURE: filter to show only free keys, or add a "overwrite?" confirmation
// when an occupied slot is picked.
func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
options := make([]huh.Option[int], gridKeys)
for i := 0; i < gridKeys; i++ {
label := fmt.Sprintf("%-3d", i)
if k, ok := keys[i]; ok {
label += fmt.Sprintf(" (occupied — %s)", keyLabel(k))
} else {
label += " (free)"
}
options[i] = huh.NewOption(label, i)
}
var keyIdx int
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Pick a key slot").
Options(options...).
Value(&keyIdx),
),
)
if err := form.Run(); err != nil {
return 0, err
}
return keyIdx, nil
}
// pickActionType asks whether to configure a module function or a raw shell command.
//
// FUTURE: add "Toggle key (with poll)" as a third option, which would walk
// through icon_true/icon_false and poll config setup.
func pickActionType() (string, error) {
var actionType string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("What should this key do?").
Options(
huh.NewOption("Module function (Slack, etc.)", "module"),
huh.NewOption("Shell command", "command"),
).
Value(&actionType),
),
)
if err := form.Run(); err != nil {
return "", err
}
return actionType, nil
}
// configureModuleKey walks the user through: module → function → params.
//
// Modules and functions are enumerated dynamically from the loaded registry,
// so any module added to modules.yaml automatically appears in the TUI.
//
// For each param defined in the function's FunctionDef.Params, an input field
// is shown pre-filled with the default value. The user can accept defaults or
// override per-key. Functions with no params (e.g. clear_status) skip this step.
//
// FUTURE: show a description/help text for each function (would require adding
// a "description" field to FunctionDef in internal/modules/modules.go).
func configureModuleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
entry := keyEntry{Index: keyIdx}
if reg == nil || len(reg.Modules) == 0 {
return entry, fmt.Errorf("no modules defined in modules.yaml")
}
// Pick module — enumerate all top-level keys from modules.yaml.
modNames := make([]string, 0, len(reg.Modules))
for name := range reg.Modules {
modNames = append(modNames, name)
}
sort.Strings(modNames)
var modName string
modOpts := make([]huh.Option[string], len(modNames))
for i, name := range modNames {
modOpts[i] = huh.NewOption(name, name)
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Pick a module").
Options(modOpts...).
Value(&modName),
),
)
if err := form.Run(); err != nil {
return entry, err
}
entry.Module = modName
// Pick function — enumerate all functions within the chosen module.
mod := reg.Modules[modName]
fnNames := make([]string, 0, len(mod))
for name := range mod {
fnNames = append(fnNames, name)
}
sort.Strings(fnNames)
var fnName string
fnOpts := make([]huh.Option[string], len(fnNames))
for i, name := range fnNames {
fnOpts[i] = huh.NewOption(name, name)
}
form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(fmt.Sprintf("Pick a function from %s", modName)).
Options(fnOpts...).
Value(&fnName),
),
)
if err := form.Run(); err != nil {
return entry, err
}
entry.Function = fnName
// Customize params — one input per param, pre-filled with the module default.
// paramPtrs is a []string (not []*string) so that &paramPtrs[i] gives a stable
// pointer to each slice element — huh.NewInput().Value() needs a *string.
fn := mod[fnName]
if len(fn.Params) > 0 {
paramKeys := make([]string, 0, len(fn.Params))
for k := range fn.Params {
paramKeys = append(paramKeys, k)
}
sort.Strings(paramKeys)
paramPtrs := make([]string, len(paramKeys))
paramFields := make([]huh.Field, len(paramKeys))
for i, k := range paramKeys {
paramPtrs[i] = fn.Params[k] // pre-fill with default
paramFields[i] = huh.NewInput().
Title(k).
Value(&paramPtrs[i]).
Placeholder(fn.Params[k])
}
form = huh.NewForm(
huh.NewGroup(paramFields...),
)
if err := form.Run(); err != nil {
return entry, err
}
// Collect the (possibly edited) values back into the entry.
entry.Params = make(map[string]string, len(paramKeys))
for i, k := range paramKeys {
entry.Params[k] = paramPtrs[i]
}
}
return entry, nil
}
// configureCommandKey asks for a raw shell command string.
// This is the non-module path — the command is written directly to config.yaml.
func configureCommandKey(keyIdx int) (keyEntry, error) {
entry := keyEntry{Index: keyIdx}
var cmd string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Shell command").
Value(&cmd).
Placeholder("e.g. open -a Firefox"),
),
)
if err := form.Run(); err != nil {
return entry, err
}
entry.Command = cmd
return entry, nil
}
// pickIcon lets the user select an icon file.
//
// If icons_dir has image files, shows a select list of them plus a "type custom"
// option. If the directory is empty, falls back to a plain text input.
//
// Supported extensions: .png, .jpg, .jpeg, .gif, .svg
//
// FUTURE: show icon previews (would require a terminal image protocol like
// sixel or kitty graphics). For now, filenames are enough.
func pickIcon(iconsDir string) (string, error) {
// Scan icons_dir for image files.
entries, err := os.ReadDir(iconsDir)
if err != nil && !os.IsNotExist(err) {
return "", err
}
var iconFiles []string
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".svg":
iconFiles = append(iconFiles, e.Name())
}
}
// No icons in directory — just ask for a filename.
if len(iconFiles) == 0 {
var icon string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Icon filename").
Value(&icon).
Placeholder("e.g. myicon.png"),
),
)
if err := form.Run(); err != nil {
return "", err
}
return icon, nil
}
// Show available icons as a select list with a "custom" escape hatch.
sort.Strings(iconFiles)
options := make([]huh.Option[string], 0, len(iconFiles)+1)
options = append(options, huh.NewOption("[ Type a custom filename ]", "__custom__"))
for _, f := range iconFiles {
options = append(options, huh.NewOption(f, f))
}
var icon string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(fmt.Sprintf("Pick an icon from %s", iconsDir)).
Options(options...).
Value(&icon),
),
)
if err := form.Run(); err != nil {
return "", err
}
if icon == "__custom__" {
form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Icon filename").
Value(&icon).
Placeholder("e.g. myicon.png"),
),
)
if err := form.Run(); err != nil {
return "", err
}
}
return icon, nil
}

148
cmd/streamdeck-init/yaml.go Normal file
View File

@@ -0,0 +1,148 @@
// yaml.go — Appends key entries to config.yaml without destroying comments.
//
// ## Why raw text append instead of yaml.Marshal?
//
// yaml.v3's Marshal destroys comments, reorders map keys, and normalises
// formatting. Users keep the default config's comments (key layout diagram,
// examples, etc.), so we must preserve them. The approach:
//
// - Parse config.yaml with config.Load() for DATA (which keys exist, icons_dir, etc.)
// - Write new keys by appending raw YAML text to the end of the file
//
// This works because keys: is always the last top-level block in config.yaml,
// and YAML maps are unordered, so appending a new 2-space-indented key entry
// to the end of the file is valid.
//
// ## Three cases handled by appendKeyToConfig
//
// Case A: "keys: {}" — the empty map literal from the default config generator.
//
// Replace {} with a newline, then insert the key block. This is the most
// common case on a fresh install.
//
// Case B: "keys:" with existing children — the normal case after keys exist.
//
// Append the new key block to the end of the file. It lands inside the
// keys: block because it's indented with 2 spaces.
//
// Case C: no "keys:" block at all — a malformed or minimal config.
//
// Append "keys:\n" then the key block.
//
// ## Expanding
//
// To support toggle keys (icon_true/icon_false + poll block), add those fields
// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines.
//
// To support deleting or editing existing keys, you'd need a different strategy
// (e.g. yaml.v3 node-level manipulation). That's not in scope for the current
// append-only tool.
package main
import (
"fmt"
"os"
"regexp"
"sort"
"strings"
)
// keyEntry holds the data collected from the TUI forms, ready to be rendered
// as a YAML snippet and appended to config.yaml.
//
// Two mutually exclusive modes:
// - Module key: Module + Function + Params are set, Command is empty
// - Command key: Command is set, Module/Function/Params are empty
type keyEntry struct {
Index int // key slot (0-31)
Icon string // icon filename relative to icons_dir
Module string // module name from modules.yaml (e.g. "slack")
Function string // function name within the module (e.g. "set_status")
Params map[string]string // param overrides (merged with module defaults at runtime)
Command string // raw shell command (non-module keys only)
}
// appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text,
// and writes the file back. Preserves all existing content including comments.
func appendKeyToConfig(path string, entry keyEntry) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
content := string(data)
snippet := renderKeySnippet(entry)
// Case A: keys: {} — empty map literal from the default config generator.
// Replace the {} with a newline so the snippet becomes the first child.
emptyKeys := regexp.MustCompile(`(?m)^keys:\s*\{\}\s*$`)
if emptyKeys.MatchString(content) {
content = emptyKeys.ReplaceAllString(content, "keys:\n"+snippet)
return os.WriteFile(path, []byte(content), 0644)
}
// Case B: keys: exists with children — append snippet to end of file.
// The 2-space indent on the snippet places it inside the keys: block.
hasKeys := regexp.MustCompile(`(?m)^keys:\s*$`)
if hasKeys.MatchString(content) {
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
content += snippet
return os.WriteFile(path, []byte(content), 0644)
}
// Case C: no keys: block at all — append both the block header and snippet.
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
content += "\nkeys:\n" + snippet
return os.WriteFile(path, []byte(content), 0644)
}
// renderKeySnippet formats a keyEntry as an indented YAML block (2-space indent
// for the key index, 4-space for fields, 6-space for params).
//
// Module key output:
//
// 3:
// icon: status.png
// module: slack
// function: set_status
// params:
// emoji: ":brb:"
// text: "BRB"
//
// Command key output:
//
// 3:
// icon: firefox.png
// command: "open -a Firefox"
func renderKeySnippet(e keyEntry) string {
var b strings.Builder
fmt.Fprintf(&b, " %d:\n", e.Index)
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
if e.Module != "" {
fmt.Fprintf(&b, " module: %s\n", e.Module)
fmt.Fprintf(&b, " function: %s\n", e.Function)
if len(e.Params) > 0 {
b.WriteString(" params:\n")
// Sort param keys for deterministic, diffable output.
keys := make([]string, 0, len(e.Params))
for k := range e.Params {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.Params[k])
}
}
} else {
fmt.Fprintf(&b, " command: \"%s\"\n", e.Command)
}
return b.String()
}

View File

@@ -595,12 +595,7 @@ func loadPrivilegedCommands(path string) (map[string]string, error) {
}
func defaultConfigPath() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, _ := os.UserHomeDir()
base = filepath.Join(home, ".config")
}
return filepath.Join(base, "streamdeck-go", "config.yaml")
return config.DefaultConfigPath()
}
func ensureConfigDir(cfgPath string) error {