Merge dev-2026-03-21 into main #1
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
dist/
|
||||
bin/
|
||||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
hf
|
||||
dist/
|
||||
coverage.out
|
||||
*.test
|
||||
*.exe
|
||||
*.out
|
||||
vendor/
|
||||
|
||||
35
Makefile
Normal file
35
Makefile
Normal 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
|
||||
232
README.md
232
README.md
@@ -1,6 +1,232 @@
|
||||
# HarborForge.Cli
|
||||
|
||||
CLI tools for HarborForge.
|
||||
`HarborForge.Cli` is the Go-based `hf` binary for HarborForge.
|
||||
|
||||
This repository is intentionally initialized with a minimal scaffold.
|
||||
Future commands and packaging can be added here.
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build -o ./bin/hf ./cmd/hf
|
||||
```
|
||||
|
||||
Or use the bundled Makefile:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
To set the version at build time:
|
||||
|
||||
```bash
|
||||
go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=1.0.0" -o ./bin/hf ./cmd/hf
|
||||
```
|
||||
|
||||
The Makefile versioned equivalent is:
|
||||
|
||||
```bash
|
||||
make build VERSION=1.0.0
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
./bin/hf --help
|
||||
|
||||
# Show only permitted commands
|
||||
./bin/hf --help-brief
|
||||
|
||||
# Show version
|
||||
./bin/hf version
|
||||
|
||||
# Check API health
|
||||
./bin/hf health
|
||||
|
||||
# Configure API URL
|
||||
./bin/hf config --url http://your-harborforge:8000
|
||||
|
||||
# View current config
|
||||
./bin/hf config
|
||||
|
||||
# JSON output
|
||||
./bin/hf version --json
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```text
|
||||
cmd/hf/ CLI entrypoint
|
||||
internal/
|
||||
client/ HTTP client wrapper for HarborForge API
|
||||
commands/ Command implementations
|
||||
config/ Config file resolution and management (.hf-config.json)
|
||||
help/ Help and help-brief renderer with detailed leaf help
|
||||
mode/ Runtime mode detection (padded-cell vs manual)
|
||||
output/ Output formatting (human-readable, JSON, tables)
|
||||
passmgr/ pass_mgr integration for secret resolution
|
||||
```
|
||||
|
||||
## Runtime Modes
|
||||
|
||||
- **Padded-cell mode**: When `pass_mgr` is available, auth tokens are resolved automatically. Manual `--token` flags are rejected.
|
||||
- **Manual mode**: When `pass_mgr` is not available, `--token` must be provided explicitly to authenticated commands.
|
||||
|
||||
## 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
|
||||
|
||||
### Implemented
|
||||
|
||||
**Foundation:**
|
||||
- Go module and binary entrypoint
|
||||
- Config file resolution relative to binary directory
|
||||
- Runtime mode detection (`pass_mgr` present/absent)
|
||||
- HTTP client wrapper (GET/POST/PUT/PATCH/DELETE)
|
||||
- Output formatting (human-readable + `--json`)
|
||||
- Auth token resolution (padded-cell + manual)
|
||||
|
||||
**Help system:**
|
||||
- Top-level and group/leaf help rendering (`--help` / `--help-brief`)
|
||||
- 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
|
||||
|
||||
985
cmd/hf/main.go
Normal file
985
cmd/hf/main.go
Normal file
@@ -0,0 +1,985 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/help"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
// Parse global flags first
|
||||
args = parseGlobalFlags(args)
|
||||
|
||||
if len(args) == 0 {
|
||||
fmt.Print(help.RenderTopHelp(commands.Version, help.CommandSurface()))
|
||||
return
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "--help", "-h":
|
||||
fmt.Print(help.RenderTopHelp(commands.Version, help.CommandSurface()))
|
||||
case "--help-brief":
|
||||
fmt.Print(help.RenderTopHelpBrief(commands.Version, help.CommandSurface()))
|
||||
case "version":
|
||||
handleLeafOrRun("version", args[1:], commands.RunVersion)
|
||||
case "health":
|
||||
handleLeafOrRun("health", args[1:], commands.RunHealth)
|
||||
case "config":
|
||||
handleConfig(args[1:])
|
||||
default:
|
||||
if group, ok := findGroup(args[0]); ok {
|
||||
handleGroup(group, args[1:])
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
|
||||
fmt.Fprintf(os.Stderr, "Run 'hf --help' for usage.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// parseGlobalFlags extracts --json from anywhere in the args and returns remaining args.
|
||||
func parseGlobalFlags(args []string) []string {
|
||||
var remaining []string
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--json":
|
||||
output.JSONMode = true
|
||||
default:
|
||||
remaining = append(remaining, a)
|
||||
}
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func handleLeafOrRun(name string, args []string, run func()) {
|
||||
if isLeafHelpFlagOnly(args) {
|
||||
if text, ok := help.RenderLeafHelp("", name); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
fmt.Printf("hf %s\n", name)
|
||||
return
|
||||
}
|
||||
if len(args) > 0 {
|
||||
output.Errorf("unknown arguments for %s: %v", name, args)
|
||||
}
|
||||
run()
|
||||
}
|
||||
|
||||
func handleConfig(args []string) {
|
||||
if isLeafHelpFlagOnly(args) || isHelpFlagOnly(args) {
|
||||
if text, ok := help.RenderLeafHelp("config", "show"); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
runConfigHelp()
|
||||
return
|
||||
}
|
||||
if len(args) == 0 {
|
||||
commands.RunConfigShow()
|
||||
return
|
||||
}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--url":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("usage: hf config --url <hf-url>")
|
||||
}
|
||||
commands.RunConfigURL(args[i+1])
|
||||
return
|
||||
case "--acc-mgr-token":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("usage: hf config --acc-mgr-token <token>")
|
||||
}
|
||||
commands.RunConfigAccMgrToken(args[i+1])
|
||||
return
|
||||
default:
|
||||
output.Errorf("unknown config flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runConfigHelp() {
|
||||
fmt.Println("hf config - View and manage CLI configuration")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage:")
|
||||
fmt.Println(" hf config Show current config")
|
||||
fmt.Println(" hf config --url <hf-url> Set HarborForge API URL")
|
||||
if !mode.IsPaddedCell() {
|
||||
fmt.Println(" hf config --acc-mgr-token <token> Set account-manager token")
|
||||
}
|
||||
}
|
||||
|
||||
func handleGroup(group help.Group, args []string) {
|
||||
if len(args) == 0 || isHelpFlagOnly(args) {
|
||||
fmt.Print(help.RenderGroupHelp(group.Name, group.SubCommands))
|
||||
return
|
||||
}
|
||||
if len(args) == 1 && args[0] == "--help-brief" {
|
||||
fmt.Print(help.RenderGroupHelpBrief(group.Name, group.SubCommands))
|
||||
return
|
||||
}
|
||||
|
||||
sub, ok := findSubCommand(group, args[0])
|
||||
if !ok {
|
||||
output.Errorf("unknown %s subcommand: %s", group.Name, args[0])
|
||||
}
|
||||
|
||||
if group.Name == "monitor" && (sub.Name == "server" || sub.Name == "api-key") {
|
||||
if len(args) == 1 {
|
||||
handleMonitorCommand(sub.Name, nil)
|
||||
return
|
||||
}
|
||||
if isHelpLikePath(args[1:]) || sub.Permitted {
|
||||
handleMonitorCommand(sub.Name, args[1:])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(args) > 1 && isLeafHelpFlagOnly(args[1:]) {
|
||||
if !sub.Permitted {
|
||||
fmt.Println(help.RenderNotPermitted(group.Name, sub.Name))
|
||||
return
|
||||
}
|
||||
if text, ok := help.RenderLeafHelp(group.Name, sub.Name); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
fmt.Printf("hf %s %s\n", group.Name, sub.Name)
|
||||
return
|
||||
}
|
||||
|
||||
if !sub.Permitted {
|
||||
fmt.Println(help.RenderNotPermitted(group.Name, sub.Name))
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch implemented commands
|
||||
remaining := args[1:]
|
||||
switch group.Name {
|
||||
case "user":
|
||||
handleUserCommand(sub.Name, remaining)
|
||||
return
|
||||
case "role":
|
||||
handleRoleCommand(sub.Name, remaining)
|
||||
return
|
||||
case "permission":
|
||||
handlePermissionCommand(sub.Name, remaining)
|
||||
return
|
||||
case "project":
|
||||
handleProjectCommand(sub.Name, remaining)
|
||||
return
|
||||
case "milestone":
|
||||
handleMilestoneCommand(sub.Name, remaining)
|
||||
return
|
||||
case "task":
|
||||
handleTaskCommand(sub.Name, remaining)
|
||||
return
|
||||
case "meeting":
|
||||
handleMeetingCommand(sub.Name, remaining)
|
||||
return
|
||||
case "support":
|
||||
handleSupportCommand(sub.Name, remaining)
|
||||
return
|
||||
case "propose":
|
||||
handleProposeCommand(sub.Name, remaining)
|
||||
return
|
||||
case "comment":
|
||||
handleCommentCommand(sub.Name, remaining)
|
||||
return
|
||||
case "worklog":
|
||||
handleWorklogCommand(sub.Name, remaining)
|
||||
return
|
||||
case "monitor":
|
||||
handleMonitorCommand(sub.Name, remaining)
|
||||
return
|
||||
}
|
||||
|
||||
output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name)
|
||||
}
|
||||
|
||||
func handleUserCommand(subCmd string, args []string) {
|
||||
// Extract --token and --acc-mgr-token flags from args
|
||||
tokenFlag := ""
|
||||
accMgrTokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
case "--acc-mgr-token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
accMgrTokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunUserList(tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user get <username>")
|
||||
}
|
||||
commands.RunUserGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
username, password, email, fullName := "", "", "", ""
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--user":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
username = filtered[i]
|
||||
}
|
||||
case "--pass":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
password = filtered[i]
|
||||
}
|
||||
case "--email":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
email = filtered[i]
|
||||
}
|
||||
case "--full-name":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
fullName = filtered[i]
|
||||
}
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", filtered[i])
|
||||
}
|
||||
}
|
||||
if username == "" {
|
||||
output.Error("usage: hf user create --user <username>")
|
||||
}
|
||||
commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")
|
||||
}
|
||||
commands.RunUserUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "activate":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user activate <username>")
|
||||
}
|
||||
commands.RunUserActivate(filtered[0], tokenFlag)
|
||||
case "deactivate":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user deactivate <username>")
|
||||
}
|
||||
commands.RunUserDeactivate(filtered[0], tokenFlag)
|
||||
case "delete":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user delete <username>")
|
||||
}
|
||||
commands.RunUserDelete(filtered[0], tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf user %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func isHelpFlagOnly(args []string) bool {
|
||||
return len(args) == 1 && (args[0] == "--help" || args[0] == "-h")
|
||||
}
|
||||
|
||||
func isLeafHelpFlagOnly(args []string) bool {
|
||||
return len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "--help-brief")
|
||||
}
|
||||
|
||||
func isHelpLikePath(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(args) == 1 {
|
||||
return args[0] == "--help" || args[0] == "-h" || args[0] == "--help-brief"
|
||||
}
|
||||
return isLeafHelpFlagOnly(args[len(args)-1:])
|
||||
}
|
||||
|
||||
func findGroup(name string) (help.Group, bool) {
|
||||
for _, group := range help.CommandSurface() {
|
||||
if group.Name == name {
|
||||
return group, true
|
||||
}
|
||||
}
|
||||
return help.Group{}, false
|
||||
}
|
||||
|
||||
func findSubCommand(group help.Group, name string) (help.Command, bool) {
|
||||
for _, cmd := range group.SubCommands {
|
||||
if cmd.Name == name {
|
||||
return cmd, true
|
||||
}
|
||||
}
|
||||
return help.Command{}, false
|
||||
}
|
||||
|
||||
func handleRoleCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunRoleList(tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf role get <role-name>")
|
||||
}
|
||||
commands.RunRoleGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
name, desc := "", ""
|
||||
global := false
|
||||
var remaining []string
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--name":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
name = filtered[i]
|
||||
}
|
||||
case "--desc":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
desc = filtered[i]
|
||||
}
|
||||
case "--global":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
global = filtered[i] == "true"
|
||||
}
|
||||
default:
|
||||
remaining = append(remaining, filtered[i])
|
||||
}
|
||||
}
|
||||
_ = remaining
|
||||
if name == "" {
|
||||
output.Error("usage: hf role create --name <role-name>")
|
||||
}
|
||||
commands.RunRoleCreate(name, desc, global, tokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf role update <role-name> [--desc ...]")
|
||||
}
|
||||
commands.RunRoleUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "delete":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf role delete <role-name>")
|
||||
}
|
||||
commands.RunRoleDelete(filtered[0], tokenFlag)
|
||||
case "set-permissions":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf role set-permissions <role-name> --permission <perm> [...]")
|
||||
}
|
||||
roleName := filtered[0]
|
||||
perms := extractPermissions(filtered[1:])
|
||||
commands.RunRoleSetPermissions(roleName, perms, tokenFlag)
|
||||
case "add-permissions":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf role add-permissions <role-name> --permission <perm> [...]")
|
||||
}
|
||||
roleName := filtered[0]
|
||||
perms := extractPermissions(filtered[1:])
|
||||
commands.RunRoleAddPermissions(roleName, perms, tokenFlag)
|
||||
case "remove-permissions":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf role remove-permissions <role-name> --permission <perm> [...]")
|
||||
}
|
||||
roleName := filtered[0]
|
||||
perms := extractPermissions(filtered[1:])
|
||||
commands.RunRoleRemovePermissions(roleName, perms, tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf role %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func extractPermissions(args []string) []string {
|
||||
var perms []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--permission" && i+1 < len(args) {
|
||||
i++
|
||||
perms = append(perms, args[i])
|
||||
}
|
||||
}
|
||||
return perms
|
||||
}
|
||||
|
||||
func handlePermissionCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--token" && i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunPermissionList(tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf permission %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleMilestoneCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunMilestoneList(filtered, tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf milestone get <milestone-code>")
|
||||
}
|
||||
commands.RunMilestoneGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
commands.RunMilestoneCreate(filtered, tokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf milestone update <milestone-code> [--title ...] [--desc ...] [--status ...] [--due ...]")
|
||||
}
|
||||
commands.RunMilestoneUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "delete":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf milestone delete <milestone-code>")
|
||||
}
|
||||
commands.RunMilestoneDelete(filtered[0], tokenFlag)
|
||||
case "progress":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf milestone progress <milestone-code>")
|
||||
}
|
||||
commands.RunMilestoneProgress(filtered[0], tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf milestone %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleTaskCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunTaskList(filtered, tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf task get <task-code>")
|
||||
}
|
||||
commands.RunTaskGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
commands.RunTaskCreate(filtered, tokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf task update <task-code> [--title ...] [--desc ...] [--status ...] [--priority ...] [--assignee ...]")
|
||||
}
|
||||
commands.RunTaskUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "transition":
|
||||
if len(filtered) < 2 {
|
||||
output.Error("usage: hf task transition <task-code> <status>")
|
||||
}
|
||||
commands.RunTaskTransition(filtered[0], filtered[1], tokenFlag)
|
||||
case "take":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf task take <task-code>")
|
||||
}
|
||||
commands.RunTaskTake(filtered[0], tokenFlag)
|
||||
case "delete":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf task delete <task-code>")
|
||||
}
|
||||
commands.RunTaskDelete(filtered[0], tokenFlag)
|
||||
case "search":
|
||||
commands.RunTaskSearch(filtered, tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf task %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleProjectCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunProjectList(filtered, tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf project get <project-code>")
|
||||
}
|
||||
commands.RunProjectGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
commands.RunProjectCreate(filtered, tokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf project update <project-code> [--name ...] [--desc ...] [--repo ...]")
|
||||
}
|
||||
commands.RunProjectUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "delete":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf project delete <project-code>")
|
||||
}
|
||||
commands.RunProjectDelete(filtered[0], tokenFlag)
|
||||
case "members":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf project members <project-code>")
|
||||
}
|
||||
commands.RunProjectMembers(filtered[0], tokenFlag)
|
||||
case "add-member":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf project add-member <project-code> --user <username> --role <role-name>")
|
||||
}
|
||||
commands.RunProjectAddMember(filtered[0], filtered[1:], tokenFlag)
|
||||
case "remove-member":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf project remove-member <project-code> --user <username>")
|
||||
}
|
||||
commands.RunProjectRemoveMember(filtered[0], filtered[1:], tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf project %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleMeetingCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunMeetingList(filtered, tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf meeting get <meeting-code>")
|
||||
}
|
||||
commands.RunMeetingGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
commands.RunMeetingCreate(filtered, tokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf meeting update <meeting-code> [--title ...] [--desc ...] [--status ...] [--time ...]")
|
||||
}
|
||||
commands.RunMeetingUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "attend":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf meeting attend <meeting-code>")
|
||||
}
|
||||
commands.RunMeetingAttend(filtered[0], tokenFlag)
|
||||
case "delete":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf meeting delete <meeting-code>")
|
||||
}
|
||||
commands.RunMeetingDelete(filtered[0], tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf meeting %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSupportCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunSupportList(filtered, tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf support get <support-code>")
|
||||
}
|
||||
commands.RunSupportGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
commands.RunSupportCreate(filtered, tokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf support update <support-code> [--title ...] [--desc ...] [--status ...] [--priority ...]")
|
||||
}
|
||||
commands.RunSupportUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "take":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf support take <support-code>")
|
||||
}
|
||||
commands.RunSupportTake(filtered[0], tokenFlag)
|
||||
case "transition":
|
||||
if len(filtered) < 2 {
|
||||
output.Error("usage: hf support transition <support-code> <status>")
|
||||
}
|
||||
commands.RunSupportTransition(filtered[0], filtered[1], tokenFlag)
|
||||
case "delete":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf support delete <support-code>")
|
||||
}
|
||||
commands.RunSupportDelete(filtered[0], tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf support %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleProposeCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunProposeList(filtered, tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf propose get <propose-code>")
|
||||
}
|
||||
commands.RunProposeGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
commands.RunProposeCreate(filtered, tokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf propose update <propose-code> [--title ...] [--desc ...]")
|
||||
}
|
||||
commands.RunProposeUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||
case "accept":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>")
|
||||
}
|
||||
commands.RunProposeAccept(filtered[0], filtered[1:], tokenFlag)
|
||||
case "reject":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf propose reject <propose-code> [--reason <reason>]")
|
||||
}
|
||||
commands.RunProposeReject(filtered[0], filtered[1:], tokenFlag)
|
||||
case "reopen":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf propose reopen <propose-code>")
|
||||
}
|
||||
commands.RunProposeReopen(filtered[0], tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf propose %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleCommentCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "add":
|
||||
taskCode, content := "", ""
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--task":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
taskCode = filtered[i]
|
||||
}
|
||||
case "--content":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
content = filtered[i]
|
||||
}
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", filtered[i])
|
||||
}
|
||||
}
|
||||
commands.RunCommentAdd(taskCode, content, tokenFlag)
|
||||
case "list":
|
||||
taskCode := ""
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--task":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
taskCode = filtered[i]
|
||||
}
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", filtered[i])
|
||||
}
|
||||
}
|
||||
commands.RunCommentList(taskCode, tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf comment %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleWorklogCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "add":
|
||||
taskCode, desc, date := "", "", ""
|
||||
hours := 0.0
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--task":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
taskCode = filtered[i]
|
||||
}
|
||||
case "--hours":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
if _, err := fmt.Sscanf(filtered[i], "%f", &hours); err != nil {
|
||||
output.Error("--hours requires a numeric value")
|
||||
}
|
||||
}
|
||||
case "--desc":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
desc = filtered[i]
|
||||
}
|
||||
case "--date":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
date = filtered[i]
|
||||
}
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", filtered[i])
|
||||
}
|
||||
}
|
||||
commands.RunWorklogAdd(taskCode, hours, desc, date, tokenFlag)
|
||||
case "list":
|
||||
taskCode, username := "", ""
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--task":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
taskCode = filtered[i]
|
||||
}
|
||||
case "--user":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
username = filtered[i]
|
||||
}
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", filtered[i])
|
||||
}
|
||||
}
|
||||
commands.RunWorklogList(taskCode, username, tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf worklog %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleMonitorCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "overview":
|
||||
commands.RunMonitorOverview(tokenFlag)
|
||||
case "server":
|
||||
handleMonitorServerCommand(filtered, tokenFlag)
|
||||
case "api-key":
|
||||
handleMonitorAPIKeyCommand(filtered, tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf monitor %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleMonitorServerCommand(args []string, tokenFlag string) {
|
||||
serverCommands := []help.Command{
|
||||
{Name: "list", Description: "List monitor servers", Permitted: true},
|
||||
{Name: "get", Description: "Show a monitor server by identifier", Permitted: true},
|
||||
{Name: "create", Description: "Create a monitor server", Permitted: true},
|
||||
{Name: "delete", Description: "Delete a monitor server", Permitted: true},
|
||||
}
|
||||
|
||||
if len(args) == 0 || isHelpFlagOnly(args) {
|
||||
fmt.Print(help.RenderGroupHelp("monitor server", serverCommands))
|
||||
return
|
||||
}
|
||||
if len(args) == 1 && args[0] == "--help-brief" {
|
||||
fmt.Print(help.RenderGroupHelpBrief("monitor server", serverCommands))
|
||||
return
|
||||
}
|
||||
|
||||
subCmd := args[0]
|
||||
remaining := args[1:]
|
||||
if isLeafHelpFlagOnly(remaining) {
|
||||
if text, ok := help.RenderLeafHelp("monitor/server", subCmd); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
fmt.Printf("hf monitor server %s\n", subCmd)
|
||||
return
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunMonitorServerList(tokenFlag)
|
||||
case "get":
|
||||
if len(remaining) < 1 {
|
||||
output.Error("usage: hf monitor server get <identifier>")
|
||||
}
|
||||
commands.RunMonitorServerGet(remaining[0], tokenFlag)
|
||||
case "create":
|
||||
commands.RunMonitorServerCreate(remaining, tokenFlag)
|
||||
case "delete":
|
||||
if len(remaining) < 1 {
|
||||
output.Error("usage: hf monitor server delete <identifier>")
|
||||
}
|
||||
commands.RunMonitorServerDelete(remaining[0], tokenFlag)
|
||||
default:
|
||||
output.Errorf("unknown monitor server subcommand: %s", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleMonitorAPIKeyCommand(args []string, tokenFlag string) {
|
||||
apiKeyCommands := []help.Command{
|
||||
{Name: "generate", Description: "Generate a monitor API key", Permitted: true},
|
||||
{Name: "revoke", Description: "Revoke a monitor API key", Permitted: true},
|
||||
}
|
||||
|
||||
if len(args) == 0 || isHelpFlagOnly(args) {
|
||||
fmt.Print(help.RenderGroupHelp("monitor api-key", apiKeyCommands))
|
||||
return
|
||||
}
|
||||
if len(args) == 1 && args[0] == "--help-brief" {
|
||||
fmt.Print(help.RenderGroupHelpBrief("monitor api-key", apiKeyCommands))
|
||||
return
|
||||
}
|
||||
|
||||
subCmd := args[0]
|
||||
remaining := args[1:]
|
||||
if isLeafHelpFlagOnly(remaining) {
|
||||
if text, ok := help.RenderLeafHelp("monitor/api-key", subCmd); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
fmt.Printf("hf monitor api-key %s\n", subCmd)
|
||||
return
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "generate":
|
||||
if len(remaining) < 1 {
|
||||
output.Error("usage: hf monitor api-key generate <identifier>")
|
||||
}
|
||||
commands.RunMonitorAPIKeyGenerate(remaining[0], tokenFlag)
|
||||
case "revoke":
|
||||
if len(remaining) < 1 {
|
||||
output.Error("usage: hf monitor api-key revoke <identifier>")
|
||||
}
|
||||
commands.RunMonitorAPIKeyRevoke(remaining[0], tokenFlag)
|
||||
default:
|
||||
output.Errorf("unknown monitor api-key subcommand: %s", subCmd)
|
||||
}
|
||||
}
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module git.hangman-lab.top/zhi/HarborForge.Cli
|
||||
|
||||
go 1.22
|
||||
105
internal/client/client.go
Normal file
105
internal/client/client.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Package client provides the HTTP client wrapper for HarborForge API calls.
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a simple HarborForge API client.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a Client with the given base URL and optional auth token.
|
||||
func New(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RequestError represents a non-2xx HTTP response.
|
||||
type RequestError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *RequestError) Error() string {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// Do executes an HTTP request and returns the response body bytes.
|
||||
func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
|
||||
url := c.BaseURL + path
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, &RequestError{StatusCode: resp.StatusCode, Body: string(data)}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Get performs a GET request.
|
||||
func (c *Client) Get(path string) ([]byte, error) {
|
||||
return c.Do("GET", path, nil)
|
||||
}
|
||||
|
||||
// Post performs a POST request with a JSON body.
|
||||
func (c *Client) Post(path string, body io.Reader) ([]byte, error) {
|
||||
return c.Do("POST", path, body)
|
||||
}
|
||||
|
||||
// Put performs a PUT request with a JSON body.
|
||||
func (c *Client) Put(path string, body io.Reader) ([]byte, error) {
|
||||
return c.Do("PUT", path, body)
|
||||
}
|
||||
|
||||
// Patch performs a PATCH request with a JSON body.
|
||||
func (c *Client) Patch(path string, body io.Reader) ([]byte, error) {
|
||||
return c.Do("PATCH", path, body)
|
||||
}
|
||||
|
||||
// Delete performs a DELETE request.
|
||||
func (c *Client) Delete(path string) ([]byte, error) {
|
||||
return c.Do("DELETE", path, nil)
|
||||
}
|
||||
|
||||
// Health checks the API health endpoint and returns the response.
|
||||
func (c *Client) Health() (map[string]interface{}, error) {
|
||||
data, err := c.Get("/api/health/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("cannot parse health response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
36
internal/commands/auth.go
Normal file
36
internal/commands/auth.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
|
||||
)
|
||||
|
||||
// ResolveToken resolves the auth token based on runtime mode.
|
||||
// In padded-cell mode, tokenFlag must be empty (enforced).
|
||||
// In manual mode, tokenFlag is required.
|
||||
func ResolveToken(tokenFlag string) string {
|
||||
if mode.IsPaddedCell() {
|
||||
if tokenFlag != "" {
|
||||
output.Error("padded-cell installed, --token flag disabled, use command directly")
|
||||
}
|
||||
tok, err := passmgr.GetToken()
|
||||
if err != nil {
|
||||
output.Errorf("cannot resolve token: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
// manual mode
|
||||
if tokenFlag == "" {
|
||||
output.Error("--token <token> required or execute this with pcexec")
|
||||
}
|
||||
return tokenFlag
|
||||
}
|
||||
|
||||
// RejectTokenInPaddedCell checks if --token was passed in padded-cell mode
|
||||
// and terminates with the standard error message.
|
||||
func RejectTokenInPaddedCell(tokenFlag string) {
|
||||
if mode.IsPaddedCell() && tokenFlag != "" {
|
||||
output.Error("padded-cell installed, --token flag disabled, use command directly")
|
||||
}
|
||||
}
|
||||
136
internal/commands/comment.go
Normal file
136
internal/commands/comment.go
Normal 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
|
||||
}
|
||||
53
internal/commands/config.go
Normal file
53
internal/commands/config.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
|
||||
)
|
||||
|
||||
// RunConfigURL sets the base URL in the config file.
|
||||
func RunConfigURL(url string) {
|
||||
if url == "" {
|
||||
output.Error("usage: hf config --url <hf-url>")
|
||||
}
|
||||
if err := config.UpdateURL(url); err != nil {
|
||||
output.Errorf("failed to update config: %v", err)
|
||||
}
|
||||
fmt.Printf("base-url set to %s\n", url)
|
||||
}
|
||||
|
||||
// RunConfigAccMgrToken stores the account-manager token via pass_mgr.
|
||||
func RunConfigAccMgrToken(token string) {
|
||||
if token == "" {
|
||||
output.Error("usage: hf config --acc-mgr-token <token>")
|
||||
}
|
||||
if !mode.IsPaddedCell() {
|
||||
output.Error("--acc-mgr-token can only be set with padded-cell plugin")
|
||||
}
|
||||
if err := passmgr.SetAccountManagerToken(token); err != nil {
|
||||
output.Errorf("failed to store acc-mgr-token: %v", err)
|
||||
}
|
||||
fmt.Println("account-manager token stored successfully")
|
||||
}
|
||||
|
||||
// RunConfigShow displays the current config.
|
||||
func RunConfigShow() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
if output.JSONMode {
|
||||
output.PrintJSON(cfg)
|
||||
} else {
|
||||
p, _ := config.ConfigPath()
|
||||
output.PrintKeyValue(
|
||||
"base-url", cfg.BaseURL,
|
||||
"config-file", p,
|
||||
"mode", mode.Detect().String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
32
internal/commands/health.go
Normal file
32
internal/commands/health.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||
)
|
||||
|
||||
// RunHealth checks the HarborForge API health endpoint.
|
||||
func RunHealth() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, "")
|
||||
result, err := c.Health()
|
||||
if err != nil {
|
||||
output.Errorf("health check failed: %v", err)
|
||||
}
|
||||
if output.JSONMode {
|
||||
output.PrintJSON(result)
|
||||
} else {
|
||||
status, _ := result["status"].(string)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
fmt.Printf("HarborForge API: %s\n", status)
|
||||
fmt.Printf("URL: %s\n", cfg.BaseURL)
|
||||
}
|
||||
}
|
||||
342
internal/commands/meeting.go
Normal file
342
internal/commands/meeting.go
Normal 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", 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)
|
||||
}
|
||||
342
internal/commands/milestone.go
Normal file
342
internal/commands/milestone.go
Normal 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", 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),
|
||||
)
|
||||
}
|
||||
279
internal/commands/monitor.go
Normal file
279
internal/commands/monitor.go
Normal file
@@ -0,0 +1,279 @@
|
||||
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 {
|
||||
TotalServers int `json:"total_servers"`
|
||||
OnlineServers int `json:"online_servers"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Identifier string `json:"identifier"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// 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/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)
|
||||
}
|
||||
|
||||
output.PrintKeyValue(
|
||||
"total-servers", fmt.Sprintf("%d", o.TotalServers),
|
||||
"online-servers", fmt.Sprintf("%d", o.OnlineServers),
|
||||
)
|
||||
}
|
||||
|
||||
// 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/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)
|
||||
data, err := c.Get("/monitor/servers/" + identifier)
|
||||
if err != nil {
|
||||
output.Errorf("failed to get 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
|
||||
}
|
||||
|
||||
var s monitorServerResponse
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
output.Errorf("cannot parse server: %v", err)
|
||||
}
|
||||
|
||||
name := ""
|
||||
if s.DisplayName != nil {
|
||||
name = *s.DisplayName
|
||||
}
|
||||
lastSeen := ""
|
||||
if s.LastSeen != nil {
|
||||
lastSeen = *s.LastSeen
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"identifier", s.Identifier,
|
||||
"name", name,
|
||||
"status", s.Status,
|
||||
"last-seen", lastSeen,
|
||||
"created", s.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/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)
|
||||
_, err = c.Delete("/monitor/servers/" + identifier)
|
||||
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)
|
||||
data, err := c.Post("/monitor/servers/"+identifier+"/api-key", 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(
|
||||
"identifier", k.Identifier,
|
||||
"api-key", k.APIKey,
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
_, err = c.Delete("/monitor/servers/" + identifier + "/api-key")
|
||||
if err != nil {
|
||||
output.Errorf("failed to revoke API key: %v", err)
|
||||
}
|
||||
fmt.Printf("API key revoked for: %s\n", identifier)
|
||||
}
|
||||
413
internal/commands/project.go
Normal file
413
internal/commands/project.go
Normal 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
|
||||
}
|
||||
365
internal/commands/propose.go
Normal file
365
internal/commands/propose.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"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"`
|
||||
}
|
||||
|
||||
// RunProposeList implements `hf propose list --project <project-code>`.
|
||||
func RunProposeList(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", 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 := "/proposes"
|
||||
if query != "" {
|
||||
path += "?" + query
|
||||
}
|
||||
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("/proposes/" + 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{}{
|
||||
"project_code": project,
|
||||
"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("/proposes", 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("/proposes/"+proposeCode, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to update proposal: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("proposal updated: %s\n", proposeCode)
|
||||
}
|
||||
|
||||
// RunProposeAccept implements `hf propose accept <propose-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 propose accept <propose-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)
|
||||
_, err = c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to accept proposal: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("proposal accepted: %s\n", proposeCode)
|
||||
}
|
||||
|
||||
// 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("/proposes/"+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("/proposes/"+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
477
internal/commands/role.go
Normal 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)
|
||||
}
|
||||
349
internal/commands/support.go
Normal file
349
internal/commands/support.go
Normal 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)
|
||||
}
|
||||
478
internal/commands/task.go
Normal file
478
internal/commands/task.go
Normal file
@@ -0,0 +1,478 @@
|
||||
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", args[i])
|
||||
case "--milestone":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--milestone requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "milestone", 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>")
|
||||
}
|
||||
|
||||
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", 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)
|
||||
}
|
||||
331
internal/commands/user.go
Normal file
331
internal/commands/user.go
Normal file
@@ -0,0 +1,331 @@
|
||||
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/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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// RunUserCreate implements `hf user create`.
|
||||
func RunUserCreate(username, password, email, fullName, 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 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.New(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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
19
internal/commands/version.go
Normal file
19
internal/commands/version.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||
)
|
||||
|
||||
// Version is the CLI version string, set at build time via ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
// RunVersion prints the CLI version.
|
||||
func RunVersion() {
|
||||
if output.JSONMode {
|
||||
output.PrintJSON(map[string]string{"version": Version})
|
||||
} else {
|
||||
fmt.Printf("hf %s\n", Version)
|
||||
}
|
||||
}
|
||||
124
internal/commands/worklog.go
Normal file
124
internal/commands/worklog.go
Normal 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)
|
||||
}
|
||||
92
internal/config/config.go
Normal file
92
internal/config/config.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package config resolves and manages .hf-config.json relative to the binary directory.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const configFileName = ".hf-config.json"
|
||||
|
||||
// Config holds the CLI configuration.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base-url"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with sensible defaults.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
BaseURL: "http://127.0.0.1:8000",
|
||||
}
|
||||
}
|
||||
|
||||
// BinaryDir returns the directory containing the running binary.
|
||||
func BinaryDir() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve binary path: %w", err)
|
||||
}
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve binary symlinks: %w", err)
|
||||
}
|
||||
return filepath.Dir(exe), nil
|
||||
}
|
||||
|
||||
// ConfigPath returns the full path to the config file.
|
||||
func ConfigPath() (string, error) {
|
||||
dir, err := BinaryDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, configFileName), nil
|
||||
}
|
||||
|
||||
// Load reads the config file. If the file does not exist, returns DefaultConfig.
|
||||
func Load() (Config, error) {
|
||||
p, err := ConfigPath()
|
||||
if err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
return DefaultConfig(), fmt.Errorf("cannot read config: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return DefaultConfig(), fmt.Errorf("invalid config JSON: %w", err)
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = DefaultConfig().BaseURL
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the config to the config file path.
|
||||
func Save(cfg Config) error {
|
||||
p, err := ConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal config: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(p, data, 0644); err != nil {
|
||||
return fmt.Errorf("cannot write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateURL loads the existing config, updates the base-url, and saves.
|
||||
func UpdateURL(url string) error {
|
||||
cfg, _ := Load()
|
||||
cfg.BaseURL = url
|
||||
return Save(cfg)
|
||||
}
|
||||
113
internal/help/help.go
Normal file
113
internal/help/help.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package help renders help and help-brief output for the hf CLI.
|
||||
package help
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Command describes a CLI command or group.
|
||||
type Command struct {
|
||||
Name string
|
||||
Description string
|
||||
Permitted bool // whether the current user can execute this
|
||||
SubCommands []Command
|
||||
}
|
||||
|
||||
// Group describes a top-level command group.
|
||||
type Group struct {
|
||||
Name string
|
||||
Description string
|
||||
Permitted bool
|
||||
SubCommands []Command
|
||||
}
|
||||
|
||||
// RenderTopHelp renders `hf --help` output showing all groups.
|
||||
func RenderTopHelp(version string, groups []Group) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("hf - HarborForge CLI")
|
||||
if version != "" {
|
||||
b.WriteString(" (" + version + ")")
|
||||
}
|
||||
b.WriteString("\n\nUsage: hf <command> [flags]\n\n")
|
||||
b.WriteString("Commands:\n")
|
||||
maxLen := 0
|
||||
for _, g := range groups {
|
||||
if len(g.Name) > maxLen {
|
||||
maxLen = len(g.Name)
|
||||
}
|
||||
}
|
||||
for _, g := range groups {
|
||||
b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, g.Name, g.Description))
|
||||
}
|
||||
b.WriteString("\nGlobal flags:\n")
|
||||
b.WriteString(" --help Show help\n")
|
||||
b.WriteString(" --help-brief Show only permitted commands\n")
|
||||
b.WriteString(" --json Output in JSON format\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderTopHelpBrief renders `hf --help-brief` showing only permitted groups.
|
||||
func RenderTopHelpBrief(version string, groups []Group) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("hf - HarborForge CLI")
|
||||
if version != "" {
|
||||
b.WriteString(" (" + version + ")")
|
||||
}
|
||||
b.WriteString("\n\nPermitted commands:\n")
|
||||
maxLen := 0
|
||||
for _, g := range groups {
|
||||
if g.Permitted && len(g.Name) > maxLen {
|
||||
maxLen = len(g.Name)
|
||||
}
|
||||
}
|
||||
for _, g := range groups {
|
||||
if g.Permitted {
|
||||
b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, g.Name, g.Description))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderGroupHelp renders `hf <group> --help` showing all subcommands.
|
||||
func RenderGroupHelp(groupName string, cmds []Command) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("hf %s - subcommands:\n\n", groupName))
|
||||
maxLen := 0
|
||||
for _, c := range cmds {
|
||||
if len(c.Name) > maxLen {
|
||||
maxLen = len(c.Name)
|
||||
}
|
||||
}
|
||||
for _, c := range cmds {
|
||||
desc := c.Description
|
||||
if !c.Permitted {
|
||||
desc = "(not permitted)"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, c.Name, desc))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderGroupHelpBrief renders `hf <group> --help-brief` showing only permitted subcommands.
|
||||
func RenderGroupHelpBrief(groupName string, cmds []Command) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("hf %s - permitted subcommands:\n\n", groupName))
|
||||
maxLen := 0
|
||||
for _, c := range cmds {
|
||||
if c.Permitted && len(c.Name) > maxLen {
|
||||
maxLen = len(c.Name)
|
||||
}
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if c.Permitted {
|
||||
b.WriteString(fmt.Sprintf(" %-*s %s\n", maxLen, c.Name, c.Description))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderNotPermitted renders the short message for an unpermitted leaf command.
|
||||
func RenderNotPermitted(group, cmd string) string {
|
||||
return fmt.Sprintf("hf %s %s: not permitted", group, cmd)
|
||||
}
|
||||
189
internal/help/leaf.go
Normal file
189
internal/help/leaf.go
Normal file
@@ -0,0 +1,189 @@
|
||||
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 pass_mgr",
|
||||
Usage: []string{"hf config --acc-mgr-token <token>"},
|
||||
Notes: []string{"Only available in padded-cell mode with pass_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 pass_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()},
|
||||
"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()},
|
||||
"propose/list": {Summary: "List proposals", Usage: []string{"hf propose list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
|
||||
"propose/get": {Summary: "Show a proposal by code", Usage: []string{"hf propose get <propose-code>"}, Flags: authFlagHelp()},
|
||||
"propose/create": {Summary: "Create a proposal", Usage: []string{"hf propose create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()},
|
||||
"propose/update": {Summary: "Update a proposal", Usage: []string{"hf propose update <propose-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()},
|
||||
"propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()},
|
||||
"propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()},
|
||||
"propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, 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
|
||||
}
|
||||
227
internal/help/surface.go
Normal file
227
internal/help/surface.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package help
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"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: "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: "propose",
|
||||
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", 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: "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 {
|
||||
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
|
||||
}
|
||||
53
internal/mode/mode.go
Normal file
53
internal/mode/mode.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Package mode detects whether the CLI runs in padded-cell mode or manual mode.
|
||||
package mode
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RuntimeMode represents the CLI operating mode.
|
||||
type RuntimeMode int
|
||||
|
||||
const (
|
||||
// ManualMode requires explicit --token / --acc-mgr-token flags.
|
||||
ManualMode RuntimeMode = iota
|
||||
// PaddedCellMode resolves secrets via pass_mgr automatically.
|
||||
PaddedCellMode
|
||||
)
|
||||
|
||||
var (
|
||||
detectedMode RuntimeMode
|
||||
detectOnce sync.Once
|
||||
)
|
||||
|
||||
// Detect checks whether pass_mgr is available and returns the runtime mode.
|
||||
// The result is cached after the first call.
|
||||
func Detect() RuntimeMode {
|
||||
detectOnce.Do(func() {
|
||||
_, err := exec.LookPath("pass_mgr")
|
||||
if err == nil {
|
||||
detectedMode = PaddedCellMode
|
||||
} else {
|
||||
detectedMode = ManualMode
|
||||
}
|
||||
})
|
||||
return detectedMode
|
||||
}
|
||||
|
||||
// IsPaddedCell is a convenience helper.
|
||||
func IsPaddedCell() bool {
|
||||
return Detect() == PaddedCellMode
|
||||
}
|
||||
|
||||
// String returns a human-readable mode name.
|
||||
func (m RuntimeMode) String() string {
|
||||
switch m {
|
||||
case PaddedCellMode:
|
||||
return "padded-cell"
|
||||
case ManualMode:
|
||||
return "manual"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
93
internal/output/output.go
Normal file
93
internal/output/output.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Package output handles human-readable and JSON output formatting.
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JSONMode is true when --json flag is provided.
|
||||
var JSONMode bool
|
||||
|
||||
// PrintJSON prints data as indented JSON to stdout.
|
||||
func PrintJSON(v interface{}) {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot marshal JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
// PrintKeyValue prints a set of key-value pairs in human-readable format.
|
||||
func PrintKeyValue(pairs ...string) {
|
||||
if len(pairs)%2 != 0 {
|
||||
pairs = append(pairs, "")
|
||||
}
|
||||
maxKeyLen := 0
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
if len(pairs[i]) > maxKeyLen {
|
||||
maxKeyLen = len(pairs[i])
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
fmt.Printf("%-*s %s\n", maxKeyLen, pairs[i]+":", pairs[i+1])
|
||||
}
|
||||
}
|
||||
|
||||
// PrintTable prints a simple table with headers and rows.
|
||||
func PrintTable(headers []string, rows [][]string) {
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("(no results)")
|
||||
return
|
||||
}
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i < len(widths) && len(cell) > widths[i] {
|
||||
widths[i] = len(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
// header
|
||||
parts := make([]string, len(headers))
|
||||
for i, h := range headers {
|
||||
parts[i] = fmt.Sprintf("%-*s", widths[i], h)
|
||||
}
|
||||
fmt.Println(strings.Join(parts, " "))
|
||||
// separator
|
||||
seps := make([]string, len(headers))
|
||||
for i := range headers {
|
||||
seps[i] = strings.Repeat("-", widths[i])
|
||||
}
|
||||
fmt.Println(strings.Join(seps, " "))
|
||||
// rows
|
||||
for _, row := range rows {
|
||||
parts := make([]string, len(headers))
|
||||
for i := range headers {
|
||||
cell := ""
|
||||
if i < len(row) {
|
||||
cell = row[i]
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%-*s", widths[i], cell)
|
||||
}
|
||||
fmt.Println(strings.Join(parts, " "))
|
||||
}
|
||||
}
|
||||
|
||||
// Error prints an error message to stderr and exits.
|
||||
func Error(msg string) {
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Errorf prints a formatted error message to stderr and exits.
|
||||
func Errorf(format string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
60
internal/passmgr/passmgr.go
Normal file
60
internal/passmgr/passmgr.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Package passmgr wraps calls to the pass_mgr binary for secret resolution.
|
||||
package passmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetSecret calls: pass_mgr get-secret [--public] --key <key>
|
||||
func GetSecret(key string, public bool) (string, error) {
|
||||
args := []string{"get-secret"}
|
||||
if public {
|
||||
args = append(args, "--public")
|
||||
}
|
||||
args = append(args, "--key", key)
|
||||
out, err := exec.Command("pass_mgr", args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// SetSecret calls: pass_mgr set [--public] --key <key> --secret <secret>
|
||||
func SetSecret(key, secret string, public bool) error {
|
||||
args := []string{"set"}
|
||||
if public {
|
||||
args = append(args, "--public")
|
||||
}
|
||||
args = append(args, "--key", key, "--secret", secret)
|
||||
if err := exec.Command("pass_mgr", args...).Run(); err != nil {
|
||||
return fmt.Errorf("pass_mgr set --key %s failed: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePassword calls: pass_mgr generate --key <key> --username <username>
|
||||
func GeneratePassword(key, username string) (string, error) {
|
||||
args := []string{"generate", "--key", key, "--username", username}
|
||||
out, err := exec.Command("pass_mgr", args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pass_mgr generate failed: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// GetToken retrieves the normal hf-token via pass_mgr.
|
||||
func GetToken() (string, error) {
|
||||
return GetSecret("hf-token", false)
|
||||
}
|
||||
|
||||
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr.
|
||||
func GetAccountManagerToken() (string, error) {
|
||||
return GetSecret("hf-acc-mgr-token", true)
|
||||
}
|
||||
|
||||
// SetAccountManagerToken stores the acc-mgr-token as a public secret.
|
||||
func SetAccountManagerToken(token string) error {
|
||||
return SetSecret("hf-acc-mgr-token", token, true)
|
||||
}
|
||||
Reference in New Issue
Block a user