feat: implement core CLI packages and Phase 3 commands
- config: resolve binary dir, load/save .hf-config.json - mode: detect padded-cell vs manual mode via pass_mgr - client: HTTP client wrapper with auth header support - passmgr: pass_mgr integration (get-secret, set, generate) - output: human-readable + JSON output formatting with tables - help: help and help-brief renderer for groups/commands - commands: version, health, config (--url, --acc-mgr-token, show) - auth: token resolution helper (padded-cell auto / manual explicit) - main: command dispatcher with --json global flag support - README: updated with current package layout and status
This commit is contained in:
77
README.md
77
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
|
||||
|
||||
111
cmd/hf/main.go
111
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 <hf-url>")
|
||||
}
|
||||
commands.RunConfigURL(args[i+1])
|
||||
return
|
||||
case "--acc-mgr-token":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("usage: hf config --acc-mgr-token <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 <hf-url> Set HarborForge API URL")
|
||||
if !mode.IsPaddedCell() {
|
||||
fmt.Println(" hf config --acc-mgr-token <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},
|
||||
}
|
||||
}
|
||||
|
||||
105
internal/client/client.go
Normal file
105
internal/client/client.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package client
|
||||
|
||||
// Package client will host HarborForge HTTP client helpers.
|
||||
36
internal/commands/auth.go
Normal file
36
internal/commands/auth.go
Normal file
@@ -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 <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")
|
||||
}
|
||||
}
|
||||
53
internal/commands/config.go
Normal file
53
internal/commands/config.go
Normal file
@@ -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 <hf-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 <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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package commands
|
||||
|
||||
// Package commands will define the hf command tree.
|
||||
32
internal/commands/health.go
Normal file
32
internal/commands/health.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
19
internal/commands/version.go
Normal file
19
internal/commands/version.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
92
internal/config/config.go
Normal file
92
internal/config/config.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package config
|
||||
|
||||
// Package config will resolve and manage .hf-config.json.
|
||||
@@ -1,3 +0,0 @@
|
||||
package help
|
||||
|
||||
// Package help will render help and help-brief output.
|
||||
113
internal/help/help.go
Normal file
113
internal/help/help.go
Normal file
@@ -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 <command> [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 <group> --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 <group> --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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package mode
|
||||
|
||||
// Package mode will detect padded-cell/manual runtime behavior.
|
||||
53
internal/mode/mode.go
Normal file
53
internal/mode/mode.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
93
internal/output/output.go
Normal file
93
internal/output/output.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package passmgr
|
||||
|
||||
// Package passmgr will integrate with pass_mgr when available.
|
||||
60
internal/passmgr/passmgr.go
Normal file
60
internal/passmgr/passmgr.go
Normal file
@@ -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 <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 <key> --secret <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 <key> --username <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)
|
||||
}
|
||||
Reference in New Issue
Block a user