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:
zhi
2026-03-21 13:50:29 +00:00
parent cb0b7669b3
commit 7d3cff7d95
24 changed files with 810 additions and 52 deletions

105
internal/client/client.go Normal file
View 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
}

View File

@@ -1,3 +0,0 @@
package client
// Package client will host HarborForge HTTP client helpers.

36
internal/commands/auth.go Normal file
View 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")
}
}

View 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(),
)
}
}

View File

@@ -1,3 +0,0 @@
package commands
// Package commands will define the hf command tree.

View 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)
}
}

View 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
View 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)
}

View File

@@ -1,3 +0,0 @@
package config
// Package config will resolve and manage .hf-config.json.

View File

View File

@@ -1,3 +0,0 @@
package help
// Package help will render help and help-brief output.

113
internal/help/help.go Normal file
View 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)
}

View File

View File

@@ -1,3 +0,0 @@
package mode
// Package mode will detect padded-cell/manual runtime behavior.

53
internal/mode/mode.go Normal file
View 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
View 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)
}

View File

@@ -1,3 +0,0 @@
package passmgr
// Package passmgr will integrate with pass_mgr when available.

View 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)
}