charm bracelet init, brb

This commit is contained in:
2026-03-19 22:14:42 +00:00
parent e88e3a4bb0
commit a997b184f5
5 changed files with 713 additions and 185 deletions

407
app.go
View File

@@ -4,6 +4,7 @@ import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/user"
@@ -49,8 +50,10 @@ type App struct {
users UserManager
runner CommandRunner
lookPath LookPathFunc
reader *bufio.Reader
input io.Reader
output io.Writer
writer *bufio.Writer
theme tuiTheme
}
type PasswdEntry struct {
@@ -95,8 +98,10 @@ func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath
users: users,
runner: runner,
lookPath: lookPath,
reader: bufio.NewReader(os.Stdin),
input: os.Stdin,
output: os.Stdout,
writer: bufio.NewWriter(os.Stdout),
theme: newTUITheme(),
}
}
@@ -119,30 +124,19 @@ func (a *App) Run() error {
}
func (a *App) chooseStartupWorkflow() (string, error) {
a.println("")
a.println("Samba setup assistant")
a.println("=====================")
a.println("[s] set up or edit shares on this computer")
a.println("[c] connect this computer to a share on another server")
a.println("[q] quit")
a.flush()
choice, err := a.prompt("What would you like to do")
if err != nil {
return "", err
}
switch strings.ToLower(choice) {
case "s", "server":
return "server", nil
case "c", "client":
return "client", nil
case "q", "quit":
return "quit", nil
default:
a.println("Unknown choice.")
return a.chooseStartupWorkflow()
}
return a.chooseMenu(
"Choose A Workflow",
"Server shares and client mounts in one place.",
[]string{
"Use the server tools to edit smb.conf and manage share accounts.",
"Use the client tools to add or maintain CIFS mounts in /etc/fstab.",
},
[]menuOption{
{Key: "s", Value: "server", Label: "Set up or edit shares on this computer", Description: "Create, edit, and save Samba share definitions."},
{Key: "c", Value: "client", Label: "Connect this computer to a remote share", Description: "Manage CIFS client mounts and mount points."},
{Key: "q", Value: "quit", Label: "Quit", Description: "Exit without changing anything."},
},
)
}
func (a *App) runServerWorkflow() error {
@@ -161,60 +155,62 @@ func (a *App) runServerWorkflow() error {
loaded:
for {
shareSections := doc.ShareSections()
a.println("")
a.println("Samba share editor")
a.println("==================")
a.println("Current shares:")
intro := []string{"Current shares:"}
if len(shareSections) == 0 {
a.println(" (none)")
intro = append(intro, " none yet")
} else {
for i, section := range shareSections {
for _, section := range shareSections {
cfg := ShareFromSection(section)
a.printf(" %d. %s -> %s\n", i+1, cfg.Name, cfg.Path)
intro = append(intro, fmt.Sprintf(" %s -> %s", cfg.Name, cfg.Path))
}
}
a.println("")
a.println("[a] add share")
a.println("[e] edit share")
a.println("[d] delete share")
a.println("[m] set up client mount")
a.println("[u] manage users")
a.println("[w] write config and exit")
a.println("[q] quit without saving")
a.flush()
choice, err := a.prompt("Select an action")
choice, err := a.chooseMenu(
"Server Share Editor",
fmt.Sprintf("Working against %s", a.configPath),
intro,
[]menuOption{
{Key: "a", Value: "add", Label: "Add share", Description: "Create a new share definition."},
{Key: "e", Value: "edit", Label: "Edit share", Description: "Update an existing share."},
{Key: "d", Value: "delete", Label: "Delete share", Description: "Remove a share definition."},
{Key: "m", Value: "mount", Label: "Set up client mount", Description: "Jump to the remote-mount workflow."},
{Key: "u", Value: "users", Label: "Manage users", Description: "Check accounts, passwords, and cleanup."},
{Key: "w", Value: "write", Label: "Write config and exit", Description: "Save smb.conf and leave the app."},
{Key: "q", Value: "quit", Label: "Quit without saving", Description: "Leave the editor immediately."},
},
)
if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err
}
switch strings.ToLower(choice) {
case "a", "add":
switch choice {
case "add":
if err := a.addShare(doc); err != nil {
return err
}
case "e", "edit":
case "edit":
if err := a.editShare(doc); err != nil {
return err
}
case "d", "delete":
case "delete":
if err := a.deleteShare(doc); err != nil {
return err
}
case "m", "mount":
case "mount":
if err := a.setupClientMount(); err != nil {
return err
}
case "u", "users":
case "users":
if err := a.manageUsers(doc); err != nil {
return err
}
case "w", "write":
case "write":
return a.writeConfig(doc)
case "q", "quit":
case "quit":
return nil
default:
a.println("Unknown choice.")
}
}
}
@@ -226,84 +222,92 @@ func (a *App) setupClientMount() error {
return err
}
a.println("")
a.println("Client mount setup")
a.println("==================")
a.println("This computer can connect to Samba or CIFS shares listed in /etc/fstab.")
a.println("Current client mounts:")
intro := []string{
"Remote shares are persisted in /etc/fstab so they can mount cleanly on boot.",
"Current client mounts:",
}
if len(entries) == 0 {
a.println(" (none)")
intro = append(intro, " none yet")
} else {
for i, entry := range entries {
a.printf(" %d. %s -> %s\n", i+1, entry.DisplayName, entry.MountPoint)
for _, entry := range entries {
intro = append(intro, fmt.Sprintf(" %s -> %s", entry.DisplayName, entry.MountPoint))
}
}
a.println("")
a.println("[a] add client mount")
a.println("[e] edit client mount")
a.println("[d] delete client mount")
a.println("[b] back")
a.flush()
choice, err := a.prompt("Select an action")
choice, err := a.chooseMenu(
"Client Mount Setup",
fmt.Sprintf("Editing %s", a.fstabPath),
intro,
[]menuOption{
{Key: "a", Value: "add", Label: "Add client mount", Description: "Create a new CIFS mount entry."},
{Key: "e", Value: "edit", Label: "Edit client mount", Description: "Change an existing mount definition."},
{Key: "d", Value: "delete", Label: "Delete client mount", Description: "Remove a saved mount entry."},
{Key: "b", Value: "back", Label: "Back", Description: "Return to the previous menu."},
},
)
if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err
}
switch strings.ToLower(choice) {
case "a", "add":
switch choice {
case "add":
if err := a.addClientMount(); err != nil {
return err
}
case "e", "edit":
case "edit":
if err := a.editClientMount(entries); err != nil {
return err
}
case "d", "delete":
case "delete":
if err := a.deleteClientMount(entries); err != nil {
return err
}
case "b", "back":
case "back":
return nil
default:
a.println("Unknown choice.")
}
}
}
func (a *App) manageUsers(doc *Document) error {
for {
a.println("")
a.println("User management")
a.println("===============")
a.println("[c] check share accounts")
a.println("[p] change a Samba password")
a.println("[x] delete an unused account")
a.println("[b] back")
a.flush()
choice, err := a.prompt("Select an action")
choice, err := a.chooseMenu(
"User Management",
"Keep local Linux accounts and Samba credentials aligned with your shares.",
[]string{
fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))),
},
[]menuOption{
{Key: "c", Value: "check", Label: "Check share accounts", Description: "Create missing local users referenced by shares."},
{Key: "p", Value: "password", Label: "Change a Samba password", Description: "Set or update a Samba credential."},
{Key: "x", Value: "delete", Label: "Delete an unused account", Description: "Remove unused share-style accounts."},
{Key: "b", Value: "back", Label: "Back", Description: "Return to the share editor."},
},
)
if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err
}
switch strings.ToLower(choice) {
case "c", "check":
switch choice {
case "check":
if err := a.checkShareAccounts(doc); err != nil {
return err
}
case "p", "password":
case "password":
if err := a.changeSambaPassword(doc); err != nil {
return err
}
case "x", "delete":
case "delete":
if err := a.deleteUnusedAccount(doc); err != nil {
return err
}
case "b", "back":
case "back":
return nil
default:
a.println("Unknown choice.")
}
}
}
@@ -311,12 +315,11 @@ func (a *App) manageUsers(doc *Document) error {
func (a *App) checkShareAccounts(doc *Document) error {
references := shareUserReferences(doc)
if len(references) == 0 {
a.println("No accounts are listed in any share.")
a.showMessage("info", "No accounts are listed in any share.")
return nil
}
a.println("")
a.println("Accounts used by the current shares:")
a.showPanel("Accounts Used By Current Shares", "", nil)
for _, ref := range references {
status := "missing"
if a.users.UserExists(ref.User) {
@@ -333,13 +336,11 @@ func (a *App) checkShareAccounts(doc *Document) error {
}
if len(missing) == 0 {
a.println("")
a.println("All listed share accounts already exist.")
a.showMessage("success", "All listed share accounts already exist.")
return nil
}
a.println("")
a.println("Some accounts are missing and those users may not be able to sign in.")
a.showMessage("warn", "Some accounts are missing and those users may not be able to sign in.")
if err := a.ensureUsers(missing); err != nil {
return err
}
@@ -354,50 +355,55 @@ func (a *App) deleteUnusedAccount(doc *Document) error {
candidates := unusedAccountCandidates(entries, shareUsers(doc))
if len(candidates) == 0 {
a.println("I couldn't find any obvious unused share accounts to delete.")
a.println("For safety, system accounts are excluded from this list.")
a.showPanel("Unused Account Cleanup", "No obvious removable share accounts were found.", []string{
"For safety, system accounts are excluded from this list.",
})
return nil
}
a.println("")
a.println("Accounts that look unused by the current shares:")
options := make([]menuOption, 0, len(candidates)+1)
for i, entry := range candidates {
a.printf(" %d. %s (shell: %s)\n", i+1, entry.Name, entry.Shell)
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: entry.Name,
Label: entry.Name,
Description: "shell: " + strings.TrimSpace(entry.Shell),
})
}
a.println("Only accounts that are not listed in any share are shown here.")
a.flush()
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Leave accounts unchanged."})
raw, err := a.prompt("Account number to delete")
choice, err := a.chooseMenu(
"Delete An Unused Account",
"Only accounts that are not listed in any share are shown here.",
nil,
options,
)
if err != nil {
return err
}
index, err := strconv.Atoi(raw)
if err != nil || index < 1 || index > len(candidates) {
a.println("Invalid selection.")
if choice == "" {
return nil
}
entry := candidates[index-1]
a.println("")
a.printf("This will remove the local account %q.\n", entry.Name)
a.println("This is only safe if nobody needs this account for Samba or anything else.")
a.flush()
a.showPanel("Delete Account", "", []string{
fmt.Sprintf("This will remove the local account %q.", choice),
"This is only safe if nobody needs this account for Samba or anything else.",
})
confirm, err := a.confirm("Delete this account now", false)
if err != nil {
return err
}
if !confirm {
a.println("Delete cancelled.")
a.showMessage("info", "Delete cancelled.")
return nil
}
if err := a.deleteUser(entry.Name); err != nil {
if err := a.deleteUser(choice); err != nil {
return err
}
a.printf("Deleted account %s\n", entry.Name)
a.showMessage("success", "Deleted account "+choice)
return nil
}
@@ -412,38 +418,50 @@ func (a *App) changeSambaPassword(doc *Document) error {
references := shareUserReferences(doc)
if len(references) == 0 {
a.println("No accounts are listed in any share.")
a.showMessage("info", "No accounts are listed in any share.")
return nil
}
a.println("")
a.println("Accounts used by the current shares:")
options := make([]menuOption, 0, len(references)+1)
for i, ref := range references {
status := "missing"
if a.users.UserExists(ref.User) {
status = "present"
}
a.printf(" %d. %s [%s] used by: %s\n", i+1, ref.User, status, strings.Join(ref.Shares, ", "))
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: ref.User,
Label: ref.User + " [" + status + "]",
Description: "used by: " + strings.Join(ref.Shares, ", "),
})
}
a.flush()
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return without changing a password."})
raw, err := a.prompt("Account number to update")
choice, err := a.chooseMenu(
"Choose An Account",
"Select the local user whose Samba password you want to change.",
nil,
options,
)
if err != nil {
return err
}
index, err := strconv.Atoi(raw)
if err != nil || index < 1 || index > len(references) {
a.println("Invalid selection.")
if choice == "" {
return nil
}
ref := references[index-1]
var ref ShareUserReference
for _, candidate := range references {
if candidate.User == choice {
ref = candidate
break
}
}
if !a.users.UserExists(ref.User) {
a.println("")
a.printf("The local account %q does not exist yet.\n", ref.User)
a.println("I can create it first so a Samba password can be set.")
a.flush()
a.showPanel("Local User Missing", "", []string{
fmt.Sprintf("The local account %q does not exist yet.", ref.User),
"I can create it first so a Samba password can be set.",
})
create, promptErr := a.confirm("Create this account now", true)
if promptErr != nil {
@@ -458,10 +476,10 @@ func (a *App) changeSambaPassword(doc *Document) error {
}
}
a.println("")
a.printf("Ill open the password setup for %q now.\n", ref.User)
a.println("Youll be asked to type the new Samba password.")
a.flush()
a.showPanel("Password Update", "", []string{
fmt.Sprintf("I will open the password setup for %q now.", ref.User),
"You will be asked to type the new Samba password.",
})
return a.setSambaPassword(ref.User)
}
@@ -836,22 +854,30 @@ func (a *App) ensureShareDirectories(doc *Document) error {
func (a *App) selectShare(doc *Document) (*Section, error) {
shares := doc.ShareSections()
if len(shares) == 0 {
a.println("There are no shares to select.")
a.showMessage("info", "There are no shares to select.")
return nil, nil
}
raw, err := a.prompt("Share number")
options := make([]menuOption, 0, len(shares)+1)
for i, share := range shares {
cfg := ShareFromSection(share)
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: share.Name,
Label: cfg.Name,
Description: cfg.Path,
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."})
raw, err := a.chooseMenu("Choose A Share", "Select the share you want to work with.", nil, options)
if err != nil {
return nil, err
}
index, err := strconv.Atoi(raw)
if err != nil || index < 1 || index > len(shares) {
a.println("Invalid selection.")
if raw == "" {
return nil, nil
}
return shares[index-1], nil
return doc.Section(raw), nil
}
func (a *App) collectShareConfig(existing ShareConfig) (ShareConfig, error) {
@@ -1060,54 +1086,35 @@ func (a *App) deleteUser(name string) error {
}
func (a *App) prompt(label string) (string, error) {
a.printf("%s: ", label)
a.flush()
text, err := a.reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(text), nil
return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, "")
}
func (a *App) promptDefault(label, defaultValue string) (string, error) {
if defaultValue == "" {
return a.prompt(label)
}
a.printf("%s [%s]: ", label, defaultValue)
a.flush()
text, err := a.reader.ReadString('\n')
if err != nil {
return "", err
}
text = strings.TrimSpace(text)
if text == "" {
return defaultValue, nil
}
return text, nil
return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, defaultValue)
}
func (a *App) confirm(label string, defaultYes bool) (bool, error) {
suffix := "y/N"
if defaultYes {
suffix = "Y/n"
options := []menuOption{
{Key: "y", Value: "yes", Label: "Yes", Description: "Continue with this action."},
{Key: "n", Value: "no", Label: "No", Description: "Leave things as they are."},
}
if !defaultYes {
options[0], options[1] = options[1], options[0]
}
answer, err := a.prompt(fmt.Sprintf("%s [%s]", label, suffix))
answer, err := a.chooseMenu("Confirm Action", label, nil, options)
if err != nil {
return false, err
}
if answer == "" {
return defaultYes, nil
}
switch strings.ToLower(answer) {
case "y", "yes":
switch answer {
case "yes":
return true, nil
case "n", "no":
case "no":
return false, nil
default:
return false, errors.New("expected yes or no")
return defaultYes, nil
}
}
@@ -1123,6 +1130,24 @@ func (a *App) flush() {
_ = a.writer.Flush()
}
func (a *App) chooseMenu(title, subtitle string, intro []string, options []menuOption) (string, error) {
a.flush()
choice, err := runMenuPrompt(a.input, a.output, a.theme, title, subtitle, intro, options)
a.flush()
return choice, err
}
func (a *App) showPanel(title, subtitle string, lines []string) {
body := a.theme.renderSection(84, "", lines)
fmt.Fprintln(a.writer, a.theme.renderFrame(84, title, subtitle, body, ""))
a.flush()
}
func (a *App) showMessage(kind, message string) {
fmt.Fprintln(a.writer, a.theme.renderFrame(84, "Status", "", a.theme.renderMessage(84, kind, message), ""))
a.flush()
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
@@ -1597,22 +1622,34 @@ func (a *App) deleteClientMount(entries []FstabMountEntry) error {
func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, error) {
if len(entries) == 0 {
a.println("There are no client mounts to select.")
a.showMessage("info", "There are no client mounts to select.")
return nil, nil
}
selection, err := a.prompt("Select mount number")
options := make([]menuOption, 0, len(entries)+1)
for i, entry := range entries {
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: strconv.Itoa(i),
Label: entry.DisplayName,
Description: entry.MountPoint,
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."})
selection, err := a.chooseMenu("Choose A Client Mount", "Select the saved mount entry you want to change.", nil, options)
if err != nil {
return nil, err
}
index, err := strconv.Atoi(selection)
if err != nil || index < 1 || index > len(entries) {
a.println("Invalid selection.")
if selection == "" {
return nil, nil
}
entry := entries[index-1]
index, err := strconv.Atoi(selection)
if err != nil || index < 0 || index >= len(entries) {
return nil, nil
}
entry := entries[index]
return &entry, nil
}