Compare commits
22 Commits
34f52cb9e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ebc52cca | |||
| de0ea39b2a | |||
| 6dae490257 | |||
| 53b5b88fc2 | |||
| 6252039fc5 | |||
| cd22642472 | |||
| 5ac90408f3 | |||
| ad0e123666 | |||
| e2177521e0 | |||
| 84150df4d5 | |||
| b287b1ff17 | |||
| 0280f2c327 | |||
| 0fe62ed430 | |||
| 97af3d3177 | |||
| fbfa866c9d | |||
| b3063733a9 | |||
| 49b54beace | |||
| cc649a7fe2 | |||
| 9b3edc0ede | |||
| 1e8437d0b1 | |||
| ebad3cd0d3 | |||
| eaf4f215b5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
bin/
|
||||
hf
|
||||
dist/
|
||||
coverage.out
|
||||
*.test
|
||||
|
||||
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
|
||||
177
README.md
177
README.md
@@ -8,12 +8,24 @@
|
||||
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
|
||||
@@ -39,15 +51,103 @@ go build -ldflags "-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.
|
||||
./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 (version, health, config, auth helpers)
|
||||
commands/ Command implementations
|
||||
config/ Config file resolution and management (.hf-config.json)
|
||||
help/ Help and help-brief renderer
|
||||
help/ Help and help-brief renderer with detailed leaf help
|
||||
mode/ Runtime mode detection (padded-cell vs manual)
|
||||
output/ Output formatting (human-readable, JSON, tables)
|
||||
passmgr/ pass_mgr integration for secret resolution
|
||||
@@ -58,20 +158,75 @@ internal/
|
||||
- **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:
|
||||
### Implemented
|
||||
|
||||
**Foundation:**
|
||||
- Go module and binary entrypoint
|
||||
- Config file resolution relative to binary directory
|
||||
- Runtime mode detection (`pass_mgr` present/absent)
|
||||
- Top-level and group/leaf help rendering system (`--help` / `--help-brief` / `not permitted` stubs)
|
||||
- Permission-aware command visibility via `/auth/me/permissions` when a token is available
|
||||
- HTTP client wrapper
|
||||
- HTTP client wrapper (GET/POST/PUT/PATCH/DELETE)
|
||||
- Output formatting (human-readable + `--json`)
|
||||
- `hf version`, `hf health`, `hf config`
|
||||
- Auth token resolution (padded-cell + manual)
|
||||
- Backend-aligned role/permission commands, including role-name lookup and permission-name↔id translation against current API routes
|
||||
|
||||
Planned:
|
||||
- User, role, project, task, milestone, meeting, support, propose, monitor commands
|
||||
- Rich per-command help/usage text beyond the current stub renderer
|
||||
**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
|
||||
|
||||
398
cmd/hf/main.go
398
cmd/hf/main.go
@@ -32,6 +32,28 @@ func main() {
|
||||
handleLeafOrRun("health", args[1:], commands.RunHealth)
|
||||
case "config":
|
||||
handleConfig(args[1:])
|
||||
case "update-discord-id":
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf update-discord-id <username> [discord-id]")
|
||||
}
|
||||
discordID := ""
|
||||
if len(filtered) >= 2 {
|
||||
discordID = filtered[1]
|
||||
}
|
||||
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
|
||||
default:
|
||||
if group, ok := findGroup(args[0]); ok {
|
||||
handleGroup(group, args[1:])
|
||||
@@ -58,7 +80,11 @@ func parseGlobalFlags(args []string) []string {
|
||||
}
|
||||
|
||||
func handleLeafOrRun(name string, args []string, run func()) {
|
||||
if isHelpFlagOnly(args) {
|
||||
if isLeafHelpFlagOnly(args) {
|
||||
if text, ok := help.RenderLeafHelp("", name); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
fmt.Printf("hf %s\n", name)
|
||||
return
|
||||
}
|
||||
@@ -69,7 +95,11 @@ func handleLeafOrRun(name string, args []string, run func()) {
|
||||
}
|
||||
|
||||
func handleConfig(args []string) {
|
||||
if isHelpFlagOnly(args) {
|
||||
if isLeafHelpFlagOnly(args) || isHelpFlagOnly(args) {
|
||||
if text, ok := help.RenderLeafHelp("config", "show"); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
runConfigHelp()
|
||||
return
|
||||
}
|
||||
@@ -123,11 +153,26 @@ func handleGroup(group help.Group, args []string) {
|
||||
output.Errorf("unknown %s subcommand: %s", group.Name, args[0])
|
||||
}
|
||||
|
||||
if len(args) > 1 && isHelpFlagOnly(args[1:]) {
|
||||
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
|
||||
}
|
||||
@@ -164,14 +209,48 @@ func handleGroup(group help.Group, args []string) {
|
||||
case "support":
|
||||
handleSupportCommand(sub.Name, remaining)
|
||||
return
|
||||
case "propose":
|
||||
handleProposeCommand(sub.Name, remaining)
|
||||
case "proposal", "propose":
|
||||
handleProposalCommand(sub.Name, remaining)
|
||||
return
|
||||
case "calendar":
|
||||
handleCalendarCommand(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
|
||||
}
|
||||
|
||||
if len(args) > 0 && args[0] == "update-discord-id" {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf update-discord-id <username> [discord-id]")
|
||||
}
|
||||
discordID := ""
|
||||
if len(filtered) >= 2 {
|
||||
discordID = filtered[1]
|
||||
}
|
||||
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
|
||||
return
|
||||
}
|
||||
|
||||
output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name)
|
||||
}
|
||||
|
||||
@@ -206,7 +285,7 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
}
|
||||
commands.RunUserGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
username, password, email, fullName := "", "", "", ""
|
||||
username, password, email, fullName, discordUserID := "", "", "", "", ""
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--user":
|
||||
@@ -229,6 +308,11 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
i++
|
||||
fullName = filtered[i]
|
||||
}
|
||||
case "--discord-user-id":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
discordUserID = filtered[i]
|
||||
}
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", filtered[i])
|
||||
}
|
||||
@@ -236,7 +320,7 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
if username == "" {
|
||||
output.Error("usage: hf user create --user <username>")
|
||||
}
|
||||
commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag)
|
||||
commands.RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")
|
||||
@@ -257,6 +341,11 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
output.Error("usage: hf user delete <username>")
|
||||
}
|
||||
commands.RunUserDelete(filtered[0], tokenFlag)
|
||||
case "reset-apikey":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user reset-apikey <username>")
|
||||
}
|
||||
commands.RunUserResetAPIKey(filtered[0], tokenFlag, accMgrTokenFlag)
|
||||
default:
|
||||
output.Errorf("hf user %s is not implemented yet", subCmd)
|
||||
}
|
||||
@@ -266,7 +355,30 @@ 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:])
|
||||
}
|
||||
|
||||
// groupAliases maps legacy command names to their current group names.
|
||||
var groupAliases = map[string]string{
|
||||
"propose": "proposal",
|
||||
}
|
||||
|
||||
func findGroup(name string) (help.Group, bool) {
|
||||
// Resolve alias first
|
||||
if alias, ok := groupAliases[name]; ok {
|
||||
name = alias
|
||||
}
|
||||
for _, group := range help.CommandSurface() {
|
||||
if group.Name == name {
|
||||
return group, true
|
||||
@@ -648,7 +760,46 @@ func handleSupportCommand(subCmd string, args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleProposeCommand(subCmd string, args []string) {
|
||||
func handleCalendarCommand(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 "schedule":
|
||||
commands.RunCalendarSchedule(filtered, tokenFlag)
|
||||
case "show":
|
||||
commands.RunCalendarShow(filtered, tokenFlag)
|
||||
case "edit":
|
||||
commands.RunCalendarEdit(filtered, tokenFlag)
|
||||
case "cancel":
|
||||
commands.RunCalendarCancel(filtered, tokenFlag)
|
||||
case "date-list":
|
||||
commands.RunCalendarDateList(filtered, tokenFlag)
|
||||
case "plan-schedule":
|
||||
commands.RunCalendarPlanSchedule(filtered, tokenFlag)
|
||||
case "plan-list":
|
||||
commands.RunCalendarPlanList(filtered, tokenFlag)
|
||||
case "plan-edit":
|
||||
commands.RunCalendarPlanEdit(filtered, tokenFlag)
|
||||
case "plan-cancel":
|
||||
commands.RunCalendarPlanCancel(filtered, tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf calendar %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleProposalCommand(subCmd string, args []string) {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
@@ -668,33 +819,206 @@ func handleProposeCommand(subCmd string, args []string) {
|
||||
commands.RunProposeList(filtered, tokenFlag)
|
||||
case "get":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf propose get <propose-code>")
|
||||
output.Error("usage: hf proposal get <proposal-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 ...]")
|
||||
output.Error("usage: hf proposal update <proposal-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>")
|
||||
output.Error("usage: hf proposal accept <proposal-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>]")
|
||||
output.Error("usage: hf proposal reject <proposal-code> [--reason <reason>]")
|
||||
}
|
||||
commands.RunProposeReject(filtered[0], filtered[1:], tokenFlag)
|
||||
case "reopen":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf propose reopen <propose-code>")
|
||||
output.Error("usage: hf proposal reopen <proposal-code>")
|
||||
}
|
||||
commands.RunProposeReopen(filtered[0], tokenFlag)
|
||||
case "essential":
|
||||
handleProposalEssentialCommand(filtered, tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf propose %s is not implemented yet", subCmd)
|
||||
output.Errorf("hf proposal %s is not implemented yet", subCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func handleProposalEssentialCommand(args []string, tokenFlag string) {
|
||||
essentialCommands := []help.Command{
|
||||
{Name: "list", Description: "List essentials for a proposal", Permitted: true},
|
||||
{Name: "create", Description: "Create an essential", Permitted: true},
|
||||
{Name: "update", Description: "Update an essential", Permitted: true},
|
||||
{Name: "delete", Description: "Delete an essential", Permitted: true},
|
||||
}
|
||||
|
||||
if len(args) == 0 || isHelpFlagOnly(args) {
|
||||
fmt.Print(help.RenderGroupHelp("proposal essential", essentialCommands))
|
||||
return
|
||||
}
|
||||
|
||||
subCmd := args[0]
|
||||
remaining := args[1:]
|
||||
|
||||
if isLeafHelpFlagOnly(remaining) {
|
||||
if text, ok := help.RenderLeafHelp("proposal/essential", subCmd); ok {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
fmt.Printf("hf proposal essential %s\n", subCmd)
|
||||
return
|
||||
}
|
||||
|
||||
switch subCmd {
|
||||
case "list":
|
||||
commands.RunEssentialList(remaining, tokenFlag)
|
||||
case "create":
|
||||
commands.RunEssentialCreate(remaining, tokenFlag)
|
||||
case "update":
|
||||
if len(remaining) < 1 {
|
||||
output.Error("usage: hf proposal essential update <essential-code> [--title ...] [--type ...] [--desc ...]")
|
||||
}
|
||||
commands.RunEssentialUpdate(remaining[0], remaining[1:], tokenFlag)
|
||||
case "delete":
|
||||
if len(remaining) < 1 {
|
||||
output.Error("usage: hf proposal essential delete <essential-code> --proposal <proposal-code>")
|
||||
}
|
||||
commands.RunEssentialDeleteFull(remaining[0], remaining[1:], tokenFlag)
|
||||
default:
|
||||
output.Errorf("hf proposal essential %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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,12 +1050,32 @@ func handleMonitorCommand(subCmd string, args []string) {
|
||||
}
|
||||
|
||||
func handleMonitorServerCommand(args []string, tokenFlag string) {
|
||||
if len(args) == 0 {
|
||||
output.Error("usage: hf monitor server <list|get|create|delete> ...")
|
||||
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":
|
||||
@@ -754,12 +1098,30 @@ func handleMonitorServerCommand(args []string, tokenFlag string) {
|
||||
}
|
||||
|
||||
func handleMonitorAPIKeyCommand(args []string, tokenFlag string) {
|
||||
if len(args) == 0 {
|
||||
output.Error("usage: hf monitor api-key <generate|revoke> <identifier>")
|
||||
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":
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
APIKey string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
@@ -28,6 +29,17 @@ func New(baseURL, token string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
|
||||
func NewWithAPIKey(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
APIKey: apiKey,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RequestError represents a non-2xx HTTP response.
|
||||
type RequestError struct {
|
||||
StatusCode int
|
||||
@@ -45,7 +57,9 @@ func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
if c.Token != "" {
|
||||
if c.APIKey != "" {
|
||||
req.Header.Set("X-API-Key", c.APIKey)
|
||||
} else if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if body != nil {
|
||||
@@ -93,7 +107,7 @@ func (c *Client) Delete(path string) ([]byte, error) {
|
||||
|
||||
// Health checks the API health endpoint and returns the response.
|
||||
func (c *Client) Health() (map[string]interface{}, error) {
|
||||
data, err := c.Get("/api/health/")
|
||||
data, err := c.Get("/health")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
660
internal/commands/calendar.go
Normal file
660
internal/commands/calendar.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||
)
|
||||
|
||||
// --- Slot Commands ---
|
||||
|
||||
// RunCalendarSchedule implements `hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>]`.
|
||||
func RunCalendarSchedule(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
if len(args) < 3 {
|
||||
output.Error("usage: hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]")
|
||||
}
|
||||
|
||||
slotType := args[0]
|
||||
scheduledAt := args[1]
|
||||
estimatedDuration := args[2]
|
||||
|
||||
date, jobCode, priority := "", "", ""
|
||||
remaining := args[3:]
|
||||
for i := 0; i < len(remaining); i++ {
|
||||
switch remaining[i] {
|
||||
case "--date":
|
||||
if i+1 >= len(remaining) {
|
||||
output.Error("--date requires a value")
|
||||
}
|
||||
i++
|
||||
date = remaining[i]
|
||||
case "--job":
|
||||
if i+1 >= len(remaining) {
|
||||
output.Error("--job requires a value")
|
||||
}
|
||||
i++
|
||||
jobCode = remaining[i]
|
||||
case "--priority":
|
||||
if i+1 >= len(remaining) {
|
||||
output.Error("--priority requires a value")
|
||||
}
|
||||
i++
|
||||
priority = remaining[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", remaining[i])
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"slot_type": slotType,
|
||||
"scheduled_at": scheduledAt,
|
||||
"estimated_duration": estimatedDuration,
|
||||
}
|
||||
if date != "" {
|
||||
payload["date"] = date
|
||||
}
|
||||
if priority != "" {
|
||||
payload["priority"] = priority
|
||||
}
|
||||
if jobCode != "" {
|
||||
payload["event_type"] = "job"
|
||||
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/calendar/slots", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to schedule slot: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for warnings
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(data, &resp); err == nil {
|
||||
if ws, ok := resp["warnings"]; ok {
|
||||
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
|
||||
fmt.Println("⚠️ Workload warnings:")
|
||||
for _, w := range warnings {
|
||||
if wm, ok := w.(map[string]interface{}); ok {
|
||||
if msg, ok := wm["message"].(string); ok {
|
||||
fmt.Printf(" - %s\n", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("slot scheduled: %s at %s (%s min)\n", slotType, scheduledAt, estimatedDuration)
|
||||
}
|
||||
|
||||
// RunCalendarShow implements `hf calendar show [--date <yyyy-mm-dd>]`.
|
||||
func RunCalendarShow(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
date := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--date":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--date requires a value")
|
||||
}
|
||||
i++
|
||||
date = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
|
||||
path := "/calendar/day"
|
||||
if date != "" {
|
||||
path += "?date=" + date
|
||||
}
|
||||
|
||||
data, err := c.Get(path)
|
||||
if err != nil {
|
||||
output.Errorf("failed to show calendar: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Slots []map[string]interface{} `json:"slots"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
output.Errorf("cannot parse calendar: %v", err)
|
||||
}
|
||||
slots := resp.Slots
|
||||
|
||||
if len(slots) == 0 {
|
||||
if date != "" {
|
||||
fmt.Printf("No slots for %s\n", date)
|
||||
} else {
|
||||
fmt.Println("No slots for today")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
headers := []string{"ID", "TIME", "TYPE", "DURATION", "PRIORITY", "STATUS", "EVENT"}
|
||||
var rows [][]string
|
||||
for _, s := range slots {
|
||||
slotID := fmt.Sprintf("%v", s["slot_id"])
|
||||
scheduled := fmt.Sprintf("%v", s["scheduled_at"])
|
||||
slotType := fmt.Sprintf("%v", s["slot_type"])
|
||||
dur := fmt.Sprintf("%v min", s["estimated_duration"])
|
||||
pri := fmt.Sprintf("%v", s["priority"])
|
||||
status := fmt.Sprintf("%v", s["status"])
|
||||
event := ""
|
||||
if et, ok := s["event_type"]; ok && et != nil {
|
||||
event = fmt.Sprintf("%v", et)
|
||||
}
|
||||
if ed, ok := s["event_data"]; ok && ed != nil {
|
||||
if edm, ok := ed.(map[string]interface{}); ok {
|
||||
if code, ok := edm["code"]; ok {
|
||||
event += " " + fmt.Sprintf("%v", code)
|
||||
}
|
||||
if ev, ok := edm["event"]; ok {
|
||||
event += " " + fmt.Sprintf("%v", ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
if isVirt, ok := s["is_virtual"]; ok && isVirt == true {
|
||||
slotID += " (plan)"
|
||||
}
|
||||
rows = append(rows, []string{slotID, scheduled, slotType, dur, pri, status, strings.TrimSpace(event)})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
}
|
||||
|
||||
// RunCalendarEdit implements `hf calendar edit [--date <yyyy-mm-dd>] <slot-id> [flags]`.
|
||||
func RunCalendarEdit(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
if len(args) < 1 {
|
||||
output.Error("usage: hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--scheduled-at <HH:mm>] [--job <code>]")
|
||||
}
|
||||
|
||||
slotID := args[0]
|
||||
date, slotType, duration, scheduledAt, jobCode := "", "", "", "", ""
|
||||
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--date":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--date requires a value")
|
||||
}
|
||||
i++
|
||||
date = args[i]
|
||||
case "--slot-type":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--slot-type requires a value")
|
||||
}
|
||||
i++
|
||||
slotType = args[i]
|
||||
case "--estimated-duration":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--estimated-duration requires a value")
|
||||
}
|
||||
i++
|
||||
duration = args[i]
|
||||
case "--scheduled-at":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--scheduled-at requires a value")
|
||||
}
|
||||
i++
|
||||
scheduledAt = args[i]
|
||||
case "--job":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--job requires a value")
|
||||
}
|
||||
i++
|
||||
jobCode = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
payload := make(map[string]interface{})
|
||||
if slotType != "" {
|
||||
payload["slot_type"] = slotType
|
||||
}
|
||||
if duration != "" {
|
||||
payload["estimated_duration"] = duration
|
||||
}
|
||||
if scheduledAt != "" {
|
||||
payload["scheduled_at"] = scheduledAt
|
||||
}
|
||||
if jobCode != "" {
|
||||
payload["event_type"] = "job"
|
||||
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
path := "/calendar/slots/" + slotID
|
||||
if strings.HasPrefix(slotID, "plan-") {
|
||||
path = "/calendar/slots/virtual/" + slotID
|
||||
}
|
||||
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
|
||||
data, err := c.Patch(path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to edit slot: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(data, &resp); err == nil {
|
||||
if ws, ok := resp["warnings"]; ok {
|
||||
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
|
||||
fmt.Println("⚠️ Workload warnings:")
|
||||
for _, w := range warnings {
|
||||
if wm, ok := w.(map[string]interface{}); ok {
|
||||
if msg, ok := wm["message"].(string); ok {
|
||||
fmt.Printf(" - %s\n", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("slot edited: %s\n", slotID)
|
||||
}
|
||||
|
||||
// RunCalendarCancel implements `hf calendar cancel [--date <yyyy-mm-dd>] <slot-id>`.
|
||||
func RunCalendarCancel(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
if len(args) < 1 {
|
||||
output.Error("usage: hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]")
|
||||
}
|
||||
|
||||
slotID := args[0]
|
||||
date := ""
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--date":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--date requires a value")
|
||||
}
|
||||
i++
|
||||
date = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
path := "/calendar/slots/" + slotID + "/cancel"
|
||||
if strings.HasPrefix(slotID, "plan-") {
|
||||
path = "/calendar/slots/virtual/" + slotID + "/cancel"
|
||||
}
|
||||
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
|
||||
_, err = c.Post(path, nil)
|
||||
if err != nil {
|
||||
output.Errorf("failed to cancel slot: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("slot cancelled: %s\n", slotID)
|
||||
}
|
||||
|
||||
// RunCalendarDateList implements `hf calendar date-list`.
|
||||
func RunCalendarDateList(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/calendar/dates")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list dates: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Dates []string `json:"dates"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
output.Errorf("cannot parse dates: %v", err)
|
||||
}
|
||||
dates := resp.Dates
|
||||
|
||||
if len(dates) == 0 {
|
||||
fmt.Println("No future dates with materialized slots")
|
||||
return
|
||||
}
|
||||
|
||||
for _, d := range dates {
|
||||
fmt.Println(d)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Plan Commands ---
|
||||
|
||||
// RunCalendarPlanSchedule implements `hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]`.
|
||||
func RunCalendarPlanSchedule(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
if len(args) < 2 {
|
||||
output.Error("usage: hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]")
|
||||
}
|
||||
|
||||
slotType := args[0]
|
||||
duration := args[1]
|
||||
atTime, onDay, onWeek, onMonth := "", "", "", ""
|
||||
|
||||
for i := 2; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--at":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--at requires a value")
|
||||
}
|
||||
i++
|
||||
atTime = args[i]
|
||||
case "--on-day":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--on-day requires a value")
|
||||
}
|
||||
i++
|
||||
onDay = args[i]
|
||||
case "--on-week":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--on-week requires a value")
|
||||
}
|
||||
i++
|
||||
onWeek = args[i]
|
||||
case "--on-month":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--on-month requires a value")
|
||||
}
|
||||
i++
|
||||
onMonth = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if atTime == "" {
|
||||
output.Error("--at is required")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"slot_type": slotType,
|
||||
"estimated_duration": duration,
|
||||
"at_time": atTime,
|
||||
}
|
||||
if onDay != "" {
|
||||
payload["on_day"] = onDay
|
||||
}
|
||||
if onWeek != "" {
|
||||
payload["on_week"] = onWeek
|
||||
}
|
||||
if onMonth != "" {
|
||||
payload["on_month"] = onMonth
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/calendar/plans", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create plan: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("plan created: %s at %s (%s min)\n", slotType, atTime, duration)
|
||||
}
|
||||
|
||||
// RunCalendarPlanList implements `hf calendar plan-list`.
|
||||
func RunCalendarPlanList(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/calendar/plans")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list plans: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Plans []map[string]interface{} `json:"plans"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
output.Errorf("cannot parse plans: %v", err)
|
||||
}
|
||||
plans := resp.Plans
|
||||
|
||||
if len(plans) == 0 {
|
||||
fmt.Println("No schedule plans")
|
||||
return
|
||||
}
|
||||
|
||||
headers := []string{"ID", "TYPE", "AT", "ON DAY", "ON WEEK", "ON MONTH", "DURATION", "ACTIVE"}
|
||||
var rows [][]string
|
||||
for _, p := range plans {
|
||||
id := fmt.Sprintf("%v", p["id"])
|
||||
slotType := fmt.Sprintf("%v", p["slot_type"])
|
||||
at := fmt.Sprintf("%v", p["at_time"])
|
||||
onDay := ""
|
||||
if d, ok := p["on_day"]; ok && d != nil {
|
||||
onDay = fmt.Sprintf("%v", d)
|
||||
}
|
||||
onWeek := ""
|
||||
if w, ok := p["on_week"]; ok && w != nil {
|
||||
onWeek = fmt.Sprintf("%v", w)
|
||||
}
|
||||
onMonth := ""
|
||||
if m, ok := p["on_month"]; ok && m != nil {
|
||||
onMonth = fmt.Sprintf("%v", m)
|
||||
}
|
||||
dur := fmt.Sprintf("%v min", p["estimated_duration"])
|
||||
active := "yes"
|
||||
if a, ok := p["is_active"]; ok && a == false {
|
||||
active = "no"
|
||||
}
|
||||
rows = append(rows, []string{id, slotType, at, onDay, onWeek, onMonth, dur, active})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
}
|
||||
|
||||
// RunCalendarPlanEdit implements `hf calendar plan-edit <plan-id> [flags]`.
|
||||
func RunCalendarPlanEdit(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
if len(args) < 1 {
|
||||
output.Error("usage: hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]")
|
||||
}
|
||||
|
||||
planID := args[0]
|
||||
payload := make(map[string]interface{})
|
||||
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--at":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--at requires a value")
|
||||
}
|
||||
i++
|
||||
payload["at_time"] = args[i]
|
||||
case "--on-day":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--on-day requires a value")
|
||||
}
|
||||
i++
|
||||
payload["on_day"] = args[i]
|
||||
case "--on-week":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--on-week requires a value")
|
||||
}
|
||||
i++
|
||||
payload["on_week"] = args[i]
|
||||
case "--on-month":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--on-month requires a value")
|
||||
}
|
||||
i++
|
||||
payload["on_month"] = args[i]
|
||||
case "--slot-type":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--slot-type requires a value")
|
||||
}
|
||||
i++
|
||||
payload["slot_type"] = args[i]
|
||||
case "--estimated-duration":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--estimated-duration requires a value")
|
||||
}
|
||||
i++
|
||||
payload["estimated_duration"] = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload) == 0 {
|
||||
output.Error("nothing to edit — provide at least one flag")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Patch("/calendar/plans/"+planID, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to edit plan: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("plan edited: %s\n", planID)
|
||||
}
|
||||
|
||||
// RunCalendarPlanCancel implements `hf calendar plan-cancel <plan-id>`.
|
||||
func RunCalendarPlanCancel(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
if len(args) < 1 {
|
||||
output.Error("usage: hf calendar plan-cancel <plan-id>")
|
||||
}
|
||||
|
||||
planID := args[0]
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Post("/calendar/plans/"+planID+"/cancel", nil)
|
||||
if err != nil {
|
||||
output.Errorf("failed to cancel plan: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("plan cancelled: %s\n", planID)
|
||||
}
|
||||
826
internal/commands/calendar_test.go
Normal file
826
internal/commands/calendar_test.go
Normal file
@@ -0,0 +1,826 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTestConfig(t *testing.T, dir, baseURL string) {
|
||||
config := map[string]string{
|
||||
"base-url": baseURL,
|
||||
}
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, ".hf-config.json")
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildCLI(t *testing.T, cliPath string) {
|
||||
srcDir := filepath.Join("..", "..")
|
||||
cmd := exec.Command("go", "build", "-o", cliPath, filepath.Join(srcDir, "cmd", "hf"))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot build CLI: %v (out: %s)", err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func runCLI(t *testing.T, dir, cliPath string, args ...string) (string, error) {
|
||||
cmd := exec.Command(cliPath, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// --- Tests: argument parsing / usage errors ---
|
||||
|
||||
func TestCalendarSchedule_MissingArgs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
// --token must come after subcommand: hf calendar schedule --token <tok>
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake")
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero exit for missing args; got out=%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "usage:") && !strings.Contains(out, "slot-type") {
|
||||
t.Errorf("expected usage message with slot-type; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarSchedule_UnknownFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--bad-flag")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for unknown flag")
|
||||
}
|
||||
if !strings.Contains(out, "unknown flag") {
|
||||
t.Errorf("expected 'unknown flag' in output; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarShow_UnknownFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"slots": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--bad-flag")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for unknown flag")
|
||||
}
|
||||
if !strings.Contains(out, "unknown flag") {
|
||||
t.Errorf("expected 'unknown flag'; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEdit_MissingSlotID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing slot-id")
|
||||
}
|
||||
if !strings.Contains(out, "usage:") {
|
||||
t.Errorf("expected usage message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarCancel_MissingSlotID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing slot-id")
|
||||
}
|
||||
if !strings.Contains(out, "usage:") {
|
||||
t.Errorf("expected usage message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanSchedule_MissingAt(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing --at")
|
||||
}
|
||||
if !strings.Contains(out, "--at") {
|
||||
t.Errorf("expected --at error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanEdit_NothingToEdit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for nothing to edit")
|
||||
}
|
||||
if !strings.Contains(out, "nothing to edit") {
|
||||
t.Errorf("expected 'nothing to edit' error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanCancel_MissingPlanID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing plan-id")
|
||||
}
|
||||
if !strings.Contains(out, "usage:") {
|
||||
t.Errorf("expected usage message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests: JSON output ---
|
||||
|
||||
func TestCalendarSchedule_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/calendar/slots" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"slot_id": 42,
|
||||
"slot_type": "Work",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %s", out)
|
||||
}
|
||||
if resp["slot_id"] != float64(42) {
|
||||
t.Errorf("expected slot_id=42; got: %v", resp["slot_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarShow_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/calendar/day" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"slots": []interface{}{
|
||||
map[string]interface{}{
|
||||
"slot_id": 1,
|
||||
"slot_type": "Work",
|
||||
"scheduled_at": "09:00",
|
||||
"estimated_duration": 30,
|
||||
"priority": 50,
|
||||
"status": "NotStarted",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "show", "--token", "fake", "--date", "2026-04-01")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %s", out)
|
||||
}
|
||||
slots, ok := resp["slots"].([]interface{})
|
||||
if !ok || len(slots) == 0 {
|
||||
t.Fatalf("expected slots array in JSON; got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarDateList_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/calendar/dates" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"dates": []string{"2026-04-01", "2026-04-02"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "date-list", "--token", "fake")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %s", out)
|
||||
}
|
||||
dates, ok := resp["dates"].([]interface{})
|
||||
if !ok || len(dates) != 2 {
|
||||
t.Errorf("expected 2 dates; got: %v", dates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanList_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/calendar/plans" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plans": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"slot_type": "Work",
|
||||
"at_time": "09:00",
|
||||
"estimated_duration": 30,
|
||||
"is_active": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "plan-list", "--token", "fake")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %s", out)
|
||||
}
|
||||
plans, ok := resp["plans"].([]interface{})
|
||||
if !ok || len(plans) == 0 {
|
||||
t.Fatalf("expected plans array; got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests: human-readable output ---
|
||||
|
||||
func TestCalendarShow_HumanOutput_WithSlots(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"slots": []interface{}{
|
||||
map[string]interface{}{
|
||||
"slot_id": 1,
|
||||
"slot_type": "Work",
|
||||
"scheduled_at": "09:00",
|
||||
"estimated_duration": 30,
|
||||
"priority": 50,
|
||||
"status": "NotStarted",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"slot_id": "plan-1-2026-04-01",
|
||||
"slot_type": "OnCall",
|
||||
"scheduled_at": "14:00",
|
||||
"estimated_duration": 60,
|
||||
"priority": 40,
|
||||
"status": "NotStarted",
|
||||
"is_virtual": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--date", "2026-04-01")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
// Virtual slot should be marked as plan
|
||||
if !strings.Contains(out, "plan") {
|
||||
t.Errorf("expected human output to mark virtual slot as plan; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarShow_HumanOutput_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"slots": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "No slots") {
|
||||
t.Errorf("expected 'No slots' for empty; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarDateList_HumanOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"dates": []string{"2026-04-01", "2026-04-02"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
for _, date := range []string{"2026-04-01", "2026-04-02"} {
|
||||
if !strings.Contains(out, date) {
|
||||
t.Errorf("expected date %s in output; got: %s", date, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarDateList_HumanOutput_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"dates": []string{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "No future dates") {
|
||||
t.Errorf("expected 'No future dates'; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanList_HumanOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plans": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"slot_type": "Work",
|
||||
"at_time": "09:00",
|
||||
"on_day": "Mon",
|
||||
"estimated_duration": 30,
|
||||
"is_active": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "09:00") || !strings.Contains(out, "Work") {
|
||||
t.Errorf("expected plan data in output; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanList_HumanOutput_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"plans": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "No schedule plans") {
|
||||
t.Errorf("expected 'No schedule plans'; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests: error output ---
|
||||
|
||||
func TestCalendarShow_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(`{"detail":"internal error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for 500 response")
|
||||
}
|
||||
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
|
||||
t.Errorf("expected error message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEdit_SlotNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
w.Write([]byte(`{"detail":"slot not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "999", "--slot-type", "Work")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for 404")
|
||||
}
|
||||
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
|
||||
t.Errorf("expected error message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarCancel_SlotNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
w.Write([]byte(`{"detail":"slot not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "999")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for 404")
|
||||
}
|
||||
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
|
||||
t.Errorf("expected error message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanSchedule_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(`{"detail":"db error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for 500")
|
||||
}
|
||||
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
|
||||
t.Errorf("expected error message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanCancel_PlanNotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
w.Write([]byte(`{"detail":"plan not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "999")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for 404")
|
||||
}
|
||||
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
|
||||
t.Errorf("expected error message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests: workload warnings ---
|
||||
|
||||
func TestCalendarSchedule_WorkloadWarning(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"slot_id": 1,
|
||||
"warnings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "workload",
|
||||
"message": "Daily minimum work workload (30 min) not met: current 0 min",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "⚠") && !strings.Contains(out, "warning") {
|
||||
t.Errorf("expected workload warning in output; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests: virtual slot routing ---
|
||||
|
||||
func TestCalendarEdit_VirtualSlot_RoutesCorrectly(t *testing.T) {
|
||||
var editedPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
editedPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"slot_id": 10,
|
||||
"slot_type": "Work",
|
||||
"scheduled_at": "10:00",
|
||||
"estimated_duration": 30,
|
||||
"status": "NotStarted",
|
||||
"priority": 50,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "plan-1-2026-04-01", "--scheduled-at", "10:00")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(editedPath, "/calendar/slots/virtual/") {
|
||||
t.Errorf("expected virtual slot path /calendar/slots/virtual/...; got: %s", editedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarCancel_VirtualSlot_RoutesCorrectly(t *testing.T) {
|
||||
var cancelledPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cancelledPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
_, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "plan-1-2026-04-01")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(cancelledPath, "/calendar/slots/virtual/") {
|
||||
t.Errorf("expected virtual slot cancel path /calendar/slots/virtual/...; got: %s", cancelledPath)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests: successful operations ---
|
||||
|
||||
func TestCalendarSchedule_SuccessOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"slot_id": 5})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--job", "TASK-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "slot scheduled") {
|
||||
t.Errorf("expected 'slot scheduled' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanSchedule_SuccessOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00", "--on-day", "Mon")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "plan created") {
|
||||
t.Errorf("expected 'plan created' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanEdit_SuccessOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "at_time": "10:00"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1", "--at", "10:00")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "plan edited") {
|
||||
t.Errorf("expected 'plan edited' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarPlanCancel_SuccessOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "plan cancelled") {
|
||||
t.Errorf("expected 'plan cancelled' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarCancel_SuccessOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "slot cancelled") {
|
||||
t.Errorf("expected 'slot cancelled' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func RunConfigURL(url string) {
|
||||
fmt.Printf("base-url set to %s\n", url)
|
||||
}
|
||||
|
||||
// RunConfigAccMgrToken stores the account-manager token via pass_mgr.
|
||||
// RunConfigAccMgrToken stores the account-manager token via secret-mgr.
|
||||
func RunConfigAccMgrToken(token string) {
|
||||
if token == "" {
|
||||
output.Error("usage: hf config --acc-mgr-token <token>")
|
||||
|
||||
274
internal/commands/essential.go
Normal file
274
internal/commands/essential.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||
)
|
||||
|
||||
type essentialResponse struct {
|
||||
ID int `json:"id"`
|
||||
EssentialCode string `json:"essential_code"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
CreatedByID *int `json:"created_by_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt *string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RunEssentialList implements `hf proposal essential list --proposal <proposal-code>`.
|
||||
func RunEssentialList(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
proposalCode := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--proposal":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--proposal requires a value")
|
||||
}
|
||||
i++
|
||||
proposalCode = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if proposalCode == "" {
|
||||
output.Error("usage: hf proposal essential list --proposal <proposal-code>")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get(proposalPath(c, proposalCode) + "/essentials")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list essentials: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
var essentials []essentialResponse
|
||||
if err := json.Unmarshal(data, &essentials); err != nil {
|
||||
output.Errorf("cannot parse essential list: %v", err)
|
||||
}
|
||||
|
||||
headers := []string{"CODE", "TYPE", "TITLE", "CREATED"}
|
||||
var rows [][]string
|
||||
for _, e := range essentials {
|
||||
title := e.Title
|
||||
if len(title) > 40 {
|
||||
title = title[:37] + "..."
|
||||
}
|
||||
rows = append(rows, []string{e.EssentialCode, e.Type, title, e.CreatedAt})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
}
|
||||
|
||||
// RunEssentialCreate implements `hf proposal essential create --proposal <proposal-code> --title <title> --type <type> [--desc <desc>]`.
|
||||
func RunEssentialCreate(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
proposalCode, title, essType, desc := "", "", "", ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--proposal":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--proposal requires a value")
|
||||
}
|
||||
i++
|
||||
proposalCode = args[i]
|
||||
case "--title":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--title requires a value")
|
||||
}
|
||||
i++
|
||||
title = args[i]
|
||||
case "--type":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--type requires a value")
|
||||
}
|
||||
i++
|
||||
essType = args[i]
|
||||
case "--desc":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--desc requires a value")
|
||||
}
|
||||
i++
|
||||
desc = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if proposalCode == "" || title == "" || essType == "" {
|
||||
output.Error("usage: hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]")
|
||||
}
|
||||
|
||||
// Validate type
|
||||
switch essType {
|
||||
case "feature", "improvement", "refactor":
|
||||
// valid
|
||||
default:
|
||||
output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
"type": essType,
|
||||
}
|
||||
if desc != "" {
|
||||
payload["description"] = desc
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post(proposalPath(c, proposalCode)+"/essentials", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create essential: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
var e essentialResponse
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
fmt.Printf("essential created: %s\n", title)
|
||||
return
|
||||
}
|
||||
fmt.Printf("essential created: %s (code: %s)\n", e.Title, e.EssentialCode)
|
||||
}
|
||||
|
||||
// RunEssentialUpdate implements `hf proposal essential update <essential-code> [--title ...] [--type ...] [--desc ...]`.
|
||||
func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
proposalCode := ""
|
||||
payload := make(map[string]interface{})
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--proposal":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--proposal requires a value")
|
||||
}
|
||||
i++
|
||||
proposalCode = args[i]
|
||||
case "--title":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--title requires a value")
|
||||
}
|
||||
i++
|
||||
payload["title"] = args[i]
|
||||
case "--type":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--type requires a value")
|
||||
}
|
||||
i++
|
||||
essType := args[i]
|
||||
switch essType {
|
||||
case "feature", "improvement", "refactor":
|
||||
payload["type"] = essType
|
||||
default:
|
||||
output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType)
|
||||
}
|
||||
case "--desc":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--desc requires a value")
|
||||
}
|
||||
i++
|
||||
payload["description"] = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if proposalCode == "" {
|
||||
output.Error("usage: hf proposal essential update <essential-code> --proposal <proposal-code> [--title ...] [--type ...] [--desc ...]")
|
||||
}
|
||||
|
||||
if len(payload) == 0 {
|
||||
output.Error("nothing to update — provide at least one flag")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Patch(proposalPath(c, proposalCode)+"/essentials/"+essentialCode, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to update essential: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("essential updated: %s\n", essentialCode)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// RunEssentialDeleteFull implements `hf proposal essential delete <essential-code> --proposal <code>`.
|
||||
func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
proposalCode := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--proposal":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--proposal requires a value")
|
||||
}
|
||||
i++
|
||||
proposalCode = args[i]
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
if proposalCode == "" {
|
||||
output.Error("usage: hf proposal essential delete <essential-code> --proposal <proposal-code>")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode)
|
||||
if err != nil {
|
||||
output.Errorf("failed to delete essential: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("essential deleted: %s\n", essentialCode)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func RunMeetingList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
|
||||
@@ -44,7 +44,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
|
||||
// monitorOverviewResponse matches the backend monitor overview schema.
|
||||
type monitorOverviewResponse struct {
|
||||
TotalServers int `json:"total_servers"`
|
||||
OnlineServers int `json:"online_servers"`
|
||||
Tasks interface{} `json:"tasks"`
|
||||
Providers interface{} `json:"providers"`
|
||||
Servers []monitorServerResponse `json:"servers"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
// monitorServerResponse matches the backend monitor server schema.
|
||||
@@ -28,8 +30,32 @@ type monitorServerResponse struct {
|
||||
|
||||
// monitorAPIKeyResponse matches the backend monitor API key schema.
|
||||
type monitorAPIKeyResponse struct {
|
||||
Identifier string `json:"identifier"`
|
||||
APIKey string `json:"api_key"`
|
||||
ServerID int `json:"server_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func monitorServerList(c *client.Client) []monitorServerResponse {
|
||||
data, err := c.Get("/monitor/admin/servers")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list monitor servers: %v", err)
|
||||
}
|
||||
var servers []monitorServerResponse
|
||||
if err := json.Unmarshal(data, &servers); err != nil {
|
||||
output.Errorf("cannot parse server list: %v", err)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func resolveMonitorServerID(c *client.Client, identifier string) int {
|
||||
servers := monitorServerList(c)
|
||||
for _, s := range servers {
|
||||
if s.Identifier == identifier {
|
||||
return s.ID
|
||||
}
|
||||
}
|
||||
output.Errorf("monitor server not found: %s", identifier)
|
||||
return 0
|
||||
}
|
||||
|
||||
// RunMonitorOverview implements `hf monitor overview`.
|
||||
@@ -40,7 +66,7 @@ func RunMonitorOverview(tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/monitor/overview")
|
||||
data, err := c.Get("/monitor/public/overview")
|
||||
if err != nil {
|
||||
output.Errorf("failed to get monitor overview: %v", err)
|
||||
}
|
||||
@@ -59,9 +85,16 @@ func RunMonitorOverview(tokenFlag string) {
|
||||
output.Errorf("cannot parse monitor overview: %v", err)
|
||||
}
|
||||
|
||||
online := 0
|
||||
for _, s := range o.Servers {
|
||||
if s.Status == "online" {
|
||||
online++
|
||||
}
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"total-servers", fmt.Sprintf("%d", o.TotalServers),
|
||||
"online-servers", fmt.Sprintf("%d", o.OnlineServers),
|
||||
"total-servers", fmt.Sprintf("%d", len(o.Servers)),
|
||||
"online-servers", fmt.Sprintf("%d", online),
|
||||
"generated-at", o.GeneratedAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,7 +106,7 @@ func RunMonitorServerList(tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/monitor/servers")
|
||||
data, err := c.Get("/monitor/admin/servers")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list monitor servers: %v", err)
|
||||
}
|
||||
@@ -116,39 +149,37 @@ func RunMonitorServerGet(identifier, tokenFlag string) {
|
||||
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)
|
||||
servers := monitorServerList(c)
|
||||
var found *monitorServerResponse
|
||||
for i := range servers {
|
||||
if servers[i].Identifier == identifier {
|
||||
found = &servers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
output.Errorf("failed to get server: not found: %s", identifier)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
output.PrintJSON(found)
|
||||
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
|
||||
if found.DisplayName != nil {
|
||||
name = *found.DisplayName
|
||||
}
|
||||
lastSeen := ""
|
||||
if s.LastSeen != nil {
|
||||
lastSeen = *s.LastSeen
|
||||
if found.LastSeen != nil {
|
||||
lastSeen = *found.LastSeen
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"identifier", s.Identifier,
|
||||
"identifier", found.Identifier,
|
||||
"name", name,
|
||||
"status", s.Status,
|
||||
"status", found.Status,
|
||||
"last-seen", lastSeen,
|
||||
"created", s.CreatedAt,
|
||||
"created", found.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,7 +228,7 @@ func RunMonitorServerCreate(args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/monitor/servers", bytes.NewReader(body))
|
||||
data, err := c.Post("/monitor/admin/servers", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create server: %v", err)
|
||||
}
|
||||
@@ -223,7 +254,8 @@ func RunMonitorServerDelete(identifier, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Delete("/monitor/servers/" + identifier)
|
||||
serverID := resolveMonitorServerID(c, identifier)
|
||||
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d", serverID))
|
||||
if err != nil {
|
||||
output.Errorf("failed to delete server: %v", err)
|
||||
}
|
||||
@@ -238,7 +270,8 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/monitor/servers/"+identifier+"/api-key", nil)
|
||||
serverID := resolveMonitorServerID(c, identifier)
|
||||
data, err := c.Post(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID), nil)
|
||||
if err != nil {
|
||||
output.Errorf("failed to generate API key: %v", err)
|
||||
}
|
||||
@@ -258,8 +291,9 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
|
||||
return
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"identifier", k.Identifier,
|
||||
"server-id", fmt.Sprintf("%d", k.ServerID),
|
||||
"api-key", k.APIKey,
|
||||
"message", k.Message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -271,7 +305,8 @@ func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Delete("/monitor/servers/" + identifier + "/api-key")
|
||||
serverID := resolveMonitorServerID(c, identifier)
|
||||
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID))
|
||||
if err != nil {
|
||||
output.Errorf("failed to revoke API key: %v", err)
|
||||
}
|
||||
|
||||
531
internal/commands/proposal_test.go
Normal file
531
internal/commands/proposal_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runCLIProposal(t *testing.T, dir, cliPath string, args ...string) (string, error) {
|
||||
cmd := exec.Command(cliPath, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// --- Essential subcommand tests ---
|
||||
|
||||
func TestEssentialList_MissingProposal(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "list", "--token", "fake")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing --proposal")
|
||||
}
|
||||
if !strings.Contains(out, "--proposal") {
|
||||
t.Errorf("expected --proposal error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialCreate_MissingRequired(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
// Missing --proposal, --title, --type
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing required args")
|
||||
}
|
||||
if !strings.Contains(out, "usage:") {
|
||||
t.Errorf("expected usage message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialCreate_InvalidType(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
|
||||
"--proposal", "PRJ-001", "--title", "Test", "--type", "invalid-type")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid type")
|
||||
}
|
||||
if !strings.Contains(out, "invalid essential type") {
|
||||
t.Errorf("expected invalid type error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialCreate_UnknownFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
|
||||
"--proposal", "PRJ-001", "--title", "Test", "--type", "feature", "--unknown")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for unknown flag")
|
||||
}
|
||||
if !strings.Contains(out, "unknown flag") {
|
||||
t.Errorf("expected unknown flag error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialUpdate_NothingToUpdate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
|
||||
"ESS-001", "--proposal", "PRJ-001")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for nothing to update")
|
||||
}
|
||||
if !strings.Contains(out, "nothing to update") {
|
||||
t.Errorf("expected 'nothing to update' error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialUpdate_MissingProposal(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing --proposal")
|
||||
}
|
||||
if !strings.Contains(out, "--proposal") {
|
||||
t.Errorf("expected --proposal error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialDelete_MissingProposal(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", "ESS-001")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing --proposal")
|
||||
}
|
||||
if !strings.Contains(out, "--proposal") {
|
||||
t.Errorf("expected --proposal error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Essential list/create JSON output ---
|
||||
|
||||
func TestEssentialList_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"essential_code": "ESS-001",
|
||||
"proposal_id": 1,
|
||||
"type": "feature",
|
||||
"title": "Add login",
|
||||
"created_at": "2026-03-01",
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "essential", "list", "--token", "fake", "--proposal", "PRJ-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
var resp interface{}
|
||||
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialCreate_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"essential_code": "ESS-001",
|
||||
"title": "Add login",
|
||||
"type": "feature",
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
|
||||
"--proposal", "PRJ-001", "--title", "Add login", "--type", "feature")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "essential created") {
|
||||
t.Errorf("expected 'essential created' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialUpdate_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "PATCH" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
|
||||
"ESS-001", "--proposal", "PRJ-001", "--title", "Updated title")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "essential updated") {
|
||||
t.Errorf("expected 'essential updated' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEssentialDelete_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "DELETE" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake",
|
||||
"ESS-001", "--proposal", "PRJ-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "essential deleted") {
|
||||
t.Errorf("expected 'essential deleted' success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Proposal Accept tests ---
|
||||
|
||||
func TestProposalAccept_MissingMilestone(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", "PRJ-001")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing --milestone")
|
||||
}
|
||||
if !strings.Contains(out, "--milestone") {
|
||||
t.Errorf("expected --milestone error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalAccept_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/accept":
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["milestone_code"] != "MS-001" {
|
||||
t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"])
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "PRJ-001",
|
||||
"status": "Accepted",
|
||||
"tasks": []interface{}{},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake",
|
||||
"PRJ-001", "--milestone", "MS-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "proposal accepted") {
|
||||
t.Errorf("expected proposal accepted success message; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalAccept_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "PRJ-001",
|
||||
"status": "Accepted",
|
||||
"tasks": []interface{}{
|
||||
map[string]interface{}{"code": "TASK-1", "type": "story/feature"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "accept", "--token", "fake",
|
||||
"PRJ-001", "--milestone", "MS-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %s", out)
|
||||
}
|
||||
if resp["status"] != "Accepted" {
|
||||
t.Errorf("expected status=Accepted; got: %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Story restricted tests ---
|
||||
|
||||
func TestTaskCreate_StoryRestricted(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
// Try to create a story/feature directly
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
|
||||
"--type", "story/feature", "--title", "My story", "--project", "PRJ-001")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for restricted story creation")
|
||||
}
|
||||
if !strings.Contains(out, "restricted") && !strings.Contains(out, "proposal accept") {
|
||||
t.Errorf("expected restricted error mentioning proposal accept; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("server should not be called for restricted story type")
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
// Just "story" without slash should also be blocked
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
|
||||
"--type", "story", "--title", "My story", "--project", "PRJ-001")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for restricted story type")
|
||||
}
|
||||
if !strings.Contains(out, "restricted") {
|
||||
t.Errorf("expected restricted error; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Proposal list tests ---
|
||||
|
||||
func TestProposalList_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"code": "PRJ-001",
|
||||
"title": "My Proposal",
|
||||
"status": "Open",
|
||||
"project_code": "PROJ-001",
|
||||
"created_by": "alice",
|
||||
"created_at": "2026-03-01",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake", "--project", "PROJ-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "PRJ-001") {
|
||||
t.Errorf("expected proposal code in output; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposalList_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"code": "PRJ-001",
|
||||
"title": "My Proposal",
|
||||
"status": "Open",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeTestConfig(t, tmpDir, server.URL)
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake", "--project", "PROJ-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
var resp interface{}
|
||||
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||
@@ -23,11 +24,44 @@ type proposeResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type projectLookup struct {
|
||||
ID int `json:"id"`
|
||||
ProjectCode string `json:"project_code"`
|
||||
}
|
||||
|
||||
func resolveProposalProject(c *client.Client, proposalCode string) string {
|
||||
data, err := c.Get("/projects")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var projects []projectLookup
|
||||
if err := json.Unmarshal(data, &projects); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, p := range projects {
|
||||
if p.ProjectCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil {
|
||||
return p.ProjectCode
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func proposalPath(c *client.Client, proposalCode string) string {
|
||||
if project := resolveProposalProject(c, proposalCode); project != "" {
|
||||
return "/projects/" + project + "/proposals/" + proposalCode
|
||||
}
|
||||
return "/proposes/" + proposalCode
|
||||
}
|
||||
|
||||
// RunProposeList implements `hf propose list --project <project-code>`.
|
||||
func RunProposeList(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
query := ""
|
||||
project := ""
|
||||
query := url.Values{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--project":
|
||||
@@ -35,32 +69,39 @@ func RunProposeList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
project = args[i]
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "status", args[i])
|
||||
query.Set("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])
|
||||
query.Set("order_by", args[i])
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
legacyPath := false
|
||||
if project == "" {
|
||||
legacyPath = true
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
path := "/proposes"
|
||||
if query != "" {
|
||||
path += "?" + query
|
||||
path := "/projects/" + project + "/proposals"
|
||||
if legacyPath {
|
||||
path = "/proposes"
|
||||
}
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
data, err := c.Get(path)
|
||||
if err != nil {
|
||||
@@ -105,7 +146,7 @@ func RunProposeGet(proposeCode, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/proposes/" + proposeCode)
|
||||
data, err := c.Get(proposalPath(c, proposeCode))
|
||||
if err != nil {
|
||||
output.Errorf("failed to get proposal: %v", err)
|
||||
}
|
||||
@@ -178,9 +219,8 @@ func RunProposeCreate(args []string, tokenFlag string) {
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"project_code": project,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
@@ -193,7 +233,7 @@ func RunProposeCreate(args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/proposes", bytes.NewReader(body))
|
||||
data, err := c.Post("/projects/"+project+"/proposals", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create proposal: %v", err)
|
||||
}
|
||||
@@ -253,7 +293,7 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Patch("/proposes/"+proposeCode, bytes.NewReader(body))
|
||||
_, err = c.Patch(proposalPath(c, proposeCode), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to update proposal: %v", err)
|
||||
}
|
||||
@@ -261,7 +301,22 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
|
||||
fmt.Printf("proposal updated: %s\n", proposeCode)
|
||||
}
|
||||
|
||||
// RunProposeAccept implements `hf propose accept <propose-code> --milestone <milestone-code>`.
|
||||
// acceptResponse holds the accept result including generated tasks.
|
||||
type acceptResponse struct {
|
||||
ProposalCode string `json:"proposal_code"`
|
||||
Status string `json:"status"`
|
||||
GeneratedTasks []generatedTask `json:"generated_tasks"`
|
||||
}
|
||||
|
||||
type generatedTask struct {
|
||||
TaskID int `json:"task_id"`
|
||||
TaskCode *string `json:"task_code"`
|
||||
Title string `json:"title"`
|
||||
TaskType string `json:"task_type"`
|
||||
TaskSubtype *string `json:"task_subtype"`
|
||||
}
|
||||
|
||||
// RunProposeAccept implements `hf proposal accept <proposal-code> --milestone <milestone-code>`.
|
||||
func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
@@ -280,7 +335,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
|
||||
}
|
||||
|
||||
if milestone == "" {
|
||||
output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>")
|
||||
output.Error("usage: hf proposal accept <proposal-code> --milestone <milestone-code>")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
@@ -296,12 +351,38 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body))
|
||||
data, err := c.Post(proposalPath(c, proposeCode)+"/accept", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to accept proposal: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("proposal accepted: %s\n", proposeCode)
|
||||
|
||||
// Try to parse and display generated tasks
|
||||
var resp acceptResponse
|
||||
if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 {
|
||||
fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks))
|
||||
for _, gt := range resp.GeneratedTasks {
|
||||
code := "(no task_code)"
|
||||
if gt.TaskCode != nil {
|
||||
code = *gt.TaskCode
|
||||
}
|
||||
subtype := ""
|
||||
if gt.TaskSubtype != nil {
|
||||
subtype = "/" + *gt.TaskSubtype
|
||||
}
|
||||
fmt.Printf(" %s %s%s %s\n", code, gt.TaskType, subtype, gt.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunProposeReject implements `hf propose reject <propose-code>`.
|
||||
@@ -339,7 +420,7 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Post("/proposes/"+proposeCode+"/reject", body)
|
||||
_, err = c.Post(proposalPath(c, proposeCode)+"/reject", body)
|
||||
if err != nil {
|
||||
output.Errorf("failed to reject proposal: %v", err)
|
||||
}
|
||||
@@ -356,7 +437,7 @@ func RunProposeReopen(proposeCode, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Post("/proposes/"+proposeCode+"/reopen", nil)
|
||||
_, err = c.Post(proposalPath(c, proposeCode)+"/reopen", nil)
|
||||
if err != nil {
|
||||
output.Errorf("failed to reopen proposal: %v", err)
|
||||
}
|
||||
|
||||
@@ -38,13 +38,13 @@ func RunTaskList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--milestone":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--milestone requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "milestone", args[i])
|
||||
query = appendQuery(query, "milestone_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
@@ -228,6 +228,11 @@ func RunTaskCreate(args []string, tokenFlag string) {
|
||||
output.Error("usage: hf task create --project <project-code> --title <title>")
|
||||
}
|
||||
|
||||
// story/* types are restricted — must be created via `hf proposal accept`
|
||||
if taskType == "story" || (len(taskType) > 6 && taskType[:6] == "story/") {
|
||||
output.Error("story tasks are restricted and cannot be created directly.\nUse 'hf proposal accept <proposal-code> --milestone <milestone-code>' to generate story tasks from a proposal.")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"project_code": project,
|
||||
"title": title,
|
||||
@@ -421,7 +426,7 @@ func RunTaskSearch(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
@@ -23,6 +25,7 @@ type userResponse struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
RoleID *int `json:"role_id"`
|
||||
RoleName *string `json:"role_name"`
|
||||
DiscordUserID *string `json:"discord_user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -137,10 +140,41 @@ type userCreatePayload struct {
|
||||
Email string `json:"email"`
|
||||
FullName *string `json:"full_name,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
DiscordUserID *string `json:"discord_user_id,omitempty"`
|
||||
}
|
||||
|
||||
func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, error) {
|
||||
if strings.TrimSpace(explicit) != "" {
|
||||
return strings.TrimSpace(explicit), true, nil
|
||||
}
|
||||
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
|
||||
agentVerify := strings.TrimSpace(os.Getenv("AGENT_VERIFY"))
|
||||
if agentID == "" || agentVerify == "" {
|
||||
if requireEnv {
|
||||
return "", false, fmt.Errorf("discord id not provided and AGENT_ID/AGENT_VERIFY are missing")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
cmd := exec.Command("ego-mgr", "get", "discord-id")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if requireEnv {
|
||||
return "", false, fmt.Errorf("failed to resolve discord id from ego-mgr: %w", err)
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
value := strings.TrimSpace(string(out))
|
||||
if value == "" {
|
||||
if requireEnv {
|
||||
return "", false, fmt.Errorf("ego-mgr returned empty discord id")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// RunUserCreate implements `hf user create`.
|
||||
func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) {
|
||||
func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag string) {
|
||||
// Resolve account-manager token
|
||||
var accMgrToken string
|
||||
if mode.IsPaddedCell() {
|
||||
@@ -181,6 +215,11 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
||||
Email: email,
|
||||
Password: &password,
|
||||
}
|
||||
if resolvedDiscordID, ok, err := maybeResolveDiscordUserID(discordUserID, false); err != nil {
|
||||
output.Errorf("failed to resolve discord user id: %v", err)
|
||||
} else if ok {
|
||||
payload.DiscordUserID = &resolvedDiscordID
|
||||
}
|
||||
if fullName != "" {
|
||||
payload.FullName = &fullName
|
||||
}
|
||||
@@ -194,7 +233,7 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, accMgrToken)
|
||||
c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken)
|
||||
data, err := c.Post("/users", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create user: %v", err)
|
||||
@@ -216,6 +255,28 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
||||
fmt.Printf("user created: %s\n", u.Username)
|
||||
}
|
||||
|
||||
// RunUserUpdateDiscordID updates a user's discord_user_id field.
|
||||
func RunUserUpdateDiscordID(username, discordUserID, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
resolvedDiscordID, _, err := maybeResolveDiscordUserID(discordUserID, true)
|
||||
if err != nil {
|
||||
output.Errorf("failed to resolve discord user id: %v", err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]interface{}{"discord_user_id": resolvedDiscordID})
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
if _, err := c.Patch("/users/"+username, bytes.NewReader(body)); err != nil {
|
||||
output.Errorf("failed to update discord id: %v", err)
|
||||
}
|
||||
fmt.Printf("discord id updated: %s\n", username)
|
||||
}
|
||||
|
||||
// RunUserUpdate implements `hf user update <username>`.
|
||||
func RunUserUpdate(username string, args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
@@ -329,3 +390,60 @@ func RunUserDelete(username, tokenFlag string) {
|
||||
}
|
||||
fmt.Printf("user deleted: %s\n", username)
|
||||
}
|
||||
|
||||
// resetAPIKeyResponse matches the backend reset-apikey response.
|
||||
type resetAPIKeyResponse struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
APIKey string `json:"api_key"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// RunUserResetAPIKey implements `hf user reset-apikey <username>`.
|
||||
func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
|
||||
// Try acc-mgr-token first (allows provisioning without existing user token)
|
||||
var c *client.Client
|
||||
if accMgrTokenFlag != "" {
|
||||
c = client.NewWithAPIKey(cfg.BaseURL, accMgrTokenFlag)
|
||||
} else if mode.IsPaddedCell() {
|
||||
if tok, err := passmgr.GetAccountManagerToken(); err == nil && tok != "" {
|
||||
c = client.NewWithAPIKey(cfg.BaseURL, tok)
|
||||
} else {
|
||||
token := ResolveToken(tokenFlag)
|
||||
c = client.New(cfg.BaseURL, token)
|
||||
}
|
||||
} else {
|
||||
token := ResolveToken(tokenFlag)
|
||||
c = client.New(cfg.BaseURL, token)
|
||||
}
|
||||
|
||||
data, err := c.Post("/users/"+username+"/reset-apikey", nil)
|
||||
if err != nil {
|
||||
output.Errorf("failed to reset API key: %v", err)
|
||||
}
|
||||
|
||||
if output.JSONMode {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
output.Errorf("invalid JSON response: %v", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return
|
||||
}
|
||||
|
||||
var r resetAPIKeyResponse
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
fmt.Printf("API key reset for: %s\n", username)
|
||||
return
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"username", r.Username,
|
||||
"api-key", r.APIKey,
|
||||
"message", r.Message,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
204
internal/help/leaf.go
Normal file
204
internal/help/leaf.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package help
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
|
||||
)
|
||||
|
||||
type leafHelp struct {
|
||||
Summary string
|
||||
Usage []string
|
||||
Flags []string
|
||||
Notes []string
|
||||
}
|
||||
|
||||
func RenderLeafHelp(group, cmd string) (string, bool) {
|
||||
spec, ok := leafHelpSpec(group, cmd)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
name := strings.TrimSpace(strings.TrimSpace(group + " " + cmd))
|
||||
name = strings.ReplaceAll(name, "/", " ")
|
||||
b.WriteString(fmt.Sprintf("hf %s - %s\n", name, spec.Summary))
|
||||
b.WriteString("\nUsage:\n")
|
||||
for _, line := range spec.Usage {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
if len(spec.Flags) > 0 {
|
||||
b.WriteString("\nFlags:\n")
|
||||
for _, line := range spec.Flags {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
if len(spec.Notes) > 0 {
|
||||
b.WriteString("\nNotes:\n")
|
||||
for _, line := range spec.Notes {
|
||||
b.WriteString(" - " + line + "\n")
|
||||
}
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
func authFlagHelp() []string {
|
||||
if mode.IsPaddedCell() {
|
||||
return []string{
|
||||
"--json Output in JSON format",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"--token <token> HarborForge API token (required in manual mode)",
|
||||
"--json Output in JSON format",
|
||||
}
|
||||
}
|
||||
|
||||
func accountManagerFlagHelp() []string {
|
||||
flags := []string{
|
||||
"--user <username> Username to create",
|
||||
"--pass <password> Initial password",
|
||||
"--email <email> Email address (defaults to <user>@harborforge.local)",
|
||||
"--full-name <name> Full name",
|
||||
"--json Output in JSON format",
|
||||
}
|
||||
if mode.IsPaddedCell() {
|
||||
return flags
|
||||
}
|
||||
return append([]string{"--acc-mgr-token <token> Account-manager token (required in manual mode)"}, flags...)
|
||||
}
|
||||
|
||||
func leafHelpSpec(group, cmd string) (leafHelp, bool) {
|
||||
specs := map[string]leafHelp{
|
||||
"config/show": {
|
||||
Summary: "View current CLI configuration",
|
||||
Usage: []string{"hf config"},
|
||||
Flags: []string{"--json Output in JSON format"},
|
||||
Notes: []string{
|
||||
"Configuration is stored in .hf-config.json next to the hf binary.",
|
||||
},
|
||||
},
|
||||
"version": {
|
||||
Summary: "Show CLI version",
|
||||
Usage: []string{"hf version"},
|
||||
Flags: []string{"--json Output in JSON format"},
|
||||
},
|
||||
"health": {
|
||||
Summary: "Check HarborForge API health",
|
||||
Usage: []string{"hf health", "hf health --json"},
|
||||
Flags: authFlagHelp(),
|
||||
},
|
||||
"config/url": {
|
||||
Summary: "Set HarborForge API base URL",
|
||||
Usage: []string{"hf config --url <hf-url>"},
|
||||
Notes: []string{"Writes base-url into .hf-config.json next to the hf binary."},
|
||||
},
|
||||
"config/acc-mgr-token": {
|
||||
Summary: "Store the account-manager token via secret-mgr",
|
||||
Usage: []string{"hf config --acc-mgr-token <token>"},
|
||||
Notes: []string{"Only available in padded-cell mode with secret-mgr installed."},
|
||||
},
|
||||
"user/create": {
|
||||
Summary: "Create a user account",
|
||||
Usage: []string{"hf user create --user <username> [--pass <password>] [--email <email>] [--full-name <name>]"},
|
||||
Flags: accountManagerFlagHelp(),
|
||||
Notes: []string{
|
||||
"This command uses the account-manager token flow, not the normal user token flow.",
|
||||
"In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to secret-mgr.",
|
||||
},
|
||||
},
|
||||
"user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()},
|
||||
"user/get": {Summary: "Show a user by username", Usage: []string{"hf user get <username>"}, Flags: authFlagHelp()},
|
||||
"user/update": {Summary: "Update a user", Usage: []string{"hf user update <username> [--email <email>] [--full-name <name>] [--pass <password>] [--active <true|false>]"}, Flags: authFlagHelp()},
|
||||
"user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()},
|
||||
"user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()},
|
||||
"user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()},
|
||||
"user/reset-apikey": {Summary: "Reset a user's API key", Usage: []string{"hf user reset-apikey <username>"}, Flags: authFlagHelp(), Notes: []string{"The new API key is shown once and cannot be retrieved again."}},
|
||||
"role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()},
|
||||
"role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()},
|
||||
"role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()},
|
||||
"role/update": {Summary: "Update a role", Usage: []string{"hf role update <role-name> [--desc <desc>]"}, Flags: authFlagHelp()},
|
||||
"role/delete": {Summary: "Delete a role", Usage: []string{"hf role delete <role-name>"}, Flags: authFlagHelp()},
|
||||
"role/set-permissions": {Summary: "Replace role permissions", Usage: []string{"hf role set-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()},
|
||||
"role/add-permissions": {Summary: "Add permissions to a role", Usage: []string{"hf role add-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()},
|
||||
"role/remove-permissions": {Summary: "Remove permissions from a role", Usage: []string{"hf role remove-permissions <role-name> --permission <perm> [--permission <perm> ...]"}, Flags: authFlagHelp()},
|
||||
"permission/list": {Summary: "List permissions", Usage: []string{"hf permission list"}, Flags: authFlagHelp()},
|
||||
"project/list": {Summary: "List projects", Usage: []string{"hf project list [--owner <username>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
|
||||
"project/get": {Summary: "Show a project by code", Usage: []string{"hf project get <project-code>"}, Flags: authFlagHelp()},
|
||||
"project/create": {Summary: "Create a project", Usage: []string{"hf project create --name <name> [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()},
|
||||
"project/update": {Summary: "Update a project", Usage: []string{"hf project update <project-code> [--name <name>] [--desc <desc>] [--repo <repo>]"}, Flags: authFlagHelp()},
|
||||
"project/delete": {Summary: "Delete a project", Usage: []string{"hf project delete <project-code>"}, Flags: authFlagHelp()},
|
||||
"project/members": {Summary: "List project members", Usage: []string{"hf project members <project-code>"}, Flags: authFlagHelp()},
|
||||
"project/add-member": {Summary: "Add a member to a project", Usage: []string{"hf project add-member <project-code> --user <username> --role <role-name>"}, Flags: authFlagHelp()},
|
||||
"project/remove-member": {Summary: "Remove a member from a project", Usage: []string{"hf project remove-member <project-code> --user <username>"}, Flags: authFlagHelp()},
|
||||
"milestone/list": {Summary: "List milestones", Usage: []string{"hf milestone list --project <project-code> [--status <status>] [--order-by <due-date|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
|
||||
"milestone/get": {Summary: "Show a milestone by code", Usage: []string{"hf milestone get <milestone-code>"}, Flags: authFlagHelp()},
|
||||
"milestone/create": {Summary: "Create a milestone", Usage: []string{"hf milestone create --project <project-code> --title <title> [--desc <desc>] [--due <date>]"}, Flags: authFlagHelp()},
|
||||
"milestone/update": {Summary: "Update a milestone", Usage: []string{"hf milestone update <milestone-code> [--title <title>] [--desc <desc>] [--status <status>] [--due <date>]"}, Flags: authFlagHelp()},
|
||||
"milestone/delete": {Summary: "Delete a milestone", Usage: []string{"hf milestone delete <milestone-code>"}, Flags: authFlagHelp()},
|
||||
"milestone/progress": {Summary: "Show milestone progress", Usage: []string{"hf milestone progress <milestone-code>"}, Flags: authFlagHelp()},
|
||||
"task/list": {Summary: "List tasks", Usage: []string{"hf task list [--project <project-code>] [--milestone <milestone-code>] [--status <status>] [--taken-by <me|null|username>] [--due-today <true|false>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
|
||||
"task/get": {Summary: "Show a task by code", Usage: []string{"hf task get <task-code>"}, Flags: authFlagHelp()},
|
||||
"task/create": {Summary: "Create a task", Usage: []string{"hf task create --project <project-code> --title <title> [--milestone <milestone-code>] [--type <type>] [--priority <priority>] [--desc <desc>]"}, Flags: authFlagHelp()},
|
||||
"task/update": {Summary: "Update a task", Usage: []string{"hf task update <task-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>] [--assignee <username|null>]"}, Flags: authFlagHelp()},
|
||||
"task/transition": {Summary: "Transition a task to a new status", Usage: []string{"hf task transition <task-code> <status>"}, Flags: authFlagHelp()},
|
||||
"task/take": {Summary: "Assign a task to the current user", Usage: []string{"hf task take <task-code>"}, Flags: authFlagHelp()},
|
||||
"task/delete": {Summary: "Delete a task", Usage: []string{"hf task delete <task-code>"}, Flags: authFlagHelp()},
|
||||
"task/search": {Summary: "Search tasks", Usage: []string{"hf task search --query <text> [--project <project-code>] [--status <status>]"}, Flags: authFlagHelp()},
|
||||
"meeting/list": {Summary: "List meetings", Usage: []string{"hf meeting list [--project <project-code>] [--status <status>] [--order-by <created|due-date|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
|
||||
"meeting/get": {Summary: "Show a meeting by code", Usage: []string{"hf meeting get <meeting-code>"}, Flags: authFlagHelp()},
|
||||
"meeting/create": {Summary: "Create a meeting", Usage: []string{"hf meeting create --project <project-code> --title <title> [--milestone <milestone-code>] [--desc <desc>] [--time <datetime>]"}, Flags: authFlagHelp()},
|
||||
"meeting/update": {Summary: "Update a meeting", Usage: []string{"hf meeting update <meeting-code> [--title <title>] [--desc <desc>] [--status <status>] [--time <datetime>]"}, Flags: authFlagHelp()},
|
||||
"meeting/attend": {Summary: "Attend a meeting", Usage: []string{"hf meeting attend <meeting-code>"}, Flags: authFlagHelp()},
|
||||
"meeting/delete": {Summary: "Delete a meeting", Usage: []string{"hf meeting delete <meeting-code>"}, Flags: authFlagHelp()},
|
||||
"support/list": {Summary: "List support tickets", Usage: []string{"hf support list [--taken-by <me|null|username>] [--status <status>] [--order-by <due-date|priority|created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
|
||||
"support/get": {Summary: "Show a support ticket by code", Usage: []string{"hf support get <support-code>"}, Flags: authFlagHelp()},
|
||||
"support/create": {Summary: "Create a support ticket", Usage: []string{"hf support create --title <title> [--project <project-code>] [--desc <desc>] [--priority <priority>]"}, Flags: authFlagHelp()},
|
||||
"support/update": {Summary: "Update a support ticket", Usage: []string{"hf support update <support-code> [--title <title>] [--desc <desc>] [--status <status>] [--priority <priority>]"}, Flags: authFlagHelp()},
|
||||
"support/take": {Summary: "Assign a support ticket to the current user", Usage: []string{"hf support take <support-code>"}, Flags: authFlagHelp()},
|
||||
"support/transition": {Summary: "Transition a support ticket to a new status", Usage: []string{"hf support transition <support-code> <status>"}, Flags: authFlagHelp()},
|
||||
"support/delete": {Summary: "Delete a support ticket", Usage: []string{"hf support delete <support-code>"}, Flags: authFlagHelp()},
|
||||
"proposal/list": {Summary: "List proposals", Usage: []string{"hf proposal list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
|
||||
"proposal/get": {Summary: "Show a proposal by code", Usage: []string{"hf proposal get <proposal-code>"}, Flags: authFlagHelp()},
|
||||
"proposal/create": {Summary: "Create a proposal", Usage: []string{"hf proposal create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()},
|
||||
"proposal/update": {Summary: "Update a proposal", Usage: []string{"hf proposal update <proposal-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()},
|
||||
"proposal/accept": {Summary: "Accept a proposal and generate story tasks", Usage: []string{"hf proposal accept <proposal-code> --milestone <milestone-code>"}, Flags: authFlagHelp(), Notes: []string{"Accept generates story/* tasks from all essentials under the proposal."}},
|
||||
"proposal/reject": {Summary: "Reject a proposal", Usage: []string{"hf proposal reject <proposal-code> [--reason <reason>]"}, Flags: authFlagHelp()},
|
||||
"proposal/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf proposal reopen <proposal-code>"}, Flags: authFlagHelp()},
|
||||
"proposal/essential": {Summary: "Manage proposal essentials", Usage: []string{"hf proposal essential list --proposal <proposal-code>", "hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]", "hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]", "hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()},
|
||||
"proposal/essential/list": {Summary: "List essentials for a proposal", Usage: []string{"hf proposal essential list --proposal <proposal-code>"}, Flags: authFlagHelp()},
|
||||
"proposal/essential/create": {Summary: "Create an essential", Usage: []string{"hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]"}, Flags: authFlagHelp()},
|
||||
"proposal/essential/update": {Summary: "Update an essential", Usage: []string{"hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]"}, Flags: authFlagHelp()},
|
||||
"proposal/essential/delete": {Summary: "Delete an essential", Usage: []string{"hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()},
|
||||
"calendar/schedule": {Summary: "Create a one-off calendar slot", Usage: []string{"hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]"}, Flags: authFlagHelp()},
|
||||
"calendar/show": {Summary: "Show slots for a day", Usage: []string{"hf calendar show [--date <yyyy-mm-dd>]", "hf calendar show --json [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
|
||||
"calendar/edit": {Summary: "Edit a calendar slot", Usage: []string{"hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--job <code>] [--scheduled-at <HH:mm>]"}, Flags: authFlagHelp(), Notes: []string{"For virtual plan slots, pass the virtual id like plan-12-2026-04-01."}},
|
||||
"calendar/cancel": {Summary: "Cancel a calendar slot", Usage: []string{"hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
|
||||
"calendar/date-list": {Summary: "List future dates with materialized slots", Usage: []string{"hf calendar date-list", "hf calendar date-list --json"}, Flags: authFlagHelp()},
|
||||
"calendar/plan-schedule": {Summary: "Create a recurring schedule plan", Usage: []string{"hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]"}, Flags: authFlagHelp()},
|
||||
"calendar/plan-list": {Summary: "List recurring schedule plans", Usage: []string{"hf calendar plan-list", "hf calendar plan-list --json"}, Flags: authFlagHelp()},
|
||||
"calendar/plan-edit": {Summary: "Edit a recurring schedule plan", Usage: []string{"hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]"}, Flags: authFlagHelp()},
|
||||
"calendar/plan-cancel": {Summary: "Cancel a recurring schedule plan", Usage: []string{"hf calendar plan-cancel <plan-id>"}, Flags: authFlagHelp()},
|
||||
"comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()},
|
||||
"comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()},
|
||||
"worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
|
||||
"worklog/list": {Summary: "List work logs by task or user", Usage: []string{"hf worklog list [--task <task-code>] [--user <username>]"}, Flags: authFlagHelp()},
|
||||
"monitor/overview": {Summary: "Show monitor overview", Usage: []string{"hf monitor overview"}, Flags: authFlagHelp()},
|
||||
"monitor/server": {Summary: "Manage monitor servers", Usage: []string{"hf monitor server list", "hf monitor server get <identifier>", "hf monitor server create --identifier <identifier> [--name <display-name>]", "hf monitor server delete <identifier>"}, Flags: authFlagHelp()},
|
||||
"monitor/server/list": {Summary: "List monitor servers", Usage: []string{"hf monitor server list"}, Flags: authFlagHelp()},
|
||||
"monitor/server/get": {Summary: "Show a monitor server by identifier", Usage: []string{"hf monitor server get <identifier>"}, Flags: authFlagHelp()},
|
||||
"monitor/server/create": {Summary: "Create a monitor server", Usage: []string{"hf monitor server create --identifier <identifier> [--name <display-name>]"}, Flags: authFlagHelp()},
|
||||
"monitor/server/delete": {Summary: "Delete a monitor server", Usage: []string{"hf monitor server delete <identifier>"}, Flags: authFlagHelp()},
|
||||
"monitor/api-key": {Summary: "Manage monitor API keys", Usage: []string{"hf monitor api-key generate <identifier>", "hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()},
|
||||
"monitor/api-key/generate": {Summary: "Generate a monitor API key", Usage: []string{"hf monitor api-key generate <identifier>"}, Flags: authFlagHelp()},
|
||||
"monitor/api-key/revoke": {Summary: "Revoke a monitor API key", Usage: []string{"hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()},
|
||||
}
|
||||
|
||||
if group == "" {
|
||||
spec, ok := specs[cmd]
|
||||
return spec, ok
|
||||
}
|
||||
spec, ok := specs[group+"/"+cmd]
|
||||
return spec, ok
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package help
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||
@@ -39,6 +40,7 @@ func CommandSurface() []Group {
|
||||
{Name: "activate", Description: "Activate a user", Permitted: has(perms, "user.manage")},
|
||||
{Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")},
|
||||
{Name: "delete", Description: "Delete a user", Permitted: has(perms, "user.manage")},
|
||||
{Name: "reset-apikey", Description: "Reset a user's API key", Permitted: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -126,16 +128,48 @@ func CommandSurface() []Group {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "propose",
|
||||
Name: "proposal",
|
||||
Description: "Manage proposals",
|
||||
SubCommands: []Command{
|
||||
{Name: "list", Description: "List proposals", Permitted: has(perms, "project.read")},
|
||||
{Name: "get", Description: "Show a proposal by code", Permitted: has(perms, "project.read")},
|
||||
{Name: "create", Description: "Create a proposal", Permitted: has(perms, "task.create")},
|
||||
{Name: "update", Description: "Update a proposal", Permitted: has(perms, "task.write")},
|
||||
{Name: "accept", Description: "Accept a proposal", Permitted: has(perms, "propose.accept")},
|
||||
{Name: "accept", Description: "Accept a proposal and generate story tasks", Permitted: has(perms, "propose.accept")},
|
||||
{Name: "reject", Description: "Reject a proposal", Permitted: has(perms, "propose.reject")},
|
||||
{Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")},
|
||||
{Name: "essential", Description: "Manage proposal essentials", Permitted: has(perms, "task.create")},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "calendar",
|
||||
Description: "Manage calendar slots and plans",
|
||||
SubCommands: []Command{
|
||||
{Name: "schedule", Description: "Create a one-off slot", Permitted: has(perms, "task.create")},
|
||||
{Name: "show", Description: "Show slots for a day", Permitted: has(perms, "task.read")},
|
||||
{Name: "edit", Description: "Edit a slot", Permitted: has(perms, "task.write")},
|
||||
{Name: "cancel", Description: "Cancel a slot", Permitted: has(perms, "task.write")},
|
||||
{Name: "date-list", Description: "List dates with materialized slots", Permitted: has(perms, "task.read")},
|
||||
{Name: "plan-schedule", Description: "Create a recurring plan", Permitted: has(perms, "task.create")},
|
||||
{Name: "plan-list", Description: "List plans", Permitted: has(perms, "task.read")},
|
||||
{Name: "plan-edit", Description: "Edit a plan", Permitted: has(perms, "task.write")},
|
||||
{Name: "plan-cancel", Description: "Cancel a plan", Permitted: has(perms, "task.write")},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comment",
|
||||
Description: "Manage task comments",
|
||||
SubCommands: []Command{
|
||||
{Name: "add", Description: "Add a comment to a task", Permitted: has(perms, "task.read")},
|
||||
{Name: "list", Description: "List comments for a task", Permitted: has(perms, "task.read")},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "worklog",
|
||||
Description: "Manage work logs",
|
||||
SubCommands: []Command{
|
||||
{Name: "add", Description: "Add a work log entry", Permitted: has(perms, "task.read")},
|
||||
{Name: "list", Description: "List work logs by task or user", Permitted: has(perms, "task.read")},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -191,6 +225,10 @@ func loadPermissionState(token string) permissionState {
|
||||
}
|
||||
|
||||
func has(state permissionState, perm string) bool {
|
||||
// Test/development bypass: HF_TEST_MODE=1 grants all permissions
|
||||
if os.Getenv("HF_TEST_MODE") == "1" {
|
||||
return true
|
||||
}
|
||||
if !state.Known {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type RuntimeMode int
|
||||
const (
|
||||
// ManualMode requires explicit --token / --acc-mgr-token flags.
|
||||
ManualMode RuntimeMode = iota
|
||||
// PaddedCellMode resolves secrets via pass_mgr automatically.
|
||||
// PaddedCellMode resolves secrets via secret-mgr automatically.
|
||||
PaddedCellMode
|
||||
)
|
||||
|
||||
@@ -21,11 +21,11 @@ var (
|
||||
detectOnce sync.Once
|
||||
)
|
||||
|
||||
// Detect checks whether pass_mgr is available and returns the runtime mode.
|
||||
// Detect checks whether secret-mgr is available and returns the runtime mode.
|
||||
// The result is cached after the first call.
|
||||
func Detect() RuntimeMode {
|
||||
detectOnce.Do(func() {
|
||||
_, err := exec.LookPath("pass_mgr")
|
||||
_, err := exec.LookPath("secret-mgr")
|
||||
if err == nil {
|
||||
detectedMode = PaddedCellMode
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package passmgr wraps calls to the pass_mgr binary for secret resolution.
|
||||
// Package passmgr wraps calls to the secret-mgr binary for secret resolution.
|
||||
package passmgr
|
||||
|
||||
import (
|
||||
@@ -7,49 +7,49 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetSecret calls: pass_mgr get-secret [--public] --key <key>
|
||||
// GetSecret calls: secret-mgr get-secret [--public] --key <key>
|
||||
func GetSecret(key string, public bool) (string, error) {
|
||||
args := []string{"get-secret"}
|
||||
if public {
|
||||
args = append(args, "--public")
|
||||
}
|
||||
args = append(args, "--key", key)
|
||||
out, err := exec.Command("pass_mgr", args...).Output()
|
||||
out, err := exec.Command("secret-mgr", args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err)
|
||||
return "", fmt.Errorf("secret-mgr get-secret --key %s failed: %w", key, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// SetSecret calls: pass_mgr set [--public] --key <key> --secret <secret>
|
||||
// SetSecret calls: secret-mgr set [--public] --key <key> --secret <secret>
|
||||
func SetSecret(key, secret string, public bool) error {
|
||||
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)
|
||||
if err := exec.Command("secret-mgr", args...).Run(); err != nil {
|
||||
return fmt.Errorf("secret-mgr set --key %s failed: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePassword calls: pass_mgr generate --key <key> --username <username>
|
||||
// GeneratePassword calls: secret-mgr generate --key <key> --username <username>
|
||||
func GeneratePassword(key, username string) (string, error) {
|
||||
args := []string{"generate", "--key", key, "--username", username}
|
||||
out, err := exec.Command("pass_mgr", args...).Output()
|
||||
out, err := exec.Command("secret-mgr", args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pass_mgr generate failed: %w", err)
|
||||
return "", fmt.Errorf("secret-mgr generate failed: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// GetToken retrieves the normal hf-token via pass_mgr.
|
||||
// GetToken retrieves the normal hf-token via secret-mgr.
|
||||
func GetToken() (string, error) {
|
||||
return GetSecret("hf-token", false)
|
||||
}
|
||||
|
||||
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr.
|
||||
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via secret-mgr.
|
||||
func GetAccountManagerToken() (string, error) {
|
||||
return GetSecret("hf-acc-mgr-token", true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user