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
|
||||||
|
|
||||||
`HarborForge.Cli` is the home of the new Go-based `hf` binary for HarborForge.
|
`HarborForge.Cli` is the 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
|
|
||||||
|
|
||||||
## Build
|
## 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
|
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
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/hf --help
|
# Show help
|
||||||
go run ./cmd/hf version
|
./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
|
```text
|
||||||
cmd/hf/
|
cmd/hf/ CLI entrypoint
|
||||||
internal/
|
internal/
|
||||||
client/
|
client/ HTTP client wrapper for HarborForge API
|
||||||
commands/
|
commands/ Command implementations (version, health, config, auth helpers)
|
||||||
config/
|
config/ Config file resolution and management (.hf-config.json)
|
||||||
help/
|
help/ Help and help-brief renderer
|
||||||
mode/
|
mode/ Runtime mode detection (padded-cell vs manual)
|
||||||
passmgr/
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"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() {
|
func main() {
|
||||||
if len(os.Args) > 1 {
|
args := os.Args[1:]
|
||||||
switch os.Args[1] {
|
|
||||||
case "--help", "-h":
|
// Parse global flags first
|
||||||
fmt.Println("hf - HarborForge CLI")
|
args = parseGlobalFlags(args)
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("This is the initial Go scaffold for the HarborForge CLI.")
|
if len(args) == 0 {
|
||||||
fmt.Println("More command groups will be added in follow-up tasks.")
|
fmt.Print(help.RenderTopHelp(commands.Version, topGroups()))
|
||||||
return
|
return
|
||||||
case "version":
|
|
||||||
fmt.Println("hf dev")
|
|
||||||
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