diff --git a/README.md b/README.md index 6230347..8ceafdf 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,6 @@ # HarborForge.Cli -`HarborForge.Cli` is the home of the new Go-based `hf` binary for HarborForge. - -## Current status - -This repository now contains the initial Go scaffold required by the cross-project plan: - -- Go module initialization -- binary entrypoint at `cmd/hf/main.go` -- placeholder internal package layout for future implementation -- basic build instructions +`HarborForge.Cli` is the Go-based `hf` binary for HarborForge. ## Build @@ -17,24 +8,68 @@ This repository now contains the initial Go scaffold required by the cross-proje go build -o ./bin/hf ./cmd/hf ``` +To set the version at build time: + +```bash +go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=1.0.0" -o ./bin/hf ./cmd/hf +``` + ## Run ```bash -go run ./cmd/hf --help -go run ./cmd/hf version +# Show help +./bin/hf --help + +# Show only permitted commands +./bin/hf --help-brief + +# Show version +./bin/hf version + +# Check API health +./bin/hf health + +# Configure API URL +./bin/hf config --url http://your-harborforge:8000 + +# View current config +./bin/hf config + +# JSON output +./bin/hf version --json ``` -## Planned package layout +## Package Layout ```text -cmd/hf/ +cmd/hf/ CLI entrypoint internal/ - client/ - commands/ - config/ - help/ - mode/ - passmgr/ + client/ HTTP client wrapper for HarborForge API + commands/ Command implementations (version, health, config, auth helpers) + config/ Config file resolution and management (.hf-config.json) + help/ Help and help-brief renderer + mode/ Runtime mode detection (padded-cell vs manual) + output/ Output formatting (human-readable, JSON, tables) + passmgr/ pass_mgr integration for secret resolution ``` -The scaffold is intentionally minimal so follow-up work can implement config loading, mode detection, help rendering, auth, and API command groups incrementally. +## Runtime Modes + +- **Padded-cell mode**: When `pass_mgr` is available, auth tokens are resolved automatically. Manual `--token` flags are rejected. +- **Manual mode**: When `pass_mgr` is not available, `--token` must be provided explicitly to authenticated commands. + +## Current Status + +Implemented: +- Go module and binary entrypoint +- Config file resolution relative to binary directory +- Runtime mode detection (`pass_mgr` present/absent) +- Help and help-brief rendering system +- HTTP client wrapper +- Output formatting (human-readable + `--json`) +- `hf version`, `hf health`, `hf config` +- Auth token resolution (padded-cell + manual) + +Planned: +- User, role, project, task, milestone, meeting, support, propose, monitor commands +- Permission-aware help rendering diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 7176e8d..92ce0f4 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -3,22 +3,107 @@ package main import ( "fmt" "os" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/help" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" ) func main() { - if len(os.Args) > 1 { - switch os.Args[1] { - case "--help", "-h": - fmt.Println("hf - HarborForge CLI") - fmt.Println() - fmt.Println("This is the initial Go scaffold for the HarborForge CLI.") - fmt.Println("More command groups will be added in follow-up tasks.") - return - case "version": - fmt.Println("hf dev") - return - } + args := os.Args[1:] + + // Parse global flags first + args = parseGlobalFlags(args) + + if len(args) == 0 { + fmt.Print(help.RenderTopHelp(commands.Version, topGroups())) + return } - fmt.Println("hf - HarborForge CLI scaffold") + switch args[0] { + case "--help", "-h": + fmt.Print(help.RenderTopHelp(commands.Version, topGroups())) + case "--help-brief": + fmt.Print(help.RenderTopHelpBrief(commands.Version, topGroups())) + case "version": + commands.RunVersion() + case "health": + commands.RunHealth() + case "config": + runConfig(args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) + fmt.Fprintf(os.Stderr, "Run 'hf --help' for usage.\n") + os.Exit(1) + } +} + +// parseGlobalFlags extracts --json from anywhere in the args and returns remaining args. +func parseGlobalFlags(args []string) []string { + var remaining []string + for _, a := range args { + switch a { + case "--json": + output.JSONMode = true + default: + remaining = append(remaining, a) + } + } + return remaining +} + +func runConfig(args []string) { + if len(args) == 0 { + commands.RunConfigShow() + return + } + for i := 0; i < len(args); i++ { + switch args[i] { + case "--url": + if i+1 >= len(args) { + output.Error("usage: hf config --url ") + } + commands.RunConfigURL(args[i+1]) + return + case "--acc-mgr-token": + if i+1 >= len(args) { + output.Error("usage: hf config --acc-mgr-token ") + } + commands.RunConfigAccMgrToken(args[i+1]) + return + case "--help", "-h": + fmt.Println("hf config - View and manage CLI configuration") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" hf config Show current config") + fmt.Println(" hf config --url Set HarborForge API URL") + if !mode.IsPaddedCell() { + fmt.Println(" hf config --acc-mgr-token Set account-manager token") + } + return + default: + output.Errorf("unknown config flag: %s", args[i]) + } + } +} + +// topGroups returns the full command tree for help rendering. +// TODO: permission awareness will be added when auth introspection is available. +func topGroups() []help.Group { + return []help.Group{ + {Name: "version", Description: "Show CLI version", Permitted: true}, + {Name: "health", Description: "Check API health", Permitted: true}, + {Name: "config", Description: "View and manage CLI configuration", Permitted: true}, + {Name: "user", Description: "Manage users", Permitted: true}, + {Name: "role", Description: "Manage roles and permissions", Permitted: true}, + {Name: "permission", Description: "List permissions", Permitted: true}, + {Name: "project", Description: "Manage projects", Permitted: true}, + {Name: "milestone", Description: "Manage milestones", Permitted: true}, + {Name: "task", Description: "Manage tasks", Permitted: true}, + {Name: "meeting", Description: "Manage meetings", Permitted: true}, + {Name: "support", Description: "Manage support tickets", Permitted: true}, + {Name: "propose", Description: "Manage proposals", Permitted: true}, + {Name: "monitor", Description: "Monitor servers and API keys", Permitted: true}, + } } diff --git a/internal/client/.gitkeep b/internal/client/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..eccd18e --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,105 @@ +// Package client provides the HTTP client wrapper for HarborForge API calls. +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client is a simple HarborForge API client. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// New creates a Client with the given base URL and optional auth token. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// RequestError represents a non-2xx HTTP response. +type RequestError struct { + StatusCode int + Body string +} + +func (e *RequestError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body) +} + +// Do executes an HTTP request and returns the response body bytes. +func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) { + url := c.BaseURL + path + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("cannot create request: %w", err) + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cannot read response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, &RequestError{StatusCode: resp.StatusCode, Body: string(data)} + } + return data, nil +} + +// Get performs a GET request. +func (c *Client) Get(path string) ([]byte, error) { + return c.Do("GET", path, nil) +} + +// Post performs a POST request with a JSON body. +func (c *Client) Post(path string, body io.Reader) ([]byte, error) { + return c.Do("POST", path, body) +} + +// Put performs a PUT request with a JSON body. +func (c *Client) Put(path string, body io.Reader) ([]byte, error) { + return c.Do("PUT", path, body) +} + +// Patch performs a PATCH request with a JSON body. +func (c *Client) Patch(path string, body io.Reader) ([]byte, error) { + return c.Do("PATCH", path, body) +} + +// Delete performs a DELETE request. +func (c *Client) Delete(path string) ([]byte, error) { + return c.Do("DELETE", path, nil) +} + +// Health checks the API health endpoint and returns the response. +func (c *Client) Health() (map[string]interface{}, error) { + data, err := c.Get("/api/health/") + if err != nil { + return nil, err + } + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("cannot parse health response: %w", err) + } + return result, nil +} diff --git a/internal/client/doc.go b/internal/client/doc.go deleted file mode 100644 index 054e459..0000000 --- a/internal/client/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package client - -// Package client will host HarborForge HTTP client helpers. diff --git a/internal/commands/.gitkeep b/internal/commands/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/commands/auth.go b/internal/commands/auth.go new file mode 100644 index 0000000..1c42f2a --- /dev/null +++ b/internal/commands/auth.go @@ -0,0 +1,36 @@ +package commands + +import ( + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" +) + +// ResolveToken resolves the auth token based on runtime mode. +// In padded-cell mode, tokenFlag must be empty (enforced). +// In manual mode, tokenFlag is required. +func ResolveToken(tokenFlag string) string { + if mode.IsPaddedCell() { + if tokenFlag != "" { + output.Error("padded-cell installed, --token flag disabled, use command directly") + } + tok, err := passmgr.GetToken() + if err != nil { + output.Errorf("cannot resolve token: %v", err) + } + return tok + } + // manual mode + if tokenFlag == "" { + output.Error("--token required or execute this with pcexec") + } + return tokenFlag +} + +// RejectTokenInPaddedCell checks if --token was passed in padded-cell mode +// and terminates with the standard error message. +func RejectTokenInPaddedCell(tokenFlag string) { + if mode.IsPaddedCell() && tokenFlag != "" { + output.Error("padded-cell installed, --token flag disabled, use command directly") + } +} diff --git a/internal/commands/config.go b/internal/commands/config.go new file mode 100644 index 0000000..54a895a --- /dev/null +++ b/internal/commands/config.go @@ -0,0 +1,53 @@ +package commands + +import ( + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" +) + +// RunConfigURL sets the base URL in the config file. +func RunConfigURL(url string) { + if url == "" { + output.Error("usage: hf config --url ") + } + if err := config.UpdateURL(url); err != nil { + output.Errorf("failed to update config: %v", err) + } + fmt.Printf("base-url set to %s\n", url) +} + +// RunConfigAccMgrToken stores the account-manager token via pass_mgr. +func RunConfigAccMgrToken(token string) { + if token == "" { + output.Error("usage: hf config --acc-mgr-token ") + } + if !mode.IsPaddedCell() { + output.Error("--acc-mgr-token can only be set with padded-cell plugin") + } + if err := passmgr.SetAccountManagerToken(token); err != nil { + output.Errorf("failed to store acc-mgr-token: %v", err) + } + fmt.Println("account-manager token stored successfully") +} + +// RunConfigShow displays the current config. +func RunConfigShow() { + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + if output.JSONMode { + output.PrintJSON(cfg) + } else { + p, _ := config.ConfigPath() + output.PrintKeyValue( + "base-url", cfg.BaseURL, + "config-file", p, + "mode", mode.Detect().String(), + ) + } +} diff --git a/internal/commands/doc.go b/internal/commands/doc.go deleted file mode 100644 index 745b171..0000000 --- a/internal/commands/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package commands - -// Package commands will define the hf command tree. diff --git a/internal/commands/health.go b/internal/commands/health.go new file mode 100644 index 0000000..7140237 --- /dev/null +++ b/internal/commands/health.go @@ -0,0 +1,32 @@ +package commands + +import ( + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// RunHealth checks the HarborForge API health endpoint. +func RunHealth() { + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, "") + result, err := c.Health() + if err != nil { + output.Errorf("health check failed: %v", err) + } + if output.JSONMode { + output.PrintJSON(result) + } else { + status, _ := result["status"].(string) + if status == "" { + status = "unknown" + } + fmt.Printf("HarborForge API: %s\n", status) + fmt.Printf("URL: %s\n", cfg.BaseURL) + } +} diff --git a/internal/commands/version.go b/internal/commands/version.go new file mode 100644 index 0000000..90865f0 --- /dev/null +++ b/internal/commands/version.go @@ -0,0 +1,19 @@ +package commands + +import ( + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// Version is the CLI version string, set at build time via ldflags. +var Version = "dev" + +// RunVersion prints the CLI version. +func RunVersion() { + if output.JSONMode { + output.PrintJSON(map[string]string{"version": Version}) + } else { + fmt.Printf("hf %s\n", Version) + } +} diff --git a/internal/config/.gitkeep b/internal/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b4cb5f6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,92 @@ +// Package config resolves and manages .hf-config.json relative to the binary directory. +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const configFileName = ".hf-config.json" + +// Config holds the CLI configuration. +type Config struct { + BaseURL string `json:"base-url"` +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() Config { + return Config{ + BaseURL: "http://127.0.0.1:8000", + } +} + +// BinaryDir returns the directory containing the running binary. +func BinaryDir() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("cannot resolve binary path: %w", err) + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return "", fmt.Errorf("cannot resolve binary symlinks: %w", err) + } + return filepath.Dir(exe), nil +} + +// ConfigPath returns the full path to the config file. +func ConfigPath() (string, error) { + dir, err := BinaryDir() + if err != nil { + return "", err + } + return filepath.Join(dir, configFileName), nil +} + +// Load reads the config file. If the file does not exist, returns DefaultConfig. +func Load() (Config, error) { + p, err := ConfigPath() + if err != nil { + return DefaultConfig(), err + } + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return DefaultConfig(), nil + } + return DefaultConfig(), fmt.Errorf("cannot read config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return DefaultConfig(), fmt.Errorf("invalid config JSON: %w", err) + } + if cfg.BaseURL == "" { + cfg.BaseURL = DefaultConfig().BaseURL + } + return cfg, nil +} + +// Save writes the config to the config file path. +func Save(cfg Config) error { + p, err := ConfigPath() + if err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("cannot marshal config: %w", err) + } + data = append(data, '\n') + if err := os.WriteFile(p, data, 0644); err != nil { + return fmt.Errorf("cannot write config: %w", err) + } + return nil +} + +// UpdateURL loads the existing config, updates the base-url, and saves. +func UpdateURL(url string) error { + cfg, _ := Load() + cfg.BaseURL = url + return Save(cfg) +} diff --git a/internal/config/doc.go b/internal/config/doc.go deleted file mode 100644 index 3706e51..0000000 --- a/internal/config/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package config - -// Package config will resolve and manage .hf-config.json. diff --git a/internal/help/.gitkeep b/internal/help/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/help/doc.go b/internal/help/doc.go deleted file mode 100644 index c9259ff..0000000 --- a/internal/help/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package help - -// Package help will render help and help-brief output. diff --git a/internal/help/help.go b/internal/help/help.go new file mode 100644 index 0000000..04788ee --- /dev/null +++ b/internal/help/help.go @@ -0,0 +1,113 @@ +// Package help renders help and help-brief output for the hf CLI. +package help + +import ( + "fmt" + "strings" +) + +// Command describes a CLI command or group. +type Command struct { + Name string + Description string + Permitted bool // whether the current user can execute this + SubCommands []Command +} + +// Group describes a top-level command group. +type Group struct { + Name string + Description string + Permitted bool + SubCommands []Command +} + +// RenderTopHelp renders `hf --help` output showing all groups. +func RenderTopHelp(version string, groups []Group) string { + var b strings.Builder + b.WriteString("hf - HarborForge CLI") + if version != "" { + b.WriteString(" (" + version + ")") + } + b.WriteString("\n\nUsage: hf [flags]\n\n") + b.WriteString("Commands:\n") + maxLen := 0 + for _, g := range groups { + if len(g.Name) > maxLen { + maxLen = len(g.Name) + } + } + for _, g := range groups { + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, g.Name, g.Description)) + } + b.WriteString("\nGlobal flags:\n") + b.WriteString(" --help Show help\n") + b.WriteString(" --help-brief Show only permitted commands\n") + b.WriteString(" --json Output in JSON format\n") + return b.String() +} + +// RenderTopHelpBrief renders `hf --help-brief` showing only permitted groups. +func RenderTopHelpBrief(version string, groups []Group) string { + var b strings.Builder + b.WriteString("hf - HarborForge CLI") + if version != "" { + b.WriteString(" (" + version + ")") + } + b.WriteString("\n\nPermitted commands:\n") + maxLen := 0 + for _, g := range groups { + if g.Permitted && len(g.Name) > maxLen { + maxLen = len(g.Name) + } + } + for _, g := range groups { + if g.Permitted { + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, g.Name, g.Description)) + } + } + return b.String() +} + +// RenderGroupHelp renders `hf --help` showing all subcommands. +func RenderGroupHelp(groupName string, cmds []Command) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("hf %s - subcommands:\n\n", groupName)) + maxLen := 0 + for _, c := range cmds { + if len(c.Name) > maxLen { + maxLen = len(c.Name) + } + } + for _, c := range cmds { + desc := c.Description + if !c.Permitted { + desc = "(not permitted)" + } + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, c.Name, desc)) + } + return b.String() +} + +// RenderGroupHelpBrief renders `hf --help-brief` showing only permitted subcommands. +func RenderGroupHelpBrief(groupName string, cmds []Command) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("hf %s - permitted subcommands:\n\n", groupName)) + maxLen := 0 + for _, c := range cmds { + if c.Permitted && len(c.Name) > maxLen { + maxLen = len(c.Name) + } + } + for _, c := range cmds { + if c.Permitted { + b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, c.Name, c.Description)) + } + } + return b.String() +} + +// RenderNotPermitted renders the short message for an unpermitted leaf command. +func RenderNotPermitted(group, cmd string) string { + return fmt.Sprintf("hf %s %s: not permitted", group, cmd) +} diff --git a/internal/mode/.gitkeep b/internal/mode/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/mode/doc.go b/internal/mode/doc.go deleted file mode 100644 index 5bd3ec0..0000000 --- a/internal/mode/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package mode - -// Package mode will detect padded-cell/manual runtime behavior. diff --git a/internal/mode/mode.go b/internal/mode/mode.go new file mode 100644 index 0000000..3c52c1f --- /dev/null +++ b/internal/mode/mode.go @@ -0,0 +1,53 @@ +// Package mode detects whether the CLI runs in padded-cell mode or manual mode. +package mode + +import ( + "os/exec" + "sync" +) + +// RuntimeMode represents the CLI operating mode. +type RuntimeMode int + +const ( + // ManualMode requires explicit --token / --acc-mgr-token flags. + ManualMode RuntimeMode = iota + // PaddedCellMode resolves secrets via pass_mgr automatically. + PaddedCellMode +) + +var ( + detectedMode RuntimeMode + detectOnce sync.Once +) + +// Detect checks whether pass_mgr is available and returns the runtime mode. +// The result is cached after the first call. +func Detect() RuntimeMode { + detectOnce.Do(func() { + _, err := exec.LookPath("pass_mgr") + if err == nil { + detectedMode = PaddedCellMode + } else { + detectedMode = ManualMode + } + }) + return detectedMode +} + +// IsPaddedCell is a convenience helper. +func IsPaddedCell() bool { + return Detect() == PaddedCellMode +} + +// String returns a human-readable mode name. +func (m RuntimeMode) String() string { + switch m { + case PaddedCellMode: + return "padded-cell" + case ManualMode: + return "manual" + default: + return "unknown" + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..c337f6e --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,93 @@ +// Package output handles human-readable and JSON output formatting. +package output + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// JSONMode is true when --json flag is provided. +var JSONMode bool + +// PrintJSON prints data as indented JSON to stdout. +func PrintJSON(v interface{}) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "error: cannot marshal JSON: %v\n", err) + os.Exit(1) + } + fmt.Println(string(data)) +} + +// PrintKeyValue prints a set of key-value pairs in human-readable format. +func PrintKeyValue(pairs ...string) { + if len(pairs)%2 != 0 { + pairs = append(pairs, "") + } + maxKeyLen := 0 + for i := 0; i < len(pairs); i += 2 { + if len(pairs[i]) > maxKeyLen { + maxKeyLen = len(pairs[i]) + } + } + for i := 0; i < len(pairs); i += 2 { + fmt.Printf("%-*s %s\n", maxKeyLen, pairs[i]+":", pairs[i+1]) + } +} + +// PrintTable prints a simple table with headers and rows. +func PrintTable(headers []string, rows [][]string) { + if len(rows) == 0 { + fmt.Println("(no results)") + return + } + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(widths) && len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + // header + parts := make([]string, len(headers)) + for i, h := range headers { + parts[i] = fmt.Sprintf("%-*s", widths[i], h) + } + fmt.Println(strings.Join(parts, " ")) + // separator + seps := make([]string, len(headers)) + for i := range headers { + seps[i] = strings.Repeat("-", widths[i]) + } + fmt.Println(strings.Join(seps, " ")) + // rows + for _, row := range rows { + parts := make([]string, len(headers)) + for i := range headers { + cell := "" + if i < len(row) { + cell = row[i] + } + parts[i] = fmt.Sprintf("%-*s", widths[i], cell) + } + fmt.Println(strings.Join(parts, " ")) + } +} + +// Error prints an error message to stderr and exits. +func Error(msg string) { + fmt.Fprintln(os.Stderr, msg) + os.Exit(1) +} + +// Errorf prints a formatted error message to stderr and exits. +func Errorf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/internal/passmgr/.gitkeep b/internal/passmgr/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/passmgr/doc.go b/internal/passmgr/doc.go deleted file mode 100644 index 89ef8f8..0000000 --- a/internal/passmgr/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package passmgr - -// Package passmgr will integrate with pass_mgr when available. diff --git a/internal/passmgr/passmgr.go b/internal/passmgr/passmgr.go new file mode 100644 index 0000000..df1c6eb --- /dev/null +++ b/internal/passmgr/passmgr.go @@ -0,0 +1,60 @@ +// Package passmgr wraps calls to the pass_mgr binary for secret resolution. +package passmgr + +import ( + "fmt" + "os/exec" + "strings" +) + +// GetSecret calls: pass_mgr get-secret [--public] --key +func GetSecret(key string, public bool) (string, error) { + args := []string{"get-secret"} + if public { + args = append(args, "--public") + } + args = append(args, "--key", key) + out, err := exec.Command("pass_mgr", args...).Output() + if err != nil { + return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err) + } + return strings.TrimSpace(string(out)), nil +} + +// SetSecret calls: pass_mgr set [--public] --key --secret +func SetSecret(key, secret string, public bool) error { + args := []string{"set"} + if public { + args = append(args, "--public") + } + args = append(args, "--key", key, "--secret", secret) + if err := exec.Command("pass_mgr", args...).Run(); err != nil { + return fmt.Errorf("pass_mgr set --key %s failed: %w", key, err) + } + return nil +} + +// GeneratePassword calls: pass_mgr generate --key --username +func GeneratePassword(key, username string) (string, error) { + args := []string{"generate", "--key", key, "--username", username} + out, err := exec.Command("pass_mgr", args...).Output() + if err != nil { + return "", fmt.Errorf("pass_mgr generate failed: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// GetToken retrieves the normal hf-token via pass_mgr. +func GetToken() (string, error) { + return GetSecret("hf-token", false) +} + +// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr. +func GetAccountManagerToken() (string, error) { + return GetSecret("hf-acc-mgr-token", true) +} + +// SetAccountManagerToken stores the acc-mgr-token as a public secret. +func SetAccountManagerToken(token string) error { + return SetSecret("hf-acc-mgr-token", token, true) +}