Compare commits

...

28 Commits

Author SHA1 Message Date
f1ebc52cca fix: allow reset-apikey command without user.manage permission
The reset-apikey command has its own auth mechanism via --acc-mgr-token,
so it should not be gated by permission introspection. This matches the
behavior of "user create" which is also Permitted: true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:32:30 +01:00
h z
de0ea39b2a Merge pull request 'dev-2026-03-29' (#3) from dev-2026-03-29 into main
Reviewed-on: #3
2026-04-16 21:21:32 +00:00
6dae490257 refactor: rename pass_mgr to secret-mgr
The secret manager binary was renamed from pass_mgr to secret-mgr.
Update all references in CLI code, mode detection, and help text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
53b5b88fc2 feat: user reset-apikey supports acc-mgr-token auth
Allows reset-apikey to use --acc-mgr-token or auto-resolve from
secret-mgr in padded-cell mode, enabling API key provisioning
without an existing user Bearer token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
6252039fc5 feat: add user reset-apikey command
Adds `hf user reset-apikey <username>` to regenerate a user API key.
Requires user.manage permission. Returns the new key (shown once only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
h z
cd22642472 Merge pull request 'HarborForge.Cli: dev-2026-03-29 -> main' (#2) from dev-2026-03-29 into main
Reviewed-on: #2
2026-04-05 22:08:34 +00:00
5ac90408f3 feat: support discord id account updates 2026-04-04 20:16:59 +00:00
ad0e123666 fix: send account-manager token as x-api-key 2026-04-03 19:12:34 +00:00
e2177521e0 feat: switch cli indexing to code-first identifiers 2026-04-03 16:25:11 +00:00
84150df4d5 fix: align cli routes with backend routers 2026-04-03 13:58:15 +00:00
b287b1ff17 fix: align health endpoint with backend 2026-04-03 13:45:36 +00:00
zhi
0280f2c327 TEST-CLI-CAL-001 TEST-CLI-PR-001 add CLI integration tests 2026-04-01 12:03:24 +00:00
zhi
0fe62ed430 CLI-CAL-001/002/003/004/005/006/007/008/009/010: Add calendar command group and CRUD plan commands
- Add hf calendar command group to command surface and router
- Implement schedule/show/edit/cancel/date-list commands
- Implement plan-schedule/plan-list/plan-edit/plan-cancel commands
- Add leaf help for all calendar commands
- Align CLI with backend calendar routes and response envelopes
- Support virtual slot ids for edit/cancel
- Validate with go build and go test ./...
2026-04-01 07:02:36 +00:00
zhi
97af3d3177 CLI-PR-001/002/003/004: Rename propose->proposal, add essential commands, improve accept, restrict story
- Rename 'propose' group to 'proposal' in surface, leaf help, and routing
- Keep 'hf propose' as backward-compatible alias via groupAliases
- Add essential subcommand group: list, create, update, delete
- Accept command now shows generated story tasks in output
- Accept command supports --json output
- Task create blocks story/* types with helpful error message
- All help text updated to use 'proposal' terminology
2026-04-01 06:56:10 +00:00
fbfa866c9d Merge pull request 'Merge dev-2026-03-21 into main' (#1) from dev-2026-03-21 into main
Reviewed-on: #1
2026-03-22 14:16:21 +00:00
zhi
b3063733a9 Add release build targets for hf 2026-03-21 20:32:28 +00:00
zhi
49b54beace Add CLI install documentation 2026-03-21 18:44:26 +00:00
zhi
cc649a7fe2 Document CLI output and exit conventions 2026-03-21 17:39:26 +00:00
zhi
9b3edc0ede Add comment and worklog CLI commands 2026-03-21 17:11:20 +00:00
zhi
1e8437d0b1 Improve nested CLI help coverage 2026-03-21 16:37:47 +00:00
zhi
ebad3cd0d3 Refresh CLI status documentation 2026-03-21 16:06:41 +00:00
zhi
eaf4f215b5 Add detailed leaf help output 2026-03-21 15:37:13 +00:00
zhi
34f52cb9e3 feat: implement meeting, support, propose, and monitor command groups
- Added meeting.go: list, get, create, update, attend, delete
- Added support.go: list, get, create, update, take, transition, delete
- Added propose.go: list, get, create, update, accept, reject, reopen
- Added monitor.go: overview, server list/get/create/delete, api-key generate/revoke
- Updated main.go with dispatch handlers for all four new groups
- All commands follow existing patterns (token resolution, --json, table output)

Covers TODO items 1.12, 1.13, 1.14, 1.15 from hf-cross-project-todo.md
2026-03-21 15:24:43 +00:00
zhi
a01e602118 Align role commands with current backend API 2026-03-21 15:06:57 +00:00
zhi
57af1512d1 feat: implement role, permission, project, milestone, and task command groups
- Add role commands: list, get, create, update, delete, set/add/remove-permissions
- Add permission list command
- Add project commands: list, get, create, update, delete, members, add/remove-member
- Add milestone commands: list, get, create, update, delete, progress
- Add task commands: list, get, create, update, transition, take, delete, search
- Wire all new command groups into main.go dispatcher
- All commands support --json output mode and --token manual auth
- Passes go build and go vet cleanly
2026-03-21 14:50:43 +00:00
zhi
69287d5a49 Add permission-aware help surface 2026-03-21 14:37:42 +00:00
zhi
25114aa17c feat: implement user commands (list, get, create, update, activate, deactivate, delete)
- Add internal/commands/user.go with full user CRUD implementation
- Wire user subcommands in main.go dispatch
- Mark user subcommands as Permitted: true
- Support both padded-cell and manual mode for all user commands
- user create uses account-manager token flow per plan
2026-03-21 14:22:19 +00:00
zhi
f18eb366eb Implement group and leaf help stubs 2026-03-21 14:05:56 +00:00
26 changed files with 7919 additions and 62 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
bin/ bin/
hf
dist/ dist/
coverage.out coverage.out
*.test *.test

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
VERSION ?= dev
BINARY := hf
MAIN_PKG := ./cmd/hf
LDFLAGS := -X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=$(VERSION)
DIST_DIR := dist
RELEASE_TARGETS := \
linux/amd64 \
linux/arm64 \
darwin/amd64 \
darwin/arm64 \
windows/amd64
.PHONY: build clean release release-all
build:
mkdir -p bin
go build -ldflags "$(LDFLAGS)" -o ./bin/$(BINARY) $(MAIN_PKG)
clean:
rm -rf ./bin/$(BINARY) ./$(DIST_DIR)
release: clean
mkdir -p $(DIST_DIR)
@set -e; \
for target in $(RELEASE_TARGETS); do \
os=$${target%/*}; \
arch=$${target#*/}; \
ext=""; \
if [ "$$os" = "windows" ]; then ext=".exe"; fi; \
out="$(DIST_DIR)/$(BINARY)_$(VERSION)_$${os}_$${arch}$${ext}"; \
echo "==> Building $$out"; \
GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o "$$out" $(MAIN_PKG); \
done
release-all: release

175
README.md
View File

@@ -8,12 +8,24 @@
go build -o ./bin/hf ./cmd/hf go build -o ./bin/hf ./cmd/hf
``` ```
Or use the bundled Makefile:
```bash
make build
```
To set the version at build time: To set the version at build time:
```bash ```bash
go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=1.0.0" -o ./bin/hf ./cmd/hf go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=1.0.0" -o ./bin/hf ./cmd/hf
``` ```
The Makefile versioned equivalent is:
```bash
make build VERSION=1.0.0
```
## Run ## Run
```bash ```bash
@@ -39,15 +51,103 @@ go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.
./bin/hf version --json ./bin/hf version --json
``` ```
## Install
### Local install into a user bin directory
```bash
mkdir -p "$HOME/.local/bin"
go build -o "$HOME/.local/bin/hf" ./cmd/hf
chmod +x "$HOME/.local/bin/hf"
```
Make sure `~/.local/bin` is on `PATH` before invoking `hf` directly.
### OpenClaw profile install target
The OpenClaw plugin installer flow places the binary at:
```text
~/.openclaw/bin/hf
```
If you want the equivalent manual install:
```bash
mkdir -p "$HOME/.openclaw/bin"
go build -o "$HOME/.openclaw/bin/hf" ./cmd/hf
chmod +x "$HOME/.openclaw/bin/hf"
```
### Config location
`hf` resolves `.hf-config.json` relative to the binary directory, not the current working directory.
Examples:
- if the binary is `~/.local/bin/hf`, config lives at `~/.local/bin/.hf-config.json`
- if the binary is `~/.openclaw/bin/hf`, config lives at `~/.openclaw/bin/.hf-config.json`
This matters when testing multiple copies of the CLI side by side.
### Quick start after install
```bash
hf config --url http://127.0.0.1:8000
hf --help-brief
hf health
```
### Auth modes after install
- **Padded-cell mode** (`pass_mgr` available): run commands directly and let `hf` resolve secrets automatically.
- **Manual mode** (`pass_mgr` unavailable): pass `--token` to authenticated commands.
Examples:
```bash
# padded-cell mode
hf task list
# manual mode
hf task list --token "$HF_TOKEN"
```
## Release Packaging
Cross-platform release builds are available through the Makefile:
```bash
make release VERSION=1.0.0
```
This produces versioned artifacts in `dist/` using a stable naming pattern:
```text
hf_<version>_<os>_<arch>
hf_<version>_<os>_<arch>.exe # Windows
```
Current release targets:
- `linux/amd64`
- `linux/arm64`
- `darwin/amd64`
- `darwin/arm64`
- `windows/amd64`
Examples:
- `dist/hf_1.0.0_linux_amd64`
- `dist/hf_1.0.0_darwin_arm64`
- `dist/hf_1.0.0_windows_amd64.exe`
## Package Layout ## Package Layout
```text ```text
cmd/hf/ CLI entrypoint cmd/hf/ CLI entrypoint
internal/ internal/
client/ HTTP client wrapper for HarborForge API client/ HTTP client wrapper for HarborForge API
commands/ Command implementations (version, health, config, auth helpers) commands/ Command implementations
config/ Config file resolution and management (.hf-config.json) config/ Config file resolution and management (.hf-config.json)
help/ Help and help-brief renderer help/ Help and help-brief renderer with detailed leaf help
mode/ Runtime mode detection (padded-cell vs manual) mode/ Runtime mode detection (padded-cell vs manual)
output/ Output formatting (human-readable, JSON, tables) output/ Output formatting (human-readable, JSON, tables)
passmgr/ pass_mgr integration for secret resolution passmgr/ pass_mgr integration for secret resolution
@@ -58,18 +158,75 @@ internal/
- **Padded-cell mode**: When `pass_mgr` is available, auth tokens are resolved automatically. Manual `--token` flags are rejected. - **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. - **Manual mode**: When `pass_mgr` is not available, `--token` must be provided explicitly to authenticated commands.
## Output / Error Contract
### Human-readable mode
Default output is human-readable:
- list commands render simple tables
- get/detail commands render key-value output
- empty lists render `(no results)`
### JSON mode
`--json` can be supplied globally and produces structured JSON on stdout.
Current contract:
- success payloads go to **stdout** as JSON
- errors go to **stderr** as plain text
- the CLI does **not** wrap successful payloads in a universal envelope yet
- list/get payloads preserve canonical code-bearing fields whenever the backend already returns them
This is intentionally simple so agents can pipe `hf ... --json` into other tooling without first stripping banners or mixed text.
### Exit / stderr conventions
Current CLI convention is:
- exit `0` on success
- exit `1` on command/validation/runtime errors
- user-facing errors are written to **stderr**
- success output is written to **stdout**
There is not yet a finer-grained exit-code taxonomy; callers should currently treat any non-zero exit as failure.
## Current Status ## Current Status
Implemented: ### Implemented
**Foundation:**
- Go module and binary entrypoint - Go module and binary entrypoint
- Config file resolution relative to binary directory - Config file resolution relative to binary directory
- Runtime mode detection (`pass_mgr` present/absent) - Runtime mode detection (`pass_mgr` present/absent)
- Help and help-brief rendering system - HTTP client wrapper (GET/POST/PUT/PATCH/DELETE)
- HTTP client wrapper
- Output formatting (human-readable + `--json`) - Output formatting (human-readable + `--json`)
- `hf version`, `hf health`, `hf config`
- Auth token resolution (padded-cell + manual) - Auth token resolution (padded-cell + manual)
Planned: **Help system:**
- User, role, project, task, milestone, meeting, support, propose, monitor commands - Top-level and group/leaf help rendering (`--help` / `--help-brief`)
- Permission-aware help rendering - Permission-aware command visibility via `/auth/me/permissions`
- Detailed leaf help text for all commands, with padded-cell/manual auth flag differences
- Nested help coverage for `config`, `monitor server`, and `monitor api-key` subtrees
- `(not permitted)` rendering for unauthorized commands
**Core commands:**
- `hf version`, `hf health`, `hf config` (show / `--url` / `--acc-mgr-token`)
**Resource commands (all implemented with list/get/create/update/delete + special actions):**
- `hf user` — create, list, get, update, activate, deactivate, delete
- `hf role` — list, get, create, update, delete, set-permissions, add-permissions, remove-permissions
- `hf permission` — list
- `hf project` — list, get, create, update, delete, members, add-member, remove-member
- `hf milestone` — list, get, create, update, delete, progress
- `hf task` — list, get, create, update, transition, take, delete, search
- `hf meeting` — list, get, create, update, attend, delete
- `hf support` — list, get, create, update, take, transition, delete
- `hf propose` — list, get, create, update, accept, reject, reopen
- `hf comment` — add, list
- `hf worklog` — add, list
- `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke)
### Pending
- Backend code-based endpoint support (some commands still use id-based API routes)
- Release automation beyond local `make release` packaging (checksums / archives / CI publishing)
- Integration tests

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ import (
type Client struct { type Client struct {
BaseURL string BaseURL string
Token string Token string
APIKey string
HTTPClient *http.Client HTTPClient *http.Client
} }
@@ -28,6 +29,17 @@ func New(baseURL, token string) *Client {
} }
} }
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
func NewWithAPIKey(baseURL, apiKey string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// RequestError represents a non-2xx HTTP response. // RequestError represents a non-2xx HTTP response.
type RequestError struct { type RequestError struct {
StatusCode int StatusCode int
@@ -45,7 +57,9 @@ func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot create request: %w", err) return nil, fmt.Errorf("cannot create request: %w", err)
} }
if c.Token != "" { if c.APIKey != "" {
req.Header.Set("X-API-Key", c.APIKey)
} else if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Authorization", "Bearer "+c.Token)
} }
if body != nil { if body != nil {
@@ -93,7 +107,7 @@ func (c *Client) Delete(path string) ([]byte, error) {
// Health checks the API health endpoint and returns the response. // Health checks the API health endpoint and returns the response.
func (c *Client) Health() (map[string]interface{}, error) { func (c *Client) Health() (map[string]interface{}, error) {
data, err := c.Get("/api/health/") data, err := c.Get("/health")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,660 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"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"
)
// --- Slot Commands ---
// RunCalendarSchedule implements `hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>]`.
func RunCalendarSchedule(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 3 {
output.Error("usage: hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]")
}
slotType := args[0]
scheduledAt := args[1]
estimatedDuration := args[2]
date, jobCode, priority := "", "", ""
remaining := args[3:]
for i := 0; i < len(remaining); i++ {
switch remaining[i] {
case "--date":
if i+1 >= len(remaining) {
output.Error("--date requires a value")
}
i++
date = remaining[i]
case "--job":
if i+1 >= len(remaining) {
output.Error("--job requires a value")
}
i++
jobCode = remaining[i]
case "--priority":
if i+1 >= len(remaining) {
output.Error("--priority requires a value")
}
i++
priority = remaining[i]
default:
output.Errorf("unknown flag: %s", remaining[i])
}
}
payload := map[string]interface{}{
"slot_type": slotType,
"scheduled_at": scheduledAt,
"estimated_duration": estimatedDuration,
}
if date != "" {
payload["date"] = date
}
if priority != "" {
payload["priority"] = priority
}
if jobCode != "" {
payload["event_type"] = "job"
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/calendar/slots", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to schedule slot: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
// Check for warnings
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil {
if ws, ok := resp["warnings"]; ok {
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
fmt.Println("⚠️ Workload warnings:")
for _, w := range warnings {
if wm, ok := w.(map[string]interface{}); ok {
if msg, ok := wm["message"].(string); ok {
fmt.Printf(" - %s\n", msg)
}
}
}
}
}
}
fmt.Printf("slot scheduled: %s at %s (%s min)\n", slotType, scheduledAt, estimatedDuration)
}
// RunCalendarShow implements `hf calendar show [--date <yyyy-mm-dd>]`.
func RunCalendarShow(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
date := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/day"
if date != "" {
path += "?date=" + date
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to show calendar: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Slots []map[string]interface{} `json:"slots"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse calendar: %v", err)
}
slots := resp.Slots
if len(slots) == 0 {
if date != "" {
fmt.Printf("No slots for %s\n", date)
} else {
fmt.Println("No slots for today")
}
return
}
headers := []string{"ID", "TIME", "TYPE", "DURATION", "PRIORITY", "STATUS", "EVENT"}
var rows [][]string
for _, s := range slots {
slotID := fmt.Sprintf("%v", s["slot_id"])
scheduled := fmt.Sprintf("%v", s["scheduled_at"])
slotType := fmt.Sprintf("%v", s["slot_type"])
dur := fmt.Sprintf("%v min", s["estimated_duration"])
pri := fmt.Sprintf("%v", s["priority"])
status := fmt.Sprintf("%v", s["status"])
event := ""
if et, ok := s["event_type"]; ok && et != nil {
event = fmt.Sprintf("%v", et)
}
if ed, ok := s["event_data"]; ok && ed != nil {
if edm, ok := ed.(map[string]interface{}); ok {
if code, ok := edm["code"]; ok {
event += " " + fmt.Sprintf("%v", code)
}
if ev, ok := edm["event"]; ok {
event += " " + fmt.Sprintf("%v", ev)
}
}
}
if isVirt, ok := s["is_virtual"]; ok && isVirt == true {
slotID += " (plan)"
}
rows = append(rows, []string{slotID, scheduled, slotType, dur, pri, status, strings.TrimSpace(event)})
}
output.PrintTable(headers, rows)
}
// RunCalendarEdit implements `hf calendar edit [--date <yyyy-mm-dd>] <slot-id> [flags]`.
func RunCalendarEdit(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--scheduled-at <HH:mm>] [--job <code>]")
}
slotID := args[0]
date, slotType, duration, scheduledAt, jobCode := "", "", "", "", ""
for i := 1; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
case "--slot-type":
if i+1 >= len(args) {
output.Error("--slot-type requires a value")
}
i++
slotType = args[i]
case "--estimated-duration":
if i+1 >= len(args) {
output.Error("--estimated-duration requires a value")
}
i++
duration = args[i]
case "--scheduled-at":
if i+1 >= len(args) {
output.Error("--scheduled-at requires a value")
}
i++
scheduledAt = args[i]
case "--job":
if i+1 >= len(args) {
output.Error("--job requires a value")
}
i++
jobCode = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
payload := make(map[string]interface{})
if slotType != "" {
payload["slot_type"] = slotType
}
if duration != "" {
payload["estimated_duration"] = duration
}
if scheduledAt != "" {
payload["scheduled_at"] = scheduledAt
}
if jobCode != "" {
payload["event_type"] = "job"
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/slots/" + slotID
if strings.HasPrefix(slotID, "plan-") {
path = "/calendar/slots/virtual/" + slotID
}
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
data, err := c.Patch(path, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to edit slot: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil {
if ws, ok := resp["warnings"]; ok {
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
fmt.Println("⚠️ Workload warnings:")
for _, w := range warnings {
if wm, ok := w.(map[string]interface{}); ok {
if msg, ok := wm["message"].(string); ok {
fmt.Printf(" - %s\n", msg)
}
}
}
}
}
}
fmt.Printf("slot edited: %s\n", slotID)
}
// RunCalendarCancel implements `hf calendar cancel [--date <yyyy-mm-dd>] <slot-id>`.
func RunCalendarCancel(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]")
}
slotID := args[0]
date := ""
for i := 1; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/slots/" + slotID + "/cancel"
if strings.HasPrefix(slotID, "plan-") {
path = "/calendar/slots/virtual/" + slotID + "/cancel"
}
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
_, err = c.Post(path, nil)
if err != nil {
output.Errorf("failed to cancel slot: %v", err)
}
fmt.Printf("slot cancelled: %s\n", slotID)
}
// RunCalendarDateList implements `hf calendar date-list`.
func RunCalendarDateList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/calendar/dates")
if err != nil {
output.Errorf("failed to list dates: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Dates []string `json:"dates"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse dates: %v", err)
}
dates := resp.Dates
if len(dates) == 0 {
fmt.Println("No future dates with materialized slots")
return
}
for _, d := range dates {
fmt.Println(d)
}
}
// --- Plan Commands ---
// RunCalendarPlanSchedule implements `hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]`.
func RunCalendarPlanSchedule(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 2 {
output.Error("usage: hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]")
}
slotType := args[0]
duration := args[1]
atTime, onDay, onWeek, onMonth := "", "", "", ""
for i := 2; i < len(args); i++ {
switch args[i] {
case "--at":
if i+1 >= len(args) {
output.Error("--at requires a value")
}
i++
atTime = args[i]
case "--on-day":
if i+1 >= len(args) {
output.Error("--on-day requires a value")
}
i++
onDay = args[i]
case "--on-week":
if i+1 >= len(args) {
output.Error("--on-week requires a value")
}
i++
onWeek = args[i]
case "--on-month":
if i+1 >= len(args) {
output.Error("--on-month requires a value")
}
i++
onMonth = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if atTime == "" {
output.Error("--at is required")
}
payload := map[string]interface{}{
"slot_type": slotType,
"estimated_duration": duration,
"at_time": atTime,
}
if onDay != "" {
payload["on_day"] = onDay
}
if onWeek != "" {
payload["on_week"] = onWeek
}
if onMonth != "" {
payload["on_month"] = onMonth
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/calendar/plans", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create plan: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("plan created: %s at %s (%s min)\n", slotType, atTime, duration)
}
// RunCalendarPlanList implements `hf calendar plan-list`.
func RunCalendarPlanList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/calendar/plans")
if err != nil {
output.Errorf("failed to list plans: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Plans []map[string]interface{} `json:"plans"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse plans: %v", err)
}
plans := resp.Plans
if len(plans) == 0 {
fmt.Println("No schedule plans")
return
}
headers := []string{"ID", "TYPE", "AT", "ON DAY", "ON WEEK", "ON MONTH", "DURATION", "ACTIVE"}
var rows [][]string
for _, p := range plans {
id := fmt.Sprintf("%v", p["id"])
slotType := fmt.Sprintf("%v", p["slot_type"])
at := fmt.Sprintf("%v", p["at_time"])
onDay := ""
if d, ok := p["on_day"]; ok && d != nil {
onDay = fmt.Sprintf("%v", d)
}
onWeek := ""
if w, ok := p["on_week"]; ok && w != nil {
onWeek = fmt.Sprintf("%v", w)
}
onMonth := ""
if m, ok := p["on_month"]; ok && m != nil {
onMonth = fmt.Sprintf("%v", m)
}
dur := fmt.Sprintf("%v min", p["estimated_duration"])
active := "yes"
if a, ok := p["is_active"]; ok && a == false {
active = "no"
}
rows = append(rows, []string{id, slotType, at, onDay, onWeek, onMonth, dur, active})
}
output.PrintTable(headers, rows)
}
// RunCalendarPlanEdit implements `hf calendar plan-edit <plan-id> [flags]`.
func RunCalendarPlanEdit(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]")
}
planID := args[0]
payload := make(map[string]interface{})
for i := 1; i < len(args); i++ {
switch args[i] {
case "--at":
if i+1 >= len(args) {
output.Error("--at requires a value")
}
i++
payload["at_time"] = args[i]
case "--on-day":
if i+1 >= len(args) {
output.Error("--on-day requires a value")
}
i++
payload["on_day"] = args[i]
case "--on-week":
if i+1 >= len(args) {
output.Error("--on-week requires a value")
}
i++
payload["on_week"] = args[i]
case "--on-month":
if i+1 >= len(args) {
output.Error("--on-month requires a value")
}
i++
payload["on_month"] = args[i]
case "--slot-type":
if i+1 >= len(args) {
output.Error("--slot-type requires a value")
}
i++
payload["slot_type"] = args[i]
case "--estimated-duration":
if i+1 >= len(args) {
output.Error("--estimated-duration requires a value")
}
i++
payload["estimated_duration"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to edit — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Patch("/calendar/plans/"+planID, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to edit plan: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("plan edited: %s\n", planID)
}
// RunCalendarPlanCancel implements `hf calendar plan-cancel <plan-id>`.
func RunCalendarPlanCancel(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar plan-cancel <plan-id>")
}
planID := args[0]
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/calendar/plans/"+planID+"/cancel", nil)
if err != nil {
output.Errorf("failed to cancel plan: %v", err)
}
fmt.Printf("plan cancelled: %s\n", planID)
}

View File

@@ -0,0 +1,826 @@
package commands
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func writeTestConfig(t *testing.T, dir, baseURL string) {
config := map[string]string{
"base-url": baseURL,
}
data, err := json.Marshal(config)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
cfgPath := filepath.Join(dir, ".hf-config.json")
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
}
func buildCLI(t *testing.T, cliPath string) {
srcDir := filepath.Join("..", "..")
cmd := exec.Command("go", "build", "-o", cliPath, filepath.Join(srcDir, "cmd", "hf"))
if out, err := cmd.CombinedOutput(); err != nil {
t.Skipf("cannot build CLI: %v (out: %s)", err, string(out))
}
}
func runCLI(t *testing.T, dir, cliPath string, args ...string) (string, error) {
cmd := exec.Command(cliPath, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
out, err := cmd.CombinedOutput()
return string(out), err
}
// --- Tests: argument parsing / usage errors ---
func TestCalendarSchedule_MissingArgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// --token must come after subcommand: hf calendar schedule --token <tok>
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake")
if err == nil {
t.Fatalf("expected non-zero exit for missing args; got out=%s", out)
}
if !strings.Contains(out, "usage:") && !strings.Contains(out, "slot-type") {
t.Errorf("expected usage message with slot-type; got: %s", out)
}
}
func TestCalendarSchedule_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--bad-flag")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected 'unknown flag' in output; got: %s", out)
}
}
func TestCalendarShow_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"slots": []interface{}{}})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--bad-flag")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected 'unknown flag'; got: %s", out)
}
}
func TestCalendarEdit_MissingSlotID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing slot-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestCalendarCancel_MissingSlotID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing slot-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestCalendarPlanSchedule_MissingAt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30")
if err == nil {
t.Fatalf("expected error for missing --at")
}
if !strings.Contains(out, "--at") {
t.Errorf("expected --at error; got: %s", out)
}
}
func TestCalendarPlanEdit_NothingToEdit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1")
if err == nil {
t.Fatalf("expected error for nothing to edit")
}
if !strings.Contains(out, "nothing to edit") {
t.Errorf("expected 'nothing to edit' error; got: %s", out)
}
}
func TestCalendarPlanCancel_MissingPlanID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing plan-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
// --- Tests: JSON output ---
func TestCalendarSchedule_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/calendar/slots" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 42,
"slot_type": "Work",
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
if resp["slot_id"] != float64(42) {
t.Errorf("expected slot_id=42; got: %v", resp["slot_id"])
}
}
func TestCalendarShow_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/day" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{
map[string]interface{}{
"slot_id": 1,
"slot_type": "Work",
"scheduled_at": "09:00",
"estimated_duration": 30,
"priority": 50,
"status": "NotStarted",
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "show", "--token", "fake", "--date", "2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
slots, ok := resp["slots"].([]interface{})
if !ok || len(slots) == 0 {
t.Fatalf("expected slots array in JSON; got: %v", resp)
}
}
func TestCalendarDateList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/dates" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{"2026-04-01", "2026-04-02"},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
dates, ok := resp["dates"].([]interface{})
if !ok || len(dates) != 2 {
t.Errorf("expected 2 dates; got: %v", dates)
}
}
func TestCalendarPlanList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/plans" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{
map[string]interface{}{
"id": 1,
"slot_type": "Work",
"at_time": "09:00",
"estimated_duration": 30,
"is_active": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
plans, ok := resp["plans"].([]interface{})
if !ok || len(plans) == 0 {
t.Fatalf("expected plans array; got: %v", resp)
}
}
// --- Tests: human-readable output ---
func TestCalendarShow_HumanOutput_WithSlots(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{
map[string]interface{}{
"slot_id": 1,
"slot_type": "Work",
"scheduled_at": "09:00",
"estimated_duration": 30,
"priority": 50,
"status": "NotStarted",
},
map[string]interface{}{
"slot_id": "plan-1-2026-04-01",
"slot_type": "OnCall",
"scheduled_at": "14:00",
"estimated_duration": 60,
"priority": 40,
"status": "NotStarted",
"is_virtual": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--date", "2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
// Virtual slot should be marked as plan
if !strings.Contains(out, "plan") {
t.Errorf("expected human output to mark virtual slot as plan; got: %s", out)
}
}
func TestCalendarShow_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No slots") {
t.Errorf("expected 'No slots' for empty; got: %s", out)
}
}
func TestCalendarDateList_HumanOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{"2026-04-01", "2026-04-02"},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
for _, date := range []string{"2026-04-01", "2026-04-02"} {
if !strings.Contains(out, date) {
t.Errorf("expected date %s in output; got: %s", date, out)
}
}
}
func TestCalendarDateList_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No future dates") {
t.Errorf("expected 'No future dates'; got: %s", out)
}
}
func TestCalendarPlanList_HumanOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{
map[string]interface{}{
"id": 1,
"slot_type": "Work",
"at_time": "09:00",
"on_day": "Mon",
"estimated_duration": 30,
"is_active": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "09:00") || !strings.Contains(out, "Work") {
t.Errorf("expected plan data in output; got: %s", out)
}
}
func TestCalendarPlanList_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No schedule plans") {
t.Errorf("expected 'No schedule plans'; got: %s", out)
}
}
// --- Tests: error output ---
func TestCalendarShow_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
w.Write([]byte(`{"detail":"internal error"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
if err == nil {
t.Fatalf("expected error for 500 response")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarEdit_SlotNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"slot not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "999", "--slot-type", "Work")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarCancel_SlotNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"slot not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "999")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarPlanSchedule_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
w.Write([]byte(`{"detail":"db error"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00")
if err == nil {
t.Fatalf("expected error for 500")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarPlanCancel_PlanNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"plan not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "999")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
// --- Tests: workload warnings ---
func TestCalendarSchedule_WorkloadWarning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 1,
"warnings": []interface{}{
map[string]interface{}{
"type": "workload",
"message": "Daily minimum work workload (30 min) not met: current 0 min",
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "⚠") && !strings.Contains(out, "warning") {
t.Errorf("expected workload warning in output; got: %s", out)
}
}
// --- Tests: virtual slot routing ---
func TestCalendarEdit_VirtualSlot_RoutesCorrectly(t *testing.T) {
var editedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
editedPath = r.URL.Path
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 10,
"slot_type": "Work",
"scheduled_at": "10:00",
"estimated_duration": 30,
"status": "NotStarted",
"priority": 50,
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "plan-1-2026-04-01", "--scheduled-at", "10:00")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(editedPath, "/calendar/slots/virtual/") {
t.Errorf("expected virtual slot path /calendar/slots/virtual/...; got: %s", editedPath)
}
}
func TestCalendarCancel_VirtualSlot_RoutesCorrectly(t *testing.T) {
var cancelledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cancelledPath = r.URL.Path
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
_, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "plan-1-2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(cancelledPath, "/calendar/slots/virtual/") {
t.Errorf("expected virtual slot cancel path /calendar/slots/virtual/...; got: %s", cancelledPath)
}
}
// --- Tests: successful operations ---
func TestCalendarSchedule_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"slot_id": 5})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--job", "TASK-1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "slot scheduled") {
t.Errorf("expected 'slot scheduled' success message; got: %s", out)
}
}
func TestCalendarPlanSchedule_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00", "--on-day", "Mon")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan created") {
t.Errorf("expected 'plan created' success message; got: %s", out)
}
}
func TestCalendarPlanEdit_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "at_time": "10:00"})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1", "--at", "10:00")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan edited") {
t.Errorf("expected 'plan edited' success message; got: %s", out)
}
}
func TestCalendarPlanCancel_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan cancelled") {
t.Errorf("expected 'plan cancelled' success message; got: %s", out)
}
}
func TestCalendarCancel_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "slot cancelled") {
t.Errorf("expected 'slot cancelled' success message; got: %s", out)
}
}

View File

@@ -0,0 +1,136 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
type commentResponse struct {
ID int `json:"id"`
TaskID int `json:"task_id"`
AuthorID int `json:"author_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}
func RunCommentAdd(taskCode, content, tokenFlag string) {
if taskCode == "" || content == "" {
output.Error("usage: hf comment add --task <task-code> --content <text>")
}
c := newAuthedClient(tokenFlag)
taskID := resolveTaskID(c, taskCode)
me := currentUser(c)
payload := map[string]interface{}{
"task_id": taskID,
"author_id": me.ID,
"content": content,
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
data, err := c.Post("/comments", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to add comment: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp commentResponse
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse response: %v", err)
}
fmt.Printf("comment added to %s: #%d\n", taskCode, resp.ID)
}
func RunCommentList(taskCode, tokenFlag string) {
if taskCode == "" {
output.Error("usage: hf comment list --task <task-code>")
}
c := newAuthedClient(tokenFlag)
taskID := resolveTaskID(c, taskCode)
data, err := c.Get(fmt.Sprintf("/tasks/%d/comments", taskID))
if err != nil {
output.Errorf("failed to list comments: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var comments []commentResponse
if err := json.Unmarshal(data, &comments); err != nil {
output.Errorf("cannot parse comment list: %v", err)
}
headers := []string{"ID", "AUTHOR", "CREATED", "CONTENT"}
var rows [][]string
for _, item := range comments {
content := item.Content
if len(content) > 60 {
content = content[:57] + "..."
}
rows = append(rows, []string{fmt.Sprintf("%d", item.ID), fmt.Sprintf("%d", item.AuthorID), item.CreatedAt, content})
}
output.PrintTable(headers, rows)
}
func newAuthedClient(tokenFlag string) *client.Client {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
return client.New(cfg.BaseURL, token)
}
type authMeResponse struct {
ID int `json:"id"`
Username string `json:"username"`
}
func currentUser(c *client.Client) authMeResponse {
data, err := c.Get("/auth/me")
if err != nil {
output.Errorf("failed to resolve current user: %v", err)
}
var me authMeResponse
if err := json.Unmarshal(data, &me); err != nil {
output.Errorf("cannot parse current user: %v", err)
}
return me
}
func resolveTaskID(c *client.Client, taskCode string) int {
data, err := c.Get("/tasks/" + taskCode)
if err != nil {
output.Errorf("failed to resolve task %s: %v", taskCode, err)
}
var task taskResponse
if err := json.Unmarshal(data, &task); err != nil {
output.Errorf("cannot parse task %s: %v", taskCode, err)
}
return task.ID
}

View File

@@ -20,7 +20,7 @@ func RunConfigURL(url string) {
fmt.Printf("base-url set to %s\n", url) fmt.Printf("base-url set to %s\n", url)
} }
// RunConfigAccMgrToken stores the account-manager token via pass_mgr. // RunConfigAccMgrToken stores the account-manager token via secret-mgr.
func RunConfigAccMgrToken(token string) { func RunConfigAccMgrToken(token string) {
if token == "" { if token == "" {
output.Error("usage: hf config --acc-mgr-token <token>") output.Error("usage: hf config --acc-mgr-token <token>")

View File

@@ -0,0 +1,274 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
type essentialResponse struct {
ID int `json:"id"`
EssentialCode string `json:"essential_code"`
Type string `json:"type"`
Title string `json:"title"`
Description *string `json:"description"`
CreatedByID *int `json:"created_by_id"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}
// RunEssentialList implements `hf proposal essential list --proposal <proposal-code>`.
func RunEssentialList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" {
output.Error("usage: hf proposal essential list --proposal <proposal-code>")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get(proposalPath(c, proposalCode) + "/essentials")
if err != nil {
output.Errorf("failed to list essentials: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var essentials []essentialResponse
if err := json.Unmarshal(data, &essentials); err != nil {
output.Errorf("cannot parse essential list: %v", err)
}
headers := []string{"CODE", "TYPE", "TITLE", "CREATED"}
var rows [][]string
for _, e := range essentials {
title := e.Title
if len(title) > 40 {
title = title[:37] + "..."
}
rows = append(rows, []string{e.EssentialCode, e.Type, title, e.CreatedAt})
}
output.PrintTable(headers, rows)
}
// RunEssentialCreate implements `hf proposal essential create --proposal <proposal-code> --title <title> --type <type> [--desc <desc>]`.
func RunEssentialCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode, title, essType, desc := "", "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
title = args[i]
case "--type":
if i+1 >= len(args) {
output.Error("--type requires a value")
}
i++
essType = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" || title == "" || essType == "" {
output.Error("usage: hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]")
}
// Validate type
switch essType {
case "feature", "improvement", "refactor":
// valid
default:
output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType)
}
payload := map[string]interface{}{
"title": title,
"type": essType,
}
if desc != "" {
payload["description"] = desc
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post(proposalPath(c, proposalCode)+"/essentials", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create essential: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var e essentialResponse
if err := json.Unmarshal(data, &e); err != nil {
fmt.Printf("essential created: %s\n", title)
return
}
fmt.Printf("essential created: %s (code: %s)\n", e.Title, e.EssentialCode)
}
// RunEssentialUpdate implements `hf proposal essential update <essential-code> [--title ...] [--type ...] [--desc ...]`.
func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode := ""
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
payload["title"] = args[i]
case "--type":
if i+1 >= len(args) {
output.Error("--type requires a value")
}
i++
essType := args[i]
switch essType {
case "feature", "improvement", "refactor":
payload["type"] = essType
default:
output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType)
}
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" {
output.Error("usage: hf proposal essential update <essential-code> --proposal <proposal-code> [--title ...] [--type ...] [--desc ...]")
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch(proposalPath(c, proposalCode)+"/essentials/"+essentialCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update essential: %v", err)
}
fmt.Printf("essential updated: %s\n", essentialCode)
}
// RunEssentialDeleteFull implements `hf proposal essential delete <essential-code> --proposal <code>`.
func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" {
output.Error("usage: hf proposal essential delete <essential-code> --proposal <proposal-code>")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode)
if err != nil {
output.Errorf("failed to delete essential: %v", err)
}
fmt.Printf("essential deleted: %s\n", essentialCode)
}

View File

@@ -0,0 +1,342 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
// meetingResponse matches the backend MeetingResponse schema.
type meetingResponse struct {
ID int `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
MeetingTime *string `json:"meeting_time"`
ProjectCode string `json:"project_code"`
MilestoneCode *string `json:"milestone_code"`
Participants []string `json:"participants"`
CreatedAt string `json:"created_at"`
}
// RunMeetingList implements `hf meeting list`.
func RunMeetingList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
query := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query = appendQuery(query, "status", args[i])
case "--order-by":
if i+1 >= len(args) {
output.Error("--order-by requires a value")
}
i++
query = appendQuery(query, "order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/meetings"
if query != "" {
path += "?" + query
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list meetings: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var meetings []meetingResponse
if err := json.Unmarshal(data, &meetings); err != nil {
output.Errorf("cannot parse meeting list: %v", err)
}
headers := []string{"CODE", "TITLE", "STATUS", "TIME", "PROJECT"}
var rows [][]string
for _, m := range meetings {
meetTime := ""
if m.MeetingTime != nil {
meetTime = *m.MeetingTime
}
title := m.Title
if len(title) > 40 {
title = title[:37] + "..."
}
rows = append(rows, []string{m.Code, title, m.Status, meetTime, m.ProjectCode})
}
output.PrintTable(headers, rows)
}
// RunMeetingGet implements `hf meeting get <meeting-code>`.
func RunMeetingGet(meetingCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/meetings/" + meetingCode)
if err != nil {
output.Errorf("failed to get meeting: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var m meetingResponse
if err := json.Unmarshal(data, &m); err != nil {
output.Errorf("cannot parse meeting: %v", err)
}
desc := ""
if m.Description != nil {
desc = *m.Description
}
meetTime := ""
if m.MeetingTime != nil {
meetTime = *m.MeetingTime
}
milestone := ""
if m.MilestoneCode != nil {
milestone = *m.MilestoneCode
}
participants := ""
if len(m.Participants) > 0 {
for i, p := range m.Participants {
if i > 0 {
participants += ", "
}
participants += p
}
}
output.PrintKeyValue(
"code", m.Code,
"title", m.Title,
"description", desc,
"status", m.Status,
"time", meetTime,
"project", m.ProjectCode,
"milestone", milestone,
"participants", participants,
"created", m.CreatedAt,
)
}
// RunMeetingCreate implements `hf meeting create`.
func RunMeetingCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
project, title, milestone, desc, meetTime := "", "", "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
project = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
title = args[i]
case "--milestone":
if i+1 >= len(args) {
output.Error("--milestone requires a value")
}
i++
milestone = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
case "--time":
if i+1 >= len(args) {
output.Error("--time requires a value")
}
i++
meetTime = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if project == "" || title == "" {
output.Error("usage: hf meeting create --project <project-code> --title <title>")
}
payload := map[string]interface{}{
"project_code": project,
"title": title,
}
if milestone != "" {
payload["milestone_code"] = milestone
}
if desc != "" {
payload["description"] = desc
}
if meetTime != "" {
payload["meeting_time"] = meetTime
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/meetings", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create meeting: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var m meetingResponse
if err := json.Unmarshal(data, &m); err != nil {
fmt.Printf("meeting created: %s\n", title)
return
}
fmt.Printf("meeting created: %s (code: %s)\n", m.Title, m.Code)
}
// RunMeetingUpdate implements `hf meeting update <meeting-code>`.
func RunMeetingUpdate(meetingCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
payload["title"] = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
payload["status"] = args[i]
case "--time":
if i+1 >= len(args) {
output.Error("--time requires a value")
}
i++
payload["meeting_time"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/meetings/"+meetingCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update meeting: %v", err)
}
fmt.Printf("meeting updated: %s\n", meetingCode)
}
// RunMeetingAttend implements `hf meeting attend <meeting-code>`.
func RunMeetingAttend(meetingCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/meetings/"+meetingCode+"/attend", nil)
if err != nil {
output.Errorf("failed to attend meeting: %v", err)
}
fmt.Printf("attending meeting: %s\n", meetingCode)
}
// RunMeetingDelete implements `hf meeting delete <meeting-code>`.
func RunMeetingDelete(meetingCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/meetings/" + meetingCode)
if err != nil {
output.Errorf("failed to delete meeting: %v", err)
}
fmt.Printf("meeting deleted: %s\n", meetingCode)
}

View File

@@ -0,0 +1,342 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
// milestoneResponse matches the backend MilestoneResponse schema.
type milestoneResponse struct {
ID int `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
DueDate *string `json:"due_date"`
ProjectCode string `json:"project_code"`
CreatedAt string `json:"created_at"`
}
// milestoneProgressResponse matches the backend progress response.
type milestoneProgressResponse struct {
Code string `json:"code"`
Title string `json:"title"`
Status string `json:"status"`
TotalTasks int `json:"total_tasks"`
DoneTasks int `json:"done_tasks"`
Progress float64 `json:"progress"`
}
// RunMilestoneList implements `hf milestone list --project <project-code>`.
func RunMilestoneList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
query := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query = appendQuery(query, "status", args[i])
case "--order-by":
if i+1 >= len(args) {
output.Error("--order-by requires a value")
}
i++
query = appendQuery(query, "order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/milestones"
if query != "" {
path += "?" + query
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list milestones: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var milestones []milestoneResponse
if err := json.Unmarshal(data, &milestones); err != nil {
output.Errorf("cannot parse milestone list: %v", err)
}
headers := []string{"CODE", "TITLE", "STATUS", "DUE DATE", "PROJECT"}
var rows [][]string
for _, m := range milestones {
due := ""
if m.DueDate != nil {
due = *m.DueDate
}
rows = append(rows, []string{m.Code, m.Title, m.Status, due, m.ProjectCode})
}
output.PrintTable(headers, rows)
}
// RunMilestoneGet implements `hf milestone get <milestone-code>`.
func RunMilestoneGet(milestoneCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/milestones/" + milestoneCode)
if err != nil {
output.Errorf("failed to get milestone: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var m milestoneResponse
if err := json.Unmarshal(data, &m); err != nil {
output.Errorf("cannot parse milestone: %v", err)
}
desc := ""
if m.Description != nil {
desc = *m.Description
}
due := ""
if m.DueDate != nil {
due = *m.DueDate
}
output.PrintKeyValue(
"code", m.Code,
"title", m.Title,
"description", desc,
"status", m.Status,
"due-date", due,
"project", m.ProjectCode,
"created", m.CreatedAt,
)
}
// RunMilestoneCreate implements `hf milestone create`.
func RunMilestoneCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
project, title, desc, due := "", "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
project = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
title = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
case "--due":
if i+1 >= len(args) {
output.Error("--due requires a value")
}
i++
due = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if project == "" || title == "" {
output.Error("usage: hf milestone create --project <project-code> --title <title>")
}
payload := map[string]interface{}{
"project_code": project,
"title": title,
}
if desc != "" {
payload["description"] = desc
}
if due != "" {
payload["due_date"] = due
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/milestones", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create milestone: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var m milestoneResponse
if err := json.Unmarshal(data, &m); err != nil {
fmt.Printf("milestone created: %s\n", title)
return
}
fmt.Printf("milestone created: %s (code: %s)\n", m.Title, m.Code)
}
// RunMilestoneUpdate implements `hf milestone update <milestone-code>`.
func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
payload["title"] = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
payload["status"] = args[i]
case "--due":
if i+1 >= len(args) {
output.Error("--due requires a value")
}
i++
payload["due_date"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/milestones/"+milestoneCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update milestone: %v", err)
}
fmt.Printf("milestone updated: %s\n", milestoneCode)
}
// RunMilestoneDelete implements `hf milestone delete <milestone-code>`.
func RunMilestoneDelete(milestoneCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/milestones/" + milestoneCode)
if err != nil {
output.Errorf("failed to delete milestone: %v", err)
}
fmt.Printf("milestone deleted: %s\n", milestoneCode)
}
// RunMilestoneProgress implements `hf milestone progress <milestone-code>`.
func RunMilestoneProgress(milestoneCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/milestones/" + milestoneCode + "/progress")
if err != nil {
output.Errorf("failed to get milestone progress: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var p milestoneProgressResponse
if err := json.Unmarshal(data, &p); err != nil {
output.Errorf("cannot parse progress: %v", err)
}
output.PrintKeyValue(
"code", p.Code,
"title", p.Title,
"status", p.Status,
"total-tasks", fmt.Sprintf("%d", p.TotalTasks),
"done-tasks", fmt.Sprintf("%d", p.DoneTasks),
"progress", fmt.Sprintf("%.1f%%", p.Progress*100),
)
}

View File

@@ -0,0 +1,314 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
// monitorOverviewResponse matches the backend monitor overview schema.
type monitorOverviewResponse struct {
Tasks interface{} `json:"tasks"`
Providers interface{} `json:"providers"`
Servers []monitorServerResponse `json:"servers"`
GeneratedAt string `json:"generated_at"`
}
// monitorServerResponse matches the backend monitor server schema.
type monitorServerResponse struct {
ID int `json:"id"`
Identifier string `json:"identifier"`
DisplayName *string `json:"display_name"`
Status string `json:"status"`
LastSeen *string `json:"last_seen"`
CreatedAt string `json:"created_at"`
}
// monitorAPIKeyResponse matches the backend monitor API key schema.
type monitorAPIKeyResponse struct {
ServerID int `json:"server_id"`
APIKey string `json:"api_key"`
Message string `json:"message"`
}
func monitorServerList(c *client.Client) []monitorServerResponse {
data, err := c.Get("/monitor/admin/servers")
if err != nil {
output.Errorf("failed to list monitor servers: %v", err)
}
var servers []monitorServerResponse
if err := json.Unmarshal(data, &servers); err != nil {
output.Errorf("cannot parse server list: %v", err)
}
return servers
}
func resolveMonitorServerID(c *client.Client, identifier string) int {
servers := monitorServerList(c)
for _, s := range servers {
if s.Identifier == identifier {
return s.ID
}
}
output.Errorf("monitor server not found: %s", identifier)
return 0
}
// RunMonitorOverview implements `hf monitor overview`.
func RunMonitorOverview(tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/monitor/public/overview")
if err != nil {
output.Errorf("failed to get monitor overview: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var o monitorOverviewResponse
if err := json.Unmarshal(data, &o); err != nil {
output.Errorf("cannot parse monitor overview: %v", err)
}
online := 0
for _, s := range o.Servers {
if s.Status == "online" {
online++
}
}
output.PrintKeyValue(
"total-servers", fmt.Sprintf("%d", len(o.Servers)),
"online-servers", fmt.Sprintf("%d", online),
"generated-at", o.GeneratedAt,
)
}
// RunMonitorServerList implements `hf monitor server list`.
func RunMonitorServerList(tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/monitor/admin/servers")
if err != nil {
output.Errorf("failed to list monitor servers: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var servers []monitorServerResponse
if err := json.Unmarshal(data, &servers); err != nil {
output.Errorf("cannot parse server list: %v", err)
}
headers := []string{"IDENTIFIER", "NAME", "STATUS", "LAST SEEN"}
var rows [][]string
for _, s := range servers {
name := ""
if s.DisplayName != nil {
name = *s.DisplayName
}
lastSeen := ""
if s.LastSeen != nil {
lastSeen = *s.LastSeen
}
rows = append(rows, []string{s.Identifier, name, s.Status, lastSeen})
}
output.PrintTable(headers, rows)
}
// RunMonitorServerGet implements `hf monitor server get <identifier>`.
func RunMonitorServerGet(identifier, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
servers := monitorServerList(c)
var found *monitorServerResponse
for i := range servers {
if servers[i].Identifier == identifier {
found = &servers[i]
break
}
}
if found == nil {
output.Errorf("failed to get server: not found: %s", identifier)
}
if output.JSONMode {
output.PrintJSON(found)
return
}
name := ""
if found.DisplayName != nil {
name = *found.DisplayName
}
lastSeen := ""
if found.LastSeen != nil {
lastSeen = *found.LastSeen
}
output.PrintKeyValue(
"identifier", found.Identifier,
"name", name,
"status", found.Status,
"last-seen", lastSeen,
"created", found.CreatedAt,
)
}
// RunMonitorServerCreate implements `hf monitor server create --identifier <identifier>`.
func RunMonitorServerCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
identifier, name := "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--identifier":
if i+1 >= len(args) {
output.Error("--identifier requires a value")
}
i++
identifier = args[i]
case "--name":
if i+1 >= len(args) {
output.Error("--name requires a value")
}
i++
name = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if identifier == "" {
output.Error("usage: hf monitor server create --identifier <identifier>")
}
payload := map[string]interface{}{
"identifier": identifier,
}
if name != "" {
payload["display_name"] = name
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/monitor/admin/servers", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create server: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("monitor server created: %s\n", identifier)
_ = data
}
// RunMonitorServerDelete implements `hf monitor server delete <identifier>`.
func RunMonitorServerDelete(identifier, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
serverID := resolveMonitorServerID(c, identifier)
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d", serverID))
if err != nil {
output.Errorf("failed to delete server: %v", err)
}
fmt.Printf("monitor server deleted: %s\n", identifier)
}
// RunMonitorAPIKeyGenerate implements `hf monitor api-key generate <identifier>`.
func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
serverID := resolveMonitorServerID(c, identifier)
data, err := c.Post(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID), nil)
if err != nil {
output.Errorf("failed to generate API key: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var k monitorAPIKeyResponse
if err := json.Unmarshal(data, &k); err != nil {
fmt.Printf("API key generated for: %s\n", identifier)
return
}
output.PrintKeyValue(
"server-id", fmt.Sprintf("%d", k.ServerID),
"api-key", k.APIKey,
"message", k.Message,
)
}
// RunMonitorAPIKeyRevoke implements `hf monitor api-key revoke <identifier>`.
func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
serverID := resolveMonitorServerID(c, identifier)
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID))
if err != nil {
output.Errorf("failed to revoke API key: %v", err)
}
fmt.Printf("API key revoked for: %s\n", identifier)
}

View File

@@ -0,0 +1,413 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
// projectResponse matches the backend ProjectResponse schema.
type projectResponse struct {
ID int `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description *string `json:"description"`
Repo *string `json:"repo"`
Owner string `json:"owner"`
CreatedAt string `json:"created_at"`
}
// projectMemberResponse matches the backend ProjectMemberResponse schema.
type projectMemberResponse struct {
Username string `json:"username"`
RoleName string `json:"role_name"`
}
// RunProjectList implements `hf project list`.
func RunProjectList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
// Build query params
query := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--owner":
if i+1 >= len(args) {
output.Error("--owner requires a value")
}
i++
query = appendQuery(query, "owner", args[i])
case "--order-by":
if i+1 >= len(args) {
output.Error("--order-by requires a value")
}
i++
query = appendQuery(query, "order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/projects"
if query != "" {
path += "?" + query
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list projects: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var projects []projectResponse
if err := json.Unmarshal(data, &projects); err != nil {
output.Errorf("cannot parse project list: %v", err)
}
headers := []string{"CODE", "NAME", "OWNER", "DESCRIPTION"}
var rows [][]string
for _, p := range projects {
desc := ""
if p.Description != nil {
desc = *p.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
}
rows = append(rows, []string{p.Code, p.Name, p.Owner, desc})
}
output.PrintTable(headers, rows)
}
// RunProjectGet implements `hf project get <project-code>`.
func RunProjectGet(projectCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/projects/" + projectCode)
if err != nil {
output.Errorf("failed to get project: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var p projectResponse
if err := json.Unmarshal(data, &p); err != nil {
output.Errorf("cannot parse project: %v", err)
}
desc := ""
if p.Description != nil {
desc = *p.Description
}
repo := ""
if p.Repo != nil {
repo = *p.Repo
}
output.PrintKeyValue(
"code", p.Code,
"name", p.Name,
"description", desc,
"repo", repo,
"owner", p.Owner,
"created", p.CreatedAt,
)
}
// RunProjectCreate implements `hf project create`.
func RunProjectCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
name, desc, repo := "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--name":
if i+1 >= len(args) {
output.Error("--name requires a value")
}
i++
name = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
case "--repo":
if i+1 >= len(args) {
output.Error("--repo requires a value")
}
i++
repo = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if name == "" {
output.Error("usage: hf project create --name <name>")
}
payload := map[string]interface{}{
"name": name,
}
if desc != "" {
payload["description"] = desc
}
if repo != "" {
payload["repo"] = repo
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/projects", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create project: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var p projectResponse
if err := json.Unmarshal(data, &p); err != nil {
fmt.Printf("project created: %s\n", name)
return
}
fmt.Printf("project created: %s (code: %s)\n", p.Name, p.Code)
}
// RunProjectUpdate implements `hf project update <project-code>`.
func RunProjectUpdate(projectCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--name":
if i+1 >= len(args) {
output.Error("--name requires a value")
}
i++
payload["name"] = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
case "--repo":
if i+1 >= len(args) {
output.Error("--repo requires a value")
}
i++
payload["repo"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/projects/"+projectCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update project: %v", err)
}
fmt.Printf("project updated: %s\n", projectCode)
}
// RunProjectDelete implements `hf project delete <project-code>`.
func RunProjectDelete(projectCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/projects/" + projectCode)
if err != nil {
output.Errorf("failed to delete project: %v", err)
}
fmt.Printf("project deleted: %s\n", projectCode)
}
// RunProjectMembers implements `hf project members <project-code>`.
func RunProjectMembers(projectCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/projects/" + projectCode + "/members")
if err != nil {
output.Errorf("failed to list project members: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var members []projectMemberResponse
if err := json.Unmarshal(data, &members); err != nil {
output.Errorf("cannot parse member list: %v", err)
}
headers := []string{"USERNAME", "ROLE"}
var rows [][]string
for _, m := range members {
rows = append(rows, []string{m.Username, m.RoleName})
}
output.PrintTable(headers, rows)
}
// RunProjectAddMember implements `hf project add-member <project-code> --user <username> --role <role-name>`.
func RunProjectAddMember(projectCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
username, roleName := "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--user":
if i+1 >= len(args) {
output.Error("--user requires a value")
}
i++
username = args[i]
case "--role":
if i+1 >= len(args) {
output.Error("--role requires a value")
}
i++
roleName = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if username == "" || roleName == "" {
output.Error("usage: hf project add-member <project-code> --user <username> --role <role-name>")
}
payload := map[string]interface{}{
"username": username,
"role_name": roleName,
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/projects/"+projectCode+"/members", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to add member: %v", err)
}
fmt.Printf("member %s added to project %s with role %s\n", username, projectCode, roleName)
}
// RunProjectRemoveMember implements `hf project remove-member <project-code> --user <username>`.
func RunProjectRemoveMember(projectCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
username := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--user":
if i+1 >= len(args) {
output.Error("--user requires a value")
}
i++
username = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if username == "" {
output.Error("usage: hf project remove-member <project-code> --user <username>")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/projects/" + projectCode + "/members/" + username)
if err != nil {
output.Errorf("failed to remove member: %v", err)
}
fmt.Printf("member %s removed from project %s\n", username, projectCode)
}
// appendQuery is a helper for building query strings.
func appendQuery(existing, key, value string) string {
if existing == "" {
return key + "=" + value
}
return existing + "&" + key + "=" + value
}

View File

@@ -0,0 +1,531 @@
package commands
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func runCLIProposal(t *testing.T, dir, cliPath string, args ...string) (string, error) {
cmd := exec.Command(cliPath, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
out, err := cmd.CombinedOutput()
return string(out), err
}
// --- Essential subcommand tests ---
func TestEssentialList_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "list", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
func TestEssentialCreate_MissingRequired(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Missing --proposal, --title, --type
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001")
if err == nil {
t.Fatalf("expected error for missing required args")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestEssentialCreate_InvalidType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Test", "--type", "invalid-type")
if err == nil {
t.Fatalf("expected error for invalid type")
}
if !strings.Contains(out, "invalid essential type") {
t.Errorf("expected invalid type error; got: %s", out)
}
}
func TestEssentialCreate_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Test", "--type", "feature", "--unknown")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected unknown flag error; got: %s", out)
}
}
func TestEssentialUpdate_NothingToUpdate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001")
if err == nil {
t.Fatalf("expected error for nothing to update")
}
if !strings.Contains(out, "nothing to update") {
t.Errorf("expected 'nothing to update' error; got: %s", out)
}
}
func TestEssentialUpdate_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
func TestEssentialDelete_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", "ESS-001")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
// --- Essential list/create JSON output ---
func TestEssentialList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"essential_code": "ESS-001",
"proposal_id": 1,
"type": "feature",
"title": "Add login",
"created_at": "2026-03-01",
},
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "essential", "list", "--token", "fake", "--proposal", "PRJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
}
func TestEssentialCreate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"essential_code": "ESS-001",
"title": "Add login",
"type": "feature",
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Add login", "--type", "feature")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential created") {
t.Errorf("expected 'essential created' success message; got: %s", out)
}
}
func TestEssentialUpdate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "PATCH" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
w.WriteHeader(200)
w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001", "--title", "Updated title")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential updated") {
t.Errorf("expected 'essential updated' success message; got: %s", out)
}
}
func TestEssentialDelete_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "DELETE" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
w.WriteHeader(200)
w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential deleted") {
t.Errorf("expected 'essential deleted' success message; got: %s", out)
}
}
// --- Proposal Accept tests ---
func TestProposalAccept_MissingMilestone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", "PRJ-001")
if err == nil {
t.Fatalf("expected error for missing --milestone")
}
if !strings.Contains(out, "--milestone") {
t.Errorf("expected --milestone error; got: %s", out)
}
}
func TestProposalAccept_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/accept":
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["milestone_code"] != "MS-001" {
t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"])
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": "PRJ-001",
"status": "Accepted",
"tasks": []interface{}{},
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake",
"PRJ-001", "--milestone", "MS-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "proposal accepted") {
t.Errorf("expected proposal accepted success message; got: %s", out)
}
}
func TestProposalAccept_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": "PRJ-001",
"status": "Accepted",
"tasks": []interface{}{
map[string]interface{}{"code": "TASK-1", "type": "story/feature"},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "accept", "--token", "fake",
"PRJ-001", "--milestone", "MS-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
if resp["status"] != "Accepted" {
t.Errorf("expected status=Accepted; got: %v", resp["status"])
}
}
// --- Story restricted tests ---
func TestTaskCreate_StoryRestricted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Try to create a story/feature directly
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
"--type", "story/feature", "--title", "My story", "--project", "PRJ-001")
if err == nil {
t.Fatalf("expected error for restricted story creation")
}
if !strings.Contains(out, "restricted") && !strings.Contains(out, "proposal accept") {
t.Errorf("expected restricted error mentioning proposal accept; got: %s", out)
}
}
func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("server should not be called for restricted story type")
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Just "story" without slash should also be blocked
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
"--type", "story", "--title", "My story", "--project", "PRJ-001")
if err == nil {
t.Fatalf("expected error for restricted story type")
}
if !strings.Contains(out, "restricted") {
t.Errorf("expected restricted error; got: %s", out)
}
}
// --- Proposal list tests ---
func TestProposalList_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"code": "PRJ-001",
"title": "My Proposal",
"status": "Open",
"project_code": "PROJ-001",
"created_by": "alice",
"created_at": "2026-03-01",
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake", "--project", "PROJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "PRJ-001") {
t.Errorf("expected proposal code in output; got: %s", out)
}
}
func TestProposalList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"code": "PRJ-001",
"title": "My Proposal",
"status": "Open",
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake", "--project", "PROJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
}

View File

@@ -0,0 +1,446 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/url"
"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"
)
// proposeResponse matches the backend ProposeResponse schema.
type proposeResponse struct {
ID int `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
ProjectCode string `json:"project_code"`
CreatedBy *string `json:"created_by"`
CreatedAt string `json:"created_at"`
}
type projectLookup struct {
ID int `json:"id"`
ProjectCode string `json:"project_code"`
}
func resolveProposalProject(c *client.Client, proposalCode string) string {
data, err := c.Get("/projects")
if err != nil {
return ""
}
var projects []projectLookup
if err := json.Unmarshal(data, &projects); err != nil {
return ""
}
for _, p := range projects {
if p.ProjectCode == "" {
continue
}
if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil {
return p.ProjectCode
}
}
return ""
}
func proposalPath(c *client.Client, proposalCode string) string {
if project := resolveProposalProject(c, proposalCode); project != "" {
return "/projects/" + project + "/proposals/" + proposalCode
}
return "/proposes/" + proposalCode
}
// RunProposeList implements `hf propose list --project <project-code>`.
func RunProposeList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
project := ""
query := url.Values{}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
project = args[i]
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query.Set("status", args[i])
case "--order-by":
if i+1 >= len(args) {
output.Error("--order-by requires a value")
}
i++
query.Set("order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
legacyPath := false
if project == "" {
legacyPath = true
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/projects/" + project + "/proposals"
if legacyPath {
path = "/proposes"
}
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list proposals: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var proposes []proposeResponse
if err := json.Unmarshal(data, &proposes); err != nil {
output.Errorf("cannot parse proposal list: %v", err)
}
headers := []string{"CODE", "TITLE", "STATUS", "PROJECT", "CREATED BY"}
var rows [][]string
for _, p := range proposes {
createdBy := ""
if p.CreatedBy != nil {
createdBy = *p.CreatedBy
}
title := p.Title
if len(title) > 40 {
title = title[:37] + "..."
}
rows = append(rows, []string{p.Code, title, p.Status, p.ProjectCode, createdBy})
}
output.PrintTable(headers, rows)
}
// RunProposeGet implements `hf propose get <propose-code>`.
func RunProposeGet(proposeCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get(proposalPath(c, proposeCode))
if err != nil {
output.Errorf("failed to get proposal: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var p proposeResponse
if err := json.Unmarshal(data, &p); err != nil {
output.Errorf("cannot parse proposal: %v", err)
}
desc := ""
if p.Description != nil {
desc = *p.Description
}
createdBy := ""
if p.CreatedBy != nil {
createdBy = *p.CreatedBy
}
output.PrintKeyValue(
"code", p.Code,
"title", p.Title,
"description", desc,
"status", p.Status,
"project", p.ProjectCode,
"created-by", createdBy,
"created", p.CreatedAt,
)
}
// RunProposeCreate implements `hf propose create`.
func RunProposeCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
project, title, desc := "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
project = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
title = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if project == "" || title == "" || desc == "" {
output.Error("usage: hf propose create --project <project-code> --title <title> --desc <desc>")
}
payload := map[string]interface{}{
"title": title,
"description": desc,
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/projects/"+project+"/proposals", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create proposal: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var p proposeResponse
if err := json.Unmarshal(data, &p); err != nil {
fmt.Printf("proposal created: %s\n", title)
return
}
fmt.Printf("proposal created: %s (code: %s)\n", p.Title, p.Code)
}
// RunProposeUpdate implements `hf propose update <propose-code>`.
func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
payload["title"] = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch(proposalPath(c, proposeCode), bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update proposal: %v", err)
}
fmt.Printf("proposal updated: %s\n", proposeCode)
}
// acceptResponse holds the accept result including generated tasks.
type acceptResponse struct {
ProposalCode string `json:"proposal_code"`
Status string `json:"status"`
GeneratedTasks []generatedTask `json:"generated_tasks"`
}
type generatedTask struct {
TaskID int `json:"task_id"`
TaskCode *string `json:"task_code"`
Title string `json:"title"`
TaskType string `json:"task_type"`
TaskSubtype *string `json:"task_subtype"`
}
// RunProposeAccept implements `hf proposal accept <proposal-code> --milestone <milestone-code>`.
func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
milestone := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--milestone":
if i+1 >= len(args) {
output.Error("--milestone requires a value")
}
i++
milestone = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if milestone == "" {
output.Error("usage: hf proposal accept <proposal-code> --milestone <milestone-code>")
}
payload := map[string]interface{}{
"milestone_code": milestone,
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post(proposalPath(c, proposeCode)+"/accept", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to accept proposal: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("proposal accepted: %s\n", proposeCode)
// Try to parse and display generated tasks
var resp acceptResponse
if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 {
fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks))
for _, gt := range resp.GeneratedTasks {
code := "(no task_code)"
if gt.TaskCode != nil {
code = *gt.TaskCode
}
subtype := ""
if gt.TaskSubtype != nil {
subtype = "/" + *gt.TaskSubtype
}
fmt.Printf(" %s %s%s %s\n", code, gt.TaskType, subtype, gt.Title)
}
}
}
// RunProposeReject implements `hf propose reject <propose-code>`.
func RunProposeReject(proposeCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
reason := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--reason":
if i+1 >= len(args) {
output.Error("--reason requires a value")
}
i++
reason = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
var body io.Reader
if reason != "" {
payload := map[string]interface{}{
"reason": reason,
}
data, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
body = bytes.NewReader(data)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post(proposalPath(c, proposeCode)+"/reject", body)
if err != nil {
output.Errorf("failed to reject proposal: %v", err)
}
fmt.Printf("proposal rejected: %s\n", proposeCode)
}
// RunProposeReopen implements `hf propose reopen <propose-code>`.
func RunProposeReopen(proposeCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post(proposalPath(c, proposeCode)+"/reopen", nil)
if err != nil {
output.Errorf("failed to reopen proposal: %v", err)
}
fmt.Printf("proposal reopened: %s\n", proposeCode)
}

477
internal/commands/role.go Normal file
View File

@@ -0,0 +1,477 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"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"
)
// roleResponse matches the backend role list schema.
type roleResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsGlobal bool `json:"is_global"`
PermissionIDs []int `json:"permission_ids"`
CreatedAt string `json:"created_at"`
}
// roleDetailResponse matches the backend role detail schema.
type roleDetailResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsGlobal bool `json:"is_global"`
Permissions []permissionResponse `json:"permissions"`
CreatedAt string `json:"created_at"`
}
// permissionResponse matches the backend permission schema.
type permissionResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
}
func loadRoleClient(tokenFlag string) (*client.Client, error) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("config error: %w", err)
}
return client.New(cfg.BaseURL, token), nil
}
func fetchRoles(c *client.Client) ([]roleResponse, error) {
data, err := c.Get("/roles")
if err != nil {
return nil, err
}
var roles []roleResponse
if err := json.Unmarshal(data, &roles); err != nil {
return nil, fmt.Errorf("cannot parse role list: %w", err)
}
return roles, nil
}
func fetchPermissions(c *client.Client) ([]permissionResponse, error) {
data, err := c.Get("/roles/permissions")
if err != nil {
return nil, err
}
var perms []permissionResponse
if err := json.Unmarshal(data, &perms); err != nil {
return nil, fmt.Errorf("cannot parse permission list: %w", err)
}
return perms, nil
}
func findRoleByName(c *client.Client, roleName string) (*roleResponse, error) {
roles, err := fetchRoles(c)
if err != nil {
return nil, err
}
for _, r := range roles {
if r.Name == roleName {
role := r
return &role, nil
}
}
return nil, fmt.Errorf("role not found: %s", roleName)
}
func fetchRoleDetailByName(c *client.Client, roleName string) (*roleDetailResponse, error) {
role, err := findRoleByName(c, roleName)
if err != nil {
return nil, err
}
data, err := c.Get(fmt.Sprintf("/roles/%d", role.ID))
if err != nil {
return nil, err
}
var detail roleDetailResponse
if err := json.Unmarshal(data, &detail); err != nil {
return nil, fmt.Errorf("cannot parse role detail: %w", err)
}
return &detail, nil
}
func resolvePermissionIDs(c *client.Client, names []string) ([]int, error) {
perms, err := fetchPermissions(c)
if err != nil {
return nil, err
}
byName := make(map[string]int, len(perms))
for _, p := range perms {
byName[p.Name] = p.ID
}
ids := make([]int, 0, len(names))
seen := map[int]struct{}{}
var missing []string
for _, name := range names {
id, ok := byName[name]
if !ok {
missing = append(missing, name)
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(missing) > 0 {
return nil, fmt.Errorf("unknown permission(s): %s", strings.Join(missing, ", "))
}
return ids, nil
}
func replaceRolePermissions(c *client.Client, roleID int, permissionIDs []int) error {
payload := map[string]interface{}{
"permission_ids": permissionIDs,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("cannot marshal payload: %w", err)
}
_, err = c.Post(fmt.Sprintf("/roles/%d/permissions", roleID), bytes.NewReader(body))
return err
}
// RunRoleList implements `hf role list`.
func RunRoleList(tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
data, err := c.Get("/roles")
if err != nil {
output.Errorf("failed to list roles: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var roles []roleResponse
if err := json.Unmarshal(data, &roles); err != nil {
output.Errorf("cannot parse role list: %v", err)
}
headers := []string{"NAME", "DESCRIPTION", "GLOBAL", "PERM IDS"}
var rows [][]string
for _, r := range roles {
global := ""
if r.IsGlobal {
global = "yes"
}
permIDs := ""
if len(r.PermissionIDs) > 0 {
parts := make([]string, 0, len(r.PermissionIDs))
for _, id := range r.PermissionIDs {
parts = append(parts, fmt.Sprintf("%d", id))
}
permIDs = strings.Join(parts, ", ")
}
rows = append(rows, []string{r.Name, r.Description, global, permIDs})
}
output.PrintTable(headers, rows)
}
// RunRoleGet implements `hf role get <role-name>`.
func RunRoleGet(roleName, tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
role, err := findRoleByName(c, roleName)
if err != nil {
output.Errorf("failed to get role: %v", err)
}
data, err := c.Get(fmt.Sprintf("/roles/%d", role.ID))
if err != nil {
output.Errorf("failed to get role: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var r roleDetailResponse
if err := json.Unmarshal(data, &r); err != nil {
output.Errorf("cannot parse role: %v", err)
}
global := "no"
if r.IsGlobal {
global = "yes"
}
perms := "(none)"
if len(r.Permissions) > 0 {
names := make([]string, 0, len(r.Permissions))
for _, p := range r.Permissions {
names = append(names, p.Name)
}
perms = strings.Join(names, ", ")
}
output.PrintKeyValue(
"name", r.Name,
"description", r.Description,
"global", global,
"permissions", perms,
)
}
// RunRoleCreate implements `hf role create`.
func RunRoleCreate(name, desc string, global bool, tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
if name == "" {
output.Error("usage: hf role create --name <role-name>")
}
payload := map[string]interface{}{
"name": name,
}
if desc != "" {
payload["description"] = desc
}
if global {
payload["is_global"] = true
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
data, err := c.Post("/roles", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create role: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("role created: %s\n", name)
}
// RunRoleUpdate implements `hf role update <role-name>`.
func RunRoleUpdate(roleName string, args []string, tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
role, err := findRoleByName(c, roleName)
if err != nil {
output.Errorf("failed to update role: %v", err)
}
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
_, err = c.Patch(fmt.Sprintf("/roles/%d", role.ID), bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update role: %v", err)
}
fmt.Printf("role updated: %s\n", roleName)
}
// RunRoleDelete implements `hf role delete <role-name>`.
func RunRoleDelete(roleName, tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
role, err := findRoleByName(c, roleName)
if err != nil {
output.Errorf("failed to delete role: %v", err)
}
_, err = c.Delete(fmt.Sprintf("/roles/%d", role.ID))
if err != nil {
output.Errorf("failed to delete role: %v", err)
}
fmt.Printf("role deleted: %s\n", roleName)
}
// RunRoleSetPermissions implements `hf role set-permissions <role-name> --permission <perm> [...]`.
func RunRoleSetPermissions(roleName string, permissions []string, tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
if len(permissions) == 0 {
output.Error("usage: hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]")
}
role, err := findRoleByName(c, roleName)
if err != nil {
output.Errorf("failed to set permissions: %v", err)
}
permissionIDs, err := resolvePermissionIDs(c, permissions)
if err != nil {
output.Errorf("failed to set permissions: %v", err)
}
if err := replaceRolePermissions(c, role.ID, permissionIDs); err != nil {
output.Errorf("failed to set permissions: %v", err)
}
fmt.Printf("permissions set for role %s: %s\n", roleName, strings.Join(permissions, ", "))
}
// RunRoleAddPermissions implements `hf role add-permissions <role-name> --permission <perm> [...]`.
func RunRoleAddPermissions(roleName string, permissions []string, tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
if len(permissions) == 0 {
output.Error("usage: hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]")
}
role, err := findRoleByName(c, roleName)
if err != nil {
output.Errorf("failed to add permissions: %v", err)
}
detail, err := fetchRoleDetailByName(c, roleName)
if err != nil {
output.Errorf("failed to add permissions: %v", err)
}
currentIDs := make([]int, 0, len(detail.Permissions))
seen := map[int]struct{}{}
for _, p := range detail.Permissions {
seen[p.ID] = struct{}{}
currentIDs = append(currentIDs, p.ID)
}
newIDs, err := resolvePermissionIDs(c, permissions)
if err != nil {
output.Errorf("failed to add permissions: %v", err)
}
for _, id := range newIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
currentIDs = append(currentIDs, id)
}
if err := replaceRolePermissions(c, role.ID, currentIDs); err != nil {
output.Errorf("failed to add permissions: %v", err)
}
fmt.Printf("permissions added to role %s: %s\n", roleName, strings.Join(permissions, ", "))
}
// RunRoleRemovePermissions implements `hf role remove-permissions <role-name> --permission <perm> [...]`.
func RunRoleRemovePermissions(roleName string, permissions []string, tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
if len(permissions) == 0 {
output.Error("usage: hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]")
}
role, err := findRoleByName(c, roleName)
if err != nil {
output.Errorf("failed to remove permissions: %v", err)
}
detail, err := fetchRoleDetailByName(c, roleName)
if err != nil {
output.Errorf("failed to remove permissions: %v", err)
}
removeIDs, err := resolvePermissionIDs(c, permissions)
if err != nil {
output.Errorf("failed to remove permissions: %v", err)
}
removeSet := map[int]struct{}{}
for _, id := range removeIDs {
removeSet[id] = struct{}{}
}
remaining := make([]int, 0, len(detail.Permissions))
for _, p := range detail.Permissions {
if _, ok := removeSet[p.ID]; ok {
continue
}
remaining = append(remaining, p.ID)
}
if err := replaceRolePermissions(c, role.ID, remaining); err != nil {
output.Errorf("failed to remove permissions: %v", err)
}
fmt.Printf("permissions removed from role %s: %s\n", roleName, strings.Join(permissions, ", "))
}
// RunPermissionList implements `hf permission list`.
func RunPermissionList(tokenFlag string) {
c, err := loadRoleClient(tokenFlag)
if err != nil {
output.Errorf("%v", err)
}
data, err := c.Get("/roles/permissions")
if err != nil {
output.Errorf("failed to list permissions: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var perms []permissionResponse
if err := json.Unmarshal(data, &perms); err != nil {
output.Errorf("cannot parse permission list: %v", err)
}
headers := []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}
var rows [][]string
for _, p := range perms {
rows = append(rows, []string{fmt.Sprintf("%d", p.ID), p.Name, p.Category, p.Description})
}
output.PrintTable(headers, rows)
}

View File

@@ -0,0 +1,349 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
// supportResponse matches the backend SupportResponse schema.
type supportResponse struct {
ID int `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
ProjectCode *string `json:"project_code"`
TakenBy *string `json:"taken_by"`
CreatedAt string `json:"created_at"`
}
// RunSupportList implements `hf support list`.
func RunSupportList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
query := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--taken-by":
if i+1 >= len(args) {
output.Error("--taken-by requires a value")
}
i++
query = appendQuery(query, "taken_by", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query = appendQuery(query, "status", args[i])
case "--order-by":
if i+1 >= len(args) {
output.Error("--order-by requires a value")
}
i++
query = appendQuery(query, "order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/supports"
if query != "" {
path += "?" + query
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list support tickets: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var tickets []supportResponse
if err := json.Unmarshal(data, &tickets); err != nil {
output.Errorf("cannot parse support list: %v", err)
}
headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY"}
var rows [][]string
for _, s := range tickets {
takenBy := ""
if s.TakenBy != nil {
takenBy = *s.TakenBy
}
title := s.Title
if len(title) > 40 {
title = title[:37] + "..."
}
rows = append(rows, []string{s.Code, title, s.Status, s.Priority, takenBy})
}
output.PrintTable(headers, rows)
}
// RunSupportGet implements `hf support get <support-code>`.
func RunSupportGet(supportCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/supports/" + supportCode)
if err != nil {
output.Errorf("failed to get support ticket: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var s supportResponse
if err := json.Unmarshal(data, &s); err != nil {
output.Errorf("cannot parse support ticket: %v", err)
}
desc := ""
if s.Description != nil {
desc = *s.Description
}
project := ""
if s.ProjectCode != nil {
project = *s.ProjectCode
}
takenBy := ""
if s.TakenBy != nil {
takenBy = *s.TakenBy
}
output.PrintKeyValue(
"code", s.Code,
"title", s.Title,
"description", desc,
"status", s.Status,
"priority", s.Priority,
"project", project,
"taken-by", takenBy,
"created", s.CreatedAt,
)
}
// RunSupportCreate implements `hf support create`.
func RunSupportCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
title, project, desc, priority := "", "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
title = args[i]
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
project = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
case "--priority":
if i+1 >= len(args) {
output.Error("--priority requires a value")
}
i++
priority = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if title == "" {
output.Error("usage: hf support create --title <title>")
}
payload := map[string]interface{}{
"title": title,
}
if project != "" {
payload["project_code"] = project
}
if desc != "" {
payload["description"] = desc
}
if priority != "" {
payload["priority"] = priority
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/supports", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create support ticket: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var s supportResponse
if err := json.Unmarshal(data, &s); err != nil {
fmt.Printf("support ticket created: %s\n", title)
return
}
fmt.Printf("support ticket created: %s (code: %s)\n", s.Title, s.Code)
}
// RunSupportUpdate implements `hf support update <support-code>`.
func RunSupportUpdate(supportCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
payload["title"] = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
payload["status"] = args[i]
case "--priority":
if i+1 >= len(args) {
output.Error("--priority requires a value")
}
i++
payload["priority"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/supports/"+supportCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update support ticket: %v", err)
}
fmt.Printf("support ticket updated: %s\n", supportCode)
}
// RunSupportTake implements `hf support take <support-code>`.
func RunSupportTake(supportCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/supports/"+supportCode+"/take", nil)
if err != nil {
output.Errorf("failed to take support ticket: %v", err)
}
fmt.Printf("support ticket taken: %s\n", supportCode)
}
// RunSupportTransition implements `hf support transition <support-code> <status>`.
func RunSupportTransition(supportCode, status, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := map[string]interface{}{
"status": status,
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/supports/"+supportCode+"/transition", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to transition support ticket: %v", err)
}
fmt.Printf("support ticket %s transitioned to %s\n", supportCode, status)
}
// RunSupportDelete implements `hf support delete <support-code>`.
func RunSupportDelete(supportCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/supports/" + supportCode)
if err != nil {
output.Errorf("failed to delete support ticket: %v", err)
}
fmt.Printf("support ticket deleted: %s\n", supportCode)
}

483
internal/commands/task.go Normal file
View File

@@ -0,0 +1,483 @@
package commands
import (
"bytes"
"encoding/json"
"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"
)
// taskResponse matches the backend TaskResponse schema.
type taskResponse struct {
ID int `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
Type string `json:"type"`
DueDate *string `json:"due_date"`
ProjectCode string `json:"project_code"`
MilestoneCode *string `json:"milestone_code"`
TakenBy *string `json:"taken_by"`
CreatedAt string `json:"created_at"`
}
// RunTaskList implements `hf task list`.
func RunTaskList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
query := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project_code", args[i])
case "--milestone":
if i+1 >= len(args) {
output.Error("--milestone requires a value")
}
i++
query = appendQuery(query, "milestone_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query = appendQuery(query, "status", args[i])
case "--taken-by":
if i+1 >= len(args) {
output.Error("--taken-by requires a value")
}
i++
query = appendQuery(query, "taken_by", args[i])
case "--due-today":
if i+1 >= len(args) {
output.Error("--due-today requires a value")
}
i++
query = appendQuery(query, "due_today", args[i])
case "--order-by":
if i+1 >= len(args) {
output.Error("--order-by requires a value")
}
i++
query = appendQuery(query, "order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/tasks"
if query != "" {
path += "?" + query
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list tasks: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var tasks []taskResponse
if err := json.Unmarshal(data, &tasks); err != nil {
output.Errorf("cannot parse task list: %v", err)
}
headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY", "PROJECT"}
var rows [][]string
for _, t := range tasks {
takenBy := ""
if t.TakenBy != nil {
takenBy = *t.TakenBy
}
title := t.Title
if len(title) > 40 {
title = title[:37] + "..."
}
rows = append(rows, []string{t.Code, title, t.Status, t.Priority, takenBy, t.ProjectCode})
}
output.PrintTable(headers, rows)
}
// RunTaskGet implements `hf task get <task-code>`.
func RunTaskGet(taskCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/tasks/" + taskCode)
if err != nil {
output.Errorf("failed to get task: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var t taskResponse
if err := json.Unmarshal(data, &t); err != nil {
output.Errorf("cannot parse task: %v", err)
}
desc := ""
if t.Description != nil {
desc = *t.Description
}
due := ""
if t.DueDate != nil {
due = *t.DueDate
}
milestone := ""
if t.MilestoneCode != nil {
milestone = *t.MilestoneCode
}
takenBy := ""
if t.TakenBy != nil {
takenBy = *t.TakenBy
}
output.PrintKeyValue(
"code", t.Code,
"title", t.Title,
"description", desc,
"status", t.Status,
"priority", t.Priority,
"type", t.Type,
"due-date", due,
"project", t.ProjectCode,
"milestone", milestone,
"taken-by", takenBy,
"created", t.CreatedAt,
)
}
// RunTaskCreate implements `hf task create`.
func RunTaskCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
project, title, milestone, taskType, priority, desc := "", "", "", "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
project = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
title = args[i]
case "--milestone":
if i+1 >= len(args) {
output.Error("--milestone requires a value")
}
i++
milestone = args[i]
case "--type":
if i+1 >= len(args) {
output.Error("--type requires a value")
}
i++
taskType = args[i]
case "--priority":
if i+1 >= len(args) {
output.Error("--priority requires a value")
}
i++
priority = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if project == "" || title == "" {
output.Error("usage: hf task create --project <project-code> --title <title>")
}
// story/* types are restricted — must be created via `hf proposal accept`
if taskType == "story" || (len(taskType) > 6 && taskType[:6] == "story/") {
output.Error("story tasks are restricted and cannot be created directly.\nUse 'hf proposal accept <proposal-code> --milestone <milestone-code>' to generate story tasks from a proposal.")
}
payload := map[string]interface{}{
"project_code": project,
"title": title,
}
if milestone != "" {
payload["milestone_code"] = milestone
}
if taskType != "" {
payload["type"] = taskType
}
if priority != "" {
payload["priority"] = priority
}
if desc != "" {
payload["description"] = desc
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/tasks", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create task: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var t taskResponse
if err := json.Unmarshal(data, &t); err != nil {
fmt.Printf("task created: %s\n", title)
return
}
fmt.Printf("task created: %s (code: %s)\n", t.Title, t.Code)
}
// RunTaskUpdate implements `hf task update <task-code>`.
func RunTaskUpdate(taskCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
payload["title"] = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
payload["status"] = args[i]
case "--priority":
if i+1 >= len(args) {
output.Error("--priority requires a value")
}
i++
payload["priority"] = args[i]
case "--assignee":
if i+1 >= len(args) {
output.Error("--assignee requires a value")
}
i++
val := args[i]
if val == "null" {
payload["taken_by"] = nil
} else {
payload["taken_by"] = val
}
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/tasks/"+taskCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update task: %v", err)
}
fmt.Printf("task updated: %s\n", taskCode)
}
// RunTaskTransition implements `hf task transition <task-code> <status>`.
func RunTaskTransition(taskCode, status, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := map[string]interface{}{
"status": status,
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/tasks/"+taskCode+"/transition", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to transition task: %v", err)
}
fmt.Printf("task %s transitioned to %s\n", taskCode, status)
}
// RunTaskTake implements `hf task take <task-code>`.
func RunTaskTake(taskCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/tasks/"+taskCode+"/take", nil)
if err != nil {
output.Errorf("failed to take task: %v", err)
}
fmt.Printf("task taken: %s\n", taskCode)
}
// RunTaskDelete implements `hf task delete <task-code>`.
func RunTaskDelete(taskCode, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/tasks/" + taskCode)
if err != nil {
output.Errorf("failed to delete task: %v", err)
}
fmt.Printf("task deleted: %s\n", taskCode)
}
// RunTaskSearch implements `hf task search`.
func RunTaskSearch(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
query := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--query":
if i+1 >= len(args) {
output.Error("--query requires a value")
}
i++
query = appendQuery(query, "q", args[i])
case "--project":
if i+1 >= len(args) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query = appendQuery(query, "status", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/tasks/search"
if query != "" {
path += "?" + query
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to search tasks: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var tasks []taskResponse
if err := json.Unmarshal(data, &tasks); err != nil {
output.Errorf("cannot parse task list: %v", err)
}
headers := []string{"CODE", "TITLE", "STATUS", "PRIORITY", "TAKEN BY", "PROJECT"}
var rows [][]string
for _, t := range tasks {
takenBy := ""
if t.TakenBy != nil {
takenBy = *t.TakenBy
}
title := t.Title
if len(title) > 40 {
title = title[:37] + "..."
}
rows = append(rows, []string{t.Code, title, t.Status, t.Priority, takenBy, t.ProjectCode})
}
output.PrintTable(headers, rows)
}

449
internal/commands/user.go Normal file
View File

@@ -0,0 +1,449 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"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/mode"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
)
// userResponse matches the backend UserResponse schema.
type userResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FullName *string `json:"full_name"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
RoleID *int `json:"role_id"`
RoleName *string `json:"role_name"`
DiscordUserID *string `json:"discord_user_id"`
CreatedAt string `json:"created_at"`
}
// RunUserList implements `hf user list`.
func RunUserList(tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/users")
if err != nil {
output.Errorf("failed to list users: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var users []userResponse
if err := json.Unmarshal(data, &users); err != nil {
output.Errorf("cannot parse user list: %v", err)
}
headers := []string{"USERNAME", "EMAIL", "FULL NAME", "ROLE", "ACTIVE", "ADMIN"}
var rows [][]string
for _, u := range users {
fullName := ""
if u.FullName != nil {
fullName = *u.FullName
}
roleName := ""
if u.RoleName != nil {
roleName = *u.RoleName
}
active := "yes"
if !u.IsActive {
active = "no"
}
admin := ""
if u.IsAdmin {
admin = "yes"
}
rows = append(rows, []string{u.Username, u.Email, fullName, roleName, active, admin})
}
output.PrintTable(headers, rows)
}
// RunUserGet implements `hf user get <username>`.
func RunUserGet(username, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/users/" + username)
if err != nil {
output.Errorf("failed to get user: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var u userResponse
if err := json.Unmarshal(data, &u); err != nil {
output.Errorf("cannot parse user: %v", err)
}
fullName := ""
if u.FullName != nil {
fullName = *u.FullName
}
roleName := ""
if u.RoleName != nil {
roleName = *u.RoleName
}
active := "yes"
if !u.IsActive {
active = "no"
}
admin := ""
if u.IsAdmin {
admin = "yes"
}
output.PrintKeyValue(
"username", u.Username,
"email", u.Email,
"full-name", fullName,
"role", roleName,
"active", active,
"admin", admin,
"created", u.CreatedAt,
)
}
// userCreatePayload is the JSON body for POST /users.
type userCreatePayload struct {
Username string `json:"username"`
Email string `json:"email"`
FullName *string `json:"full_name,omitempty"`
Password *string `json:"password,omitempty"`
DiscordUserID *string `json:"discord_user_id,omitempty"`
}
func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, error) {
if strings.TrimSpace(explicit) != "" {
return strings.TrimSpace(explicit), true, nil
}
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
agentVerify := strings.TrimSpace(os.Getenv("AGENT_VERIFY"))
if agentID == "" || agentVerify == "" {
if requireEnv {
return "", false, fmt.Errorf("discord id not provided and AGENT_ID/AGENT_VERIFY are missing")
}
return "", false, nil
}
cmd := exec.Command("ego-mgr", "get", "discord-id")
out, err := cmd.Output()
if err != nil {
if requireEnv {
return "", false, fmt.Errorf("failed to resolve discord id from ego-mgr: %w", err)
}
return "", false, nil
}
value := strings.TrimSpace(string(out))
if value == "" {
if requireEnv {
return "", false, fmt.Errorf("ego-mgr returned empty discord id")
}
return "", false, nil
}
return value, true, nil
}
// RunUserCreate implements `hf user create`.
func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag string) {
// Resolve account-manager token
var accMgrToken string
if mode.IsPaddedCell() {
if accMgrTokenFlag != "" {
output.Error("padded-cell installed, --acc-mgr-token flag disabled, use command directly")
}
tok, err := passmgr.GetAccountManagerToken()
if err != nil {
output.Error("--acc-mgr-token <token> required or execute with pcexec")
}
accMgrToken = tok
} else {
if accMgrTokenFlag == "" {
output.Error("--acc-mgr-token <token> required or execute with pcexec")
}
accMgrToken = accMgrTokenFlag
}
// Resolve password
if password == "" && mode.IsPaddedCell() {
pw, err := passmgr.GeneratePassword("hf", username)
if err != nil {
output.Error("--pass <password> required or execute with pcexec")
}
password = pw
}
if password == "" && !mode.IsPaddedCell() {
output.Error("--pass <password> required or execute with pcexec")
}
// Resolve email (default to username@harborforge.local if not provided)
if email == "" {
email = username + "@harborforge.local"
}
payload := userCreatePayload{
Username: username,
Email: email,
Password: &password,
}
if resolvedDiscordID, ok, err := maybeResolveDiscordUserID(discordUserID, false); err != nil {
output.Errorf("failed to resolve discord user id: %v", err)
} else if ok {
payload.DiscordUserID = &resolvedDiscordID
}
if fullName != "" {
payload.FullName = &fullName
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken)
data, err := c.Post("/users", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create user: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var u userResponse
if err := json.Unmarshal(data, &u); err != nil {
output.Errorf("cannot parse response: %v", err)
}
fmt.Printf("user created: %s\n", u.Username)
}
// RunUserUpdateDiscordID updates a user's discord_user_id field.
func RunUserUpdateDiscordID(username, discordUserID, tokenFlag string) {
token := ResolveToken(tokenFlag)
resolvedDiscordID, _, err := maybeResolveDiscordUserID(discordUserID, true)
if err != nil {
output.Errorf("failed to resolve discord user id: %v", err)
}
body, err := json.Marshal(map[string]interface{}{"discord_user_id": resolvedDiscordID})
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
if _, err := c.Patch("/users/"+username, bytes.NewReader(body)); err != nil {
output.Errorf("failed to update discord id: %v", err)
}
fmt.Printf("discord id updated: %s\n", username)
}
// RunUserUpdate implements `hf user update <username>`.
func RunUserUpdate(username string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--email":
if i+1 >= len(args) {
output.Error("--email requires a value")
}
i++
payload["email"] = args[i]
case "--full-name":
if i+1 >= len(args) {
output.Error("--full-name requires a value")
}
i++
payload["full_name"] = args[i]
case "--pass":
if i+1 >= len(args) {
output.Error("--pass requires a value")
}
i++
payload["password"] = args[i]
case "--active":
if i+1 >= len(args) {
output.Error("--active requires true or false")
}
i++
payload["is_active"] = strings.ToLower(args[i]) == "true"
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Patch("/users/"+username, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update user: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("user updated: %s\n", username)
}
// RunUserActivate implements `hf user activate <username>`.
func RunUserActivate(username, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
body, _ := json.Marshal(map[string]interface{}{"is_active": true})
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/users/"+username, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to activate user: %v", err)
}
fmt.Printf("user activated: %s\n", username)
}
// RunUserDeactivate implements `hf user deactivate <username>`.
func RunUserDeactivate(username, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
body, _ := json.Marshal(map[string]interface{}{"is_active": false})
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/users/"+username, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to deactivate user: %v", err)
}
fmt.Printf("user deactivated: %s\n", username)
}
// RunUserDelete implements `hf user delete <username>`.
func RunUserDelete(username, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/users/" + username)
if err != nil {
output.Errorf("failed to delete user: %v", err)
}
fmt.Printf("user deleted: %s\n", username)
}
// resetAPIKeyResponse matches the backend reset-apikey response.
type resetAPIKeyResponse struct {
UserID int `json:"user_id"`
Username string `json:"username"`
APIKey string `json:"api_key"`
Message string `json:"message"`
}
// RunUserResetAPIKey implements `hf user reset-apikey <username>`.
func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) {
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
// Try acc-mgr-token first (allows provisioning without existing user token)
var c *client.Client
if accMgrTokenFlag != "" {
c = client.NewWithAPIKey(cfg.BaseURL, accMgrTokenFlag)
} else if mode.IsPaddedCell() {
if tok, err := passmgr.GetAccountManagerToken(); err == nil && tok != "" {
c = client.NewWithAPIKey(cfg.BaseURL, tok)
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
data, err := c.Post("/users/"+username+"/reset-apikey", nil)
if err != nil {
output.Errorf("failed to reset API key: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var r resetAPIKeyResponse
if err := json.Unmarshal(data, &r); err != nil {
fmt.Printf("API key reset for: %s\n", username)
return
}
output.PrintKeyValue(
"username", r.Username,
"api-key", r.APIKey,
"message", r.Message,
)
}

View File

@@ -0,0 +1,124 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
)
type worklogResponse struct {
ID int `json:"id"`
TaskID int `json:"task_id"`
UserID int `json:"user_id"`
Hours float64 `json:"hours"`
Description *string `json:"description"`
LoggedDate string `json:"logged_date"`
CreatedAt string `json:"created_at"`
}
func RunWorklogAdd(taskCode string, hours float64, desc, date, tokenFlag string) {
if taskCode == "" || hours <= 0 {
output.Error("usage: hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]")
}
c := newAuthedClient(tokenFlag)
taskID := resolveTaskID(c, taskCode)
me := currentUser(c)
payload := map[string]interface{}{
"task_id": taskID,
"user_id": me.ID,
"hours": hours,
}
if desc != "" {
payload["description"] = desc
}
if date != "" {
payload["logged_date"] = date
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
data, err := c.Post("/worklogs", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to add worklog: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp worklogResponse
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse response: %v", err)
}
fmt.Printf("worklog added to %s: #%d (%.2fh)\n", taskCode, resp.ID, resp.Hours)
}
func RunWorklogList(taskCode, username, tokenFlag string) {
if taskCode == "" && username == "" {
output.Error("usage: hf worklog list [--task <task-code>] [--user <username>]")
}
if taskCode != "" && username != "" {
output.Error("choose only one of --task <task-code> or --user <username>")
}
c := newAuthedClient(tokenFlag)
path := ""
if taskCode != "" {
taskID := resolveTaskID(c, taskCode)
path = fmt.Sprintf("/tasks/%d/worklogs", taskID)
} else {
path = fmt.Sprintf("/users/%s/worklogs", username)
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to list worklogs: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var logs []worklogResponse
if err := json.Unmarshal(data, &logs); err != nil {
output.Errorf("cannot parse worklog list: %v", err)
}
headers := []string{"ID", "TASK", "USER", "HOURS", "DATE", "DESCRIPTION"}
var rows [][]string
for _, item := range logs {
desc := ""
if item.Description != nil {
desc = *item.Description
}
if len(desc) > 40 {
desc = desc[:37] + "..."
}
rows = append(rows, []string{
fmt.Sprintf("%d", item.ID),
fmt.Sprintf("%d", item.TaskID),
fmt.Sprintf("%d", item.UserID),
fmt.Sprintf("%.2f", item.Hours),
item.LoggedDate,
desc,
})
}
output.PrintTable(headers, rows)
}

204
internal/help/leaf.go Normal file
View File

@@ -0,0 +1,204 @@
package help
import (
"fmt"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
)
type leafHelp struct {
Summary string
Usage []string
Flags []string
Notes []string
}
func RenderLeafHelp(group, cmd string) (string, bool) {
spec, ok := leafHelpSpec(group, cmd)
if !ok {
return "", false
}
var b strings.Builder
name := strings.TrimSpace(strings.TrimSpace(group + " " + cmd))
name = strings.ReplaceAll(name, "/", " ")
b.WriteString(fmt.Sprintf("hf %s - %s\n", name, spec.Summary))
b.WriteString("\nUsage:\n")
for _, line := range spec.Usage {
b.WriteString(" " + line + "\n")
}
if len(spec.Flags) > 0 {
b.WriteString("\nFlags:\n")
for _, line := range spec.Flags {
b.WriteString(" " + line + "\n")
}
}
if len(spec.Notes) > 0 {
b.WriteString("\nNotes:\n")
for _, line := range spec.Notes {
b.WriteString(" - " + line + "\n")
}
}
return b.String(), true
}
func authFlagHelp() []string {
if mode.IsPaddedCell() {
return []string{
"--json Output in JSON format",
}
}
return []string{
"--token <token> HarborForge API token (required in manual mode)",
"--json Output in JSON format",
}
}
func accountManagerFlagHelp() []string {
flags := []string{
"--user <username> Username to create",
"--pass <password> Initial password",
"--email <email> Email address (defaults to <user>@harborforge.local)",
"--full-name <name> Full name",
"--json Output in JSON format",
}
if mode.IsPaddedCell() {
return flags
}
return append([]string{"--acc-mgr-token <token> Account-manager token (required in manual mode)"}, flags...)
}
func leafHelpSpec(group, cmd string) (leafHelp, bool) {
specs := map[string]leafHelp{
"config/show": {
Summary: "View current CLI configuration",
Usage: []string{"hf config"},
Flags: []string{"--json Output in JSON format"},
Notes: []string{
"Configuration is stored in .hf-config.json next to the hf binary.",
},
},
"version": {
Summary: "Show CLI version",
Usage: []string{"hf version"},
Flags: []string{"--json Output in JSON format"},
},
"health": {
Summary: "Check HarborForge API health",
Usage: []string{"hf health", "hf health --json"},
Flags: authFlagHelp(),
},
"config/url": {
Summary: "Set HarborForge API base URL",
Usage: []string{"hf config --url <hf-url>"},
Notes: []string{"Writes base-url into .hf-config.json next to the hf binary."},
},
"config/acc-mgr-token": {
Summary: "Store the account-manager token via secret-mgr",
Usage: []string{"hf config --acc-mgr-token <token>"},
Notes: []string{"Only available in padded-cell mode with secret-mgr installed."},
},
"user/create": {
Summary: "Create a user account",
Usage: []string{"hf user create --user <username> [--pass <password>] [--email <email>] [--full-name <name>]"},
Flags: accountManagerFlagHelp(),
Notes: []string{
"This command uses the account-manager token flow, not the normal user token flow.",
"In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to secret-mgr.",
},
},
"user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()},
"user/get": {Summary: "Show a user by username", Usage: []string{"hf user get <username>"}, Flags: authFlagHelp()},
"user/update": {Summary: "Update a user", Usage: []string{"hf user update <username> [--email <email>] [--full-name <name>] [--pass <password>] [--active <true|false>]"}, Flags: authFlagHelp()},
"user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()},
"user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()},
"user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()},
"user/reset-apikey": {Summary: "Reset a user's API key", Usage: []string{"hf user reset-apikey <username>"}, Flags: authFlagHelp(), Notes: []string{"The new API key is shown once and cannot be retrieved again."}},
"role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()},
"role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()},
"role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()},
"role/update": {Summary: "Update a role", Usage: []string{"hf role update <role-name> [--desc <desc>]"}, Flags: authFlagHelp()},
"role/delete": {Summary: "Delete a role", Usage: []string{"hf role delete <role-name>"}, Flags: authFlagHelp()},
"role/set-permissions": {Summary: "Replace role permissions", Usage: []string{"hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()},
"role/add-permissions": {Summary: "Add permissions to a role", Usage: []string{"hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()},
"role/remove-permissions": {Summary: "Remove permissions from a role", Usage: []string{"hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()},
"permission/list": {Summary: "List permissions", Usage: []string{"hf permission list"}, Flags: authFlagHelp()},
"project/list": {Summary: "List projects", Usage: []string{"hf project list [--owner <username>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"project/get": {Summary: "Show a project by code", Usage: []string{"hf project get <project-code>"}, Flags: authFlagHelp()},
"project/create": {Summary: "Create a project", Usage: []string{"hf project create --name <name> [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()},
"project/update": {Summary: "Update a project", Usage: []string{"hf project update <project-code> [--name <name>] [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()},
"project/delete": {Summary: "Delete a project", Usage: []string{"hf project delete <project-code>"}, Flags: authFlagHelp()},
"project/members": {Summary: "List project members", Usage: []string{"hf project members <project-code>"}, Flags: authFlagHelp()},
"project/add-member": {Summary: "Add a member to a project", Usage: []string{"hf project add-member <project-code> --user <username> --role <role-name>"}, Flags: authFlagHelp()},
"project/remove-member": {Summary: "Remove a member from a project", Usage: []string{"hf project remove-member <project-code> --user <username>"}, Flags: authFlagHelp()},
"milestone/list": {Summary: "List milestones", Usage: []string{"hf milestone list --project <project-code> [--status <status>] [--order-by <due-date|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"milestone/get": {Summary: "Show a milestone by code", Usage: []string{"hf milestone get <milestone-code>"}, Flags: authFlagHelp()},
"milestone/create": {Summary: "Create a milestone", Usage: []string{"hf milestone create --project <project-code> --title <title> [--desc <desc>] [--due <date>]"}, Flags: authFlagHelp()},
"milestone/update": {Summary: "Update a milestone", Usage: []string{"hf milestone update <milestone-code> [--title <title>] [--desc <desc>] [--status <status>] [--due <date>]"}, Flags: authFlagHelp()},
"milestone/delete": {Summary: "Delete a milestone", Usage: []string{"hf milestone delete <milestone-code>"}, Flags: authFlagHelp()},
"milestone/progress": {Summary: "Show milestone progress", Usage: []string{"hf milestone progress <milestone-code>"}, Flags: authFlagHelp()},
"task/list": {Summary: "List tasks", Usage: []string{"hf task list [--project <project-code>] [--milestone <milestone-code>] [--status <status>] [--taken-by <me|null|username>] [--due-today <true|false>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"task/get": {Summary: "Show a task by code", Usage: []string{"hf task get <task-code>"}, Flags: authFlagHelp()},
"task/create": {Summary: "Create a task", Usage: []string{"hf task create --project <project-code> --title <title> [--milestone <milestone-code>] [--type <type>] [--priority <priority>] [--desc <desc>]"}, Flags: authFlagHelp()},
"task/update": {Summary: "Update a task", Usage: []string{"hf task update <task-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>] [--assignee <username|null>]"}, Flags: authFlagHelp()},
"task/transition": {Summary: "Transition a task to a new status", Usage: []string{"hf task transition <task-code> <status>"}, Flags: authFlagHelp()},
"task/take": {Summary: "Assign a task to the current user", Usage: []string{"hf task take <task-code>"}, Flags: authFlagHelp()},
"task/delete": {Summary: "Delete a task", Usage: []string{"hf task delete <task-code>"}, Flags: authFlagHelp()},
"task/search": {Summary: "Search tasks", Usage: []string{"hf task search --query <text> [--project <project-code>] [--status <status>]"}, Flags: authFlagHelp()},
"meeting/list": {Summary: "List meetings", Usage: []string{"hf meeting list [--project <project-code>] [--status <status>] [--order-by <created|due-date|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"meeting/get": {Summary: "Show a meeting by code", Usage: []string{"hf meeting get <meeting-code>"}, Flags: authFlagHelp()},
"meeting/create": {Summary: "Create a meeting", Usage: []string{"hf meeting create --project <project-code> --title <title> [--milestone <milestone-code>] [--desc <desc>] [--time <datetime>]"}, Flags: authFlagHelp()},
"meeting/update": {Summary: "Update a meeting", Usage: []string{"hf meeting update <meeting-code> [--title <title>] [--desc <desc>] [--status <status>] [--time <datetime>]"}, Flags: authFlagHelp()},
"meeting/attend": {Summary: "Attend a meeting", Usage: []string{"hf meeting attend <meeting-code>"}, Flags: authFlagHelp()},
"meeting/delete": {Summary: "Delete a meeting", Usage: []string{"hf meeting delete <meeting-code>"}, Flags: authFlagHelp()},
"support/list": {Summary: "List support tickets", Usage: []string{"hf support list [--taken-by <me|null|username>] [--status <status>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"support/get": {Summary: "Show a support ticket by code", Usage: []string{"hf support get <support-code>"}, Flags: authFlagHelp()},
"support/create": {Summary: "Create a support ticket", Usage: []string{"hf support create --title <title> [--project <project-code>] [--desc <desc>] [--priority <priority>]"}, Flags: authFlagHelp()},
"support/update": {Summary: "Update a support ticket", Usage: []string{"hf support update <support-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>]"}, Flags: authFlagHelp()},
"support/take": {Summary: "Assign a support ticket to the current user", Usage: []string{"hf support take <support-code>"}, Flags: authFlagHelp()},
"support/transition": {Summary: "Transition a support ticket to a new status", Usage: []string{"hf support transition <support-code> <status>"}, Flags: authFlagHelp()},
"support/delete": {Summary: "Delete a support ticket", Usage: []string{"hf support delete <support-code>"}, Flags: authFlagHelp()},
"proposal/list": {Summary: "List proposals", Usage: []string{"hf proposal list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"proposal/get": {Summary: "Show a proposal by code", Usage: []string{"hf proposal get <proposal-code>"}, Flags: authFlagHelp()},
"proposal/create": {Summary: "Create a proposal", Usage: []string{"hf proposal create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()},
"proposal/update": {Summary: "Update a proposal", Usage: []string{"hf proposal update <proposal-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/accept": {Summary: "Accept a proposal and generate story tasks", Usage: []string{"hf proposal accept <proposal-code> --milestone <milestone-code>"}, Flags: authFlagHelp(), Notes: []string{"Accept generates story/* tasks from all essentials under the proposal."}},
"proposal/reject": {Summary: "Reject a proposal", Usage: []string{"hf proposal reject <proposal-code> [--reason <reason>]"}, Flags: authFlagHelp()},
"proposal/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf proposal reopen <proposal-code>"}, Flags: authFlagHelp()},
"proposal/essential": {Summary: "Manage proposal essentials", Usage: []string{"hf proposal essential list --proposal <proposal-code>", "hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]", "hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]", "hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()},
"proposal/essential/list": {Summary: "List essentials for a proposal", Usage: []string{"hf proposal essential list --proposal <proposal-code>"}, Flags: authFlagHelp()},
"proposal/essential/create": {Summary: "Create an essential", Usage: []string{"hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/essential/update": {Summary: "Update an essential", Usage: []string{"hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/essential/delete": {Summary: "Delete an essential", Usage: []string{"hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()},
"calendar/schedule": {Summary: "Create a one-off calendar slot", Usage: []string{"hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]"}, Flags: authFlagHelp()},
"calendar/show": {Summary: "Show slots for a day", Usage: []string{"hf calendar show [--date <yyyy-mm-dd>]", "hf calendar show --json [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
"calendar/edit": {Summary: "Edit a calendar slot", Usage: []string{"hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--job <code>] [--scheduled-at <HH:mm>]"}, Flags: authFlagHelp(), Notes: []string{"For virtual plan slots, pass the virtual id like plan-12-2026-04-01."}},
"calendar/cancel": {Summary: "Cancel a calendar slot", Usage: []string{"hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
"calendar/date-list": {Summary: "List future dates with materialized slots", Usage: []string{"hf calendar date-list", "hf calendar date-list --json"}, Flags: authFlagHelp()},
"calendar/plan-schedule": {Summary: "Create a recurring schedule plan", Usage: []string{"hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]"}, Flags: authFlagHelp()},
"calendar/plan-list": {Summary: "List recurring schedule plans", Usage: []string{"hf calendar plan-list", "hf calendar plan-list --json"}, Flags: authFlagHelp()},
"calendar/plan-edit": {Summary: "Edit a recurring schedule plan", Usage: []string{"hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]"}, Flags: authFlagHelp()},
"calendar/plan-cancel": {Summary: "Cancel a recurring schedule plan", Usage: []string{"hf calendar plan-cancel <plan-id>"}, Flags: authFlagHelp()},
"comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()},
"comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()},
"worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
"worklog/list": {Summary: "List work logs by task or user", Usage: []string{"hf worklog list [--task <task-code>] [--user <username>]"}, Flags: authFlagHelp()},
"monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, Flags: authFlagHelp()},
"monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()},
"monitor/server/list": {Summary: "List monitor servers", Usage: []string{"hf monitor server list"}, Flags: authFlagHelp()},
"monitor/server/get": {Summary: "Show a monitor server by identifier", Usage: []string{"hf monitor server get <identifier>"}, Flags: authFlagHelp()},
"monitor/server/create": {Summary: "Create a monitor server", Usage: []string{"hf monitor server create --identifier <identifier> [--name <display-name>]"}, Flags: authFlagHelp()},
"monitor/server/delete": {Summary: "Delete a monitor server", Usage: []string{"hf monitor server delete <identifier>"}, Flags: authFlagHelp()},
"monitor/api-key": {Summary: "Manage monitor API keys", Usage: []string{"hf monitor api-key generate <identifier>", "hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()},
"monitor/api-key/generate": {Summary: "Generate a monitor API key", Usage: []string{"hf monitor api-key generate <identifier>"}, Flags: authFlagHelp()},
"monitor/api-key/revoke": {Summary: "Revoke a monitor API key", Usage: []string{"hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()},
}
if group == "" {
spec, ok := specs[cmd]
return spec, ok
}
spec, ok := specs[group+"/"+cmd]
return spec, ok
}

249
internal/help/surface.go Normal file
View File

@@ -0,0 +1,249 @@
package help
import (
"encoding/json"
"os"
"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/mode"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
)
type permissionState struct {
Known bool
Permissions map[string]struct{}
}
type permissionIntrospectionResponse struct {
Username string `json:"username"`
RoleName *string `json:"role_name"`
IsAdmin bool `json:"is_admin"`
Permissions []string `json:"permissions"`
}
func CommandSurface() []Group {
perms := detectPermissionState()
groups := []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",
SubCommands: []Command{
{Name: "create", Description: "Create a user account (uses account-manager token flow)", Permitted: true},
{Name: "list", Description: "List users", Permitted: has(perms, "user.manage")},
{Name: "get", Description: "Show a user by username", Permitted: has(perms, "user.manage")},
{Name: "update", Description: "Update a user", Permitted: has(perms, "user.manage")},
{Name: "activate", Description: "Activate a user", Permitted: has(perms, "user.manage")},
{Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")},
{Name: "delete", Description: "Delete a user", Permitted: has(perms, "user.manage")},
{Name: "reset-apikey", Description: "Reset a user's API key", Permitted: true},
},
},
{
Name: "role",
Description: "Manage roles and permissions",
SubCommands: []Command{
{Name: "list", Description: "List roles", Permitted: has(perms, "role.manage")},
{Name: "get", Description: "Show a role by name", Permitted: has(perms, "role.manage")},
{Name: "create", Description: "Create a role", Permitted: has(perms, "role.manage")},
{Name: "update", Description: "Update a role", Permitted: has(perms, "role.manage")},
{Name: "delete", Description: "Delete a role", Permitted: has(perms, "role.manage")},
{Name: "set-permissions", Description: "Replace role permissions", Permitted: has(perms, "role.manage")},
{Name: "add-permissions", Description: "Add permissions to a role", Permitted: has(perms, "role.manage")},
{Name: "remove-permissions", Description: "Remove permissions from a role", Permitted: has(perms, "role.manage")},
},
},
{
Name: "permission",
Description: "List permissions",
SubCommands: []Command{{Name: "list", Description: "List permissions", Permitted: has(perms, "role.manage")}},
},
{
Name: "project",
Description: "Manage projects",
SubCommands: []Command{
{Name: "list", Description: "List projects", Permitted: has(perms, "project.read")},
{Name: "get", Description: "Show a project by code", Permitted: has(perms, "project.read")},
{Name: "create", Description: "Create a project", Permitted: has(perms, "project.write")},
{Name: "update", Description: "Update a project", Permitted: has(perms, "project.write")},
{Name: "delete", Description: "Delete a project", Permitted: has(perms, "project.delete")},
{Name: "members", Description: "List project members", Permitted: has(perms, "project.read")},
{Name: "add-member", Description: "Add a project member", Permitted: has(perms, "project.manage_members")},
{Name: "remove-member", Description: "Remove a project member", Permitted: has(perms, "project.manage_members")},
},
},
{
Name: "milestone",
Description: "Manage milestones",
SubCommands: []Command{
{Name: "list", Description: "List milestones", Permitted: has(perms, "milestone.read")},
{Name: "get", Description: "Show a milestone by code", Permitted: has(perms, "milestone.read")},
{Name: "create", Description: "Create a milestone", Permitted: has(perms, "milestone.create")},
{Name: "update", Description: "Update a milestone", Permitted: has(perms, "milestone.write")},
{Name: "delete", Description: "Delete a milestone", Permitted: has(perms, "milestone.delete")},
{Name: "progress", Description: "Show milestone progress", Permitted: has(perms, "milestone.read")},
},
},
{
Name: "task",
Description: "Manage tasks",
SubCommands: []Command{
{Name: "list", Description: "List tasks", Permitted: has(perms, "task.read")},
{Name: "get", Description: "Show a task by code", Permitted: has(perms, "task.read")},
{Name: "create", Description: "Create a task", Permitted: has(perms, "task.create")},
{Name: "update", Description: "Update a task", Permitted: has(perms, "task.write")},
{Name: "transition", Description: "Transition a task to a new status", Permitted: has(perms, "task.write")},
{Name: "take", Description: "Assign a task to the current user", Permitted: has(perms, "task.write")},
{Name: "delete", Description: "Delete a task", Permitted: has(perms, "task.delete")},
{Name: "search", Description: "Search tasks", Permitted: has(perms, "task.read")},
},
},
{
Name: "meeting",
Description: "Manage meetings",
SubCommands: []Command{
{Name: "list", Description: "List meetings", Permitted: has(perms, "task.read")},
{Name: "get", Description: "Show a meeting by code", Permitted: has(perms, "task.read")},
{Name: "create", Description: "Create a meeting", Permitted: has(perms, "task.create")},
{Name: "update", Description: "Update a meeting", Permitted: has(perms, "task.write")},
{Name: "attend", Description: "Attend a meeting", Permitted: has(perms, "task.write")},
{Name: "delete", Description: "Delete a meeting", Permitted: has(perms, "task.delete")},
},
},
{
Name: "support",
Description: "Manage support tickets",
SubCommands: []Command{
{Name: "list", Description: "List support tickets", Permitted: has(perms, "task.read")},
{Name: "get", Description: "Show a support ticket by code", Permitted: has(perms, "task.read")},
{Name: "create", Description: "Create a support ticket", Permitted: has(perms, "task.create")},
{Name: "update", Description: "Update a support ticket", Permitted: has(perms, "task.write")},
{Name: "take", Description: "Assign a support ticket to the current user", Permitted: has(perms, "task.write")},
{Name: "transition", Description: "Transition a support ticket to a new status", Permitted: has(perms, "task.write")},
{Name: "delete", Description: "Delete a support ticket", Permitted: has(perms, "task.delete")},
},
},
{
Name: "proposal",
Description: "Manage proposals",
SubCommands: []Command{
{Name: "list", Description: "List proposals", Permitted: has(perms, "project.read")},
{Name: "get", Description: "Show a proposal by code", Permitted: has(perms, "project.read")},
{Name: "create", Description: "Create a proposal", Permitted: has(perms, "task.create")},
{Name: "update", Description: "Update a proposal", Permitted: has(perms, "task.write")},
{Name: "accept", Description: "Accept a proposal and generate story tasks", Permitted: has(perms, "propose.accept")},
{Name: "reject", Description: "Reject a proposal", Permitted: has(perms, "propose.reject")},
{Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")},
{Name: "essential", Description: "Manage proposal essentials", Permitted: has(perms, "task.create")},
},
},
{
Name: "calendar",
Description: "Manage calendar slots and plans",
SubCommands: []Command{
{Name: "schedule", Description: "Create a one-off slot", Permitted: has(perms, "task.create")},
{Name: "show", Description: "Show slots for a day", Permitted: has(perms, "task.read")},
{Name: "edit", Description: "Edit a slot", Permitted: has(perms, "task.write")},
{Name: "cancel", Description: "Cancel a slot", Permitted: has(perms, "task.write")},
{Name: "date-list", Description: "List dates with materialized slots", Permitted: has(perms, "task.read")},
{Name: "plan-schedule", Description: "Create a recurring plan", Permitted: has(perms, "task.create")},
{Name: "plan-list", Description: "List plans", Permitted: has(perms, "task.read")},
{Name: "plan-edit", Description: "Edit a plan", Permitted: has(perms, "task.write")},
{Name: "plan-cancel", Description: "Cancel a plan", Permitted: has(perms, "task.write")},
},
},
{
Name: "comment",
Description: "Manage task comments",
SubCommands: []Command{
{Name: "add", Description: "Add a comment to a task", Permitted: has(perms, "task.read")},
{Name: "list", Description: "List comments for a task", Permitted: has(perms, "task.read")},
},
},
{
Name: "worklog",
Description: "Manage work logs",
SubCommands: []Command{
{Name: "add", Description: "Add a work log entry", Permitted: has(perms, "task.read")},
{Name: "list", Description: "List work logs by task or user", Permitted: has(perms, "task.read")},
},
},
{
Name: "monitor",
Description: "Monitor servers and API keys",
SubCommands: []Command{
{Name: "overview", Description: "Show monitor overview", Permitted: has(perms, "monitor.read")},
{Name: "server", Description: "Manage monitor servers", Permitted: has(perms, "monitor.manage") || has(perms, "monitor.read")},
{Name: "api-key", Description: "Manage monitor API keys", Permitted: has(perms, "monitor.manage")},
},
},
}
for i := range groups {
groups[i].Permitted = groupPermitted(groups[i])
}
return groups
}
func detectPermissionState() permissionState {
if mode.IsPaddedCell() {
token, err := passmgr.GetToken()
if err != nil || token == "" {
return permissionState{Known: false, Permissions: map[string]struct{}{}}
}
return loadPermissionState(token)
}
return permissionState{Known: false, Permissions: map[string]struct{}{}}
}
func loadPermissionState(token string) permissionState {
cfg, err := config.Load()
if err != nil || cfg.BaseURL == "" {
return permissionState{Known: false, Permissions: map[string]struct{}{}}
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/auth/me/permissions")
if err != nil {
return permissionState{Known: false, Permissions: map[string]struct{}{}}
}
var resp permissionIntrospectionResponse
if err := json.Unmarshal(data, &resp); err != nil {
return permissionState{Known: false, Permissions: map[string]struct{}{}}
}
perms := make(map[string]struct{}, len(resp.Permissions))
for _, perm := range resp.Permissions {
perms[perm] = struct{}{}
}
return permissionState{Known: true, Permissions: perms}
}
func has(state permissionState, perm string) bool {
// Test/development bypass: HF_TEST_MODE=1 grants all permissions
if os.Getenv("HF_TEST_MODE") == "1" {
return true
}
if !state.Known {
return false
}
_, ok := state.Permissions[perm]
return ok
}
func groupPermitted(group Group) bool {
if len(group.SubCommands) == 0 {
return group.Permitted
}
for _, cmd := range group.SubCommands {
if cmd.Permitted {
return true
}
}
return false
}

View File

@@ -12,7 +12,7 @@ type RuntimeMode int
const ( const (
// ManualMode requires explicit --token / --acc-mgr-token flags. // ManualMode requires explicit --token / --acc-mgr-token flags.
ManualMode RuntimeMode = iota ManualMode RuntimeMode = iota
// PaddedCellMode resolves secrets via pass_mgr automatically. // PaddedCellMode resolves secrets via secret-mgr automatically.
PaddedCellMode PaddedCellMode
) )
@@ -21,11 +21,11 @@ var (
detectOnce sync.Once detectOnce sync.Once
) )
// Detect checks whether pass_mgr is available and returns the runtime mode. // Detect checks whether secret-mgr is available and returns the runtime mode.
// The result is cached after the first call. // The result is cached after the first call.
func Detect() RuntimeMode { func Detect() RuntimeMode {
detectOnce.Do(func() { detectOnce.Do(func() {
_, err := exec.LookPath("pass_mgr") _, err := exec.LookPath("secret-mgr")
if err == nil { if err == nil {
detectedMode = PaddedCellMode detectedMode = PaddedCellMode
} else { } else {

View File

@@ -1,4 +1,4 @@
// Package passmgr wraps calls to the pass_mgr binary for secret resolution. // Package passmgr wraps calls to the secret-mgr binary for secret resolution.
package passmgr package passmgr
import ( import (
@@ -7,49 +7,49 @@ import (
"strings" "strings"
) )
// GetSecret calls: pass_mgr get-secret [--public] --key <key> // GetSecret calls: secret-mgr get-secret [--public] --key <key>
func GetSecret(key string, public bool) (string, error) { func GetSecret(key string, public bool) (string, error) {
args := []string{"get-secret"} args := []string{"get-secret"}
if public { if public {
args = append(args, "--public") args = append(args, "--public")
} }
args = append(args, "--key", key) args = append(args, "--key", key)
out, err := exec.Command("pass_mgr", args...).Output() out, err := exec.Command("secret-mgr", args...).Output()
if err != nil { if err != nil {
return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err) return "", fmt.Errorf("secret-mgr get-secret --key %s failed: %w", key, err)
} }
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
// SetSecret calls: pass_mgr set [--public] --key <key> --secret <secret> // SetSecret calls: secret-mgr set [--public] --key <key> --secret <secret>
func SetSecret(key, secret string, public bool) error { func SetSecret(key, secret string, public bool) error {
args := []string{"set"} args := []string{"set"}
if public { if public {
args = append(args, "--public") args = append(args, "--public")
} }
args = append(args, "--key", key, "--secret", secret) args = append(args, "--key", key, "--secret", secret)
if err := exec.Command("pass_mgr", args...).Run(); err != nil { if err := exec.Command("secret-mgr", args...).Run(); err != nil {
return fmt.Errorf("pass_mgr set --key %s failed: %w", key, err) return fmt.Errorf("secret-mgr set --key %s failed: %w", key, err)
} }
return nil return nil
} }
// GeneratePassword calls: pass_mgr generate --key <key> --username <username> // GeneratePassword calls: secret-mgr generate --key <key> --username <username>
func GeneratePassword(key, username string) (string, error) { func GeneratePassword(key, username string) (string, error) {
args := []string{"generate", "--key", key, "--username", username} args := []string{"generate", "--key", key, "--username", username}
out, err := exec.Command("pass_mgr", args...).Output() out, err := exec.Command("secret-mgr", args...).Output()
if err != nil { if err != nil {
return "", fmt.Errorf("pass_mgr generate failed: %w", err) return "", fmt.Errorf("secret-mgr generate failed: %w", err)
} }
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
// GetToken retrieves the normal hf-token via pass_mgr. // GetToken retrieves the normal hf-token via secret-mgr.
func GetToken() (string, error) { func GetToken() (string, error) {
return GetSecret("hf-token", false) return GetSecret("hf-token", false)
} }
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr. // GetAccountManagerToken retrieves the public hf-acc-mgr-token via secret-mgr.
func GetAccountManagerToken() (string, error) { func GetAccountManagerToken() (string, error) {
return GetSecret("hf-acc-mgr-token", true) return GetSecret("hf-acc-mgr-token", true)
} }