Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ Run `flashduty <group> --help` for the commands in a group, and `flashduty <grou
| Platform | `account`, `member`, `person`, `team`, `role`, `audit` |
| CLI | `login`, `whoami`, `config`, `update`, `version`, `completion` |

### Integration scope

Use `flashduty integration …` for public integrations managed in the integration center. Use `flashduty channel integration-…` for dedicated alert integrations owned by one channel:

```bash
flashduty integration list --fields integration_id,name,plugin_type
flashduty channel integration-list --channel-id 20001 --fields integration_id,channel_id,name,plugin_type
```

Public integrations require integration permissions; dedicated integrations require channel permissions and access to the owning channel. Choose by ownership, not by the numeric ID. Both use the management App Key, and a permission failure never switches command families. The bundled Agent Skill explains selection, discovery, and confirmed changes.

### Request bodies

Generated commands expose each top-level request field as a typed flag, and take the full JSON body through `--data` (`--data -` reads stdin). Positional arguments and typed flags override the matching keys in `--data`, so nested objects and arrays go in `--data` while scalars stay readable:
Expand Down
11 changes: 11 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ APP Key 的创建方法见 [API 参考](https://docs.flashduty.com/zh/openapi/in
| 平台 | `account`、`member`、`person`、`team`、`role`、`audit` |
| CLI 自身 | `login`、`whoami`、`config`、`update`、`version`、`completion` |

### 选择集成类型

集成中心管理的公共集成使用 `flashduty integration …`;属于某个协作空间的专属告警集成使用 `flashduty channel integration-…`:

```bash
flashduty integration list --fields integration_id,name,plugin_type
flashduty channel integration-list --channel-id 20001 --fields integration_id,channel_id,name,plugin_type
```

公共集成需要集成中心权限;专属集成需要协作空间权限以及对目标空间的访问或管理权限。按实际归属选择,不能只凭数字 ID 判断类型。两类命令均使用管理用的 App Key,权限失败时不会自动切换接口。随 CLI 分发的 Agent Skill 提供类型选择、查询和经确认后修改配置的指引。

### 请求体

生成的命令把请求的每个顶层字段做成带类型的 flag,完整 JSON 请求体通过 `--data` 传入(`--data -` 从 stdin 读取)。位置参数和 flag 会覆盖 `--data` 中的同名字段,所以嵌套对象和数组放进 `--data`,标量字段用 flag:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
go 1.26.0

require (
github.com/flashcatcloud/go-flashduty v0.15.20
github.com/flashcatcloud/go-flashduty v0.15.21-0.20261027024437-bab720b88e34
github.com/mattn/go-runewidth v0.0.30
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/flashcatcloud/go-flashduty v0.15.20 h1:VFWxpansyQRcwPS5F0hzM+P3zyiaFcaRsMkVpi/9SPE=
github.com/flashcatcloud/go-flashduty v0.15.20/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.21-0.20261027024437-bab720b88e34 h1:Cje7u+AOH3qL/PyxV97Eh2tVTFnDSG3tB21Q2HZwT3o=
github.com/flashcatcloud/go-flashduty v0.15.21-0.20261027024437-bab720b88e34/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
Expand Down
137 changes: 137 additions & 0 deletions internal/cli/dedicated_integrations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package cli

import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"

"github.com/flashcatcloud/go-flashduty"
)

func TestDedicatedIntegrationCommands(t *testing.T) {
for _, tc := range []struct {
verb string
args []string
path string
body string
}{
{"list", []string{"--channel-id", "20001", "--page", "2", "--limit", "10", "--plugin-type", "standard.alert", "--status", "enabled"}, "list", `{"channel_id":20001,"p":2,"limit":10,"plugin_type":"standard.alert","status":"enabled"}`},
{"info", []string{"10001"}, "info", `{"integration_id":10001}`},
{"create", []string{"--channel-id", "20001", "--plugin-type", "standard.alert", "--name", "Primary", "--integration-key", "0123456789abcdef0123456789abcdef100", "--data", `{"settings":{"headers":{}}}`}, "create", `{"channel_id":20001,"plugin_type":"standard.alert","name":"Primary","integration_key":"0123456789abcdef0123456789abcdef100","settings":{"headers":{}}}`},
{"update", []string{"10001", "--description", ""}, "update", `{"integration_id":10001,"description":""}`},
{"delete", []string{"10001"}, "delete", `{"integration_id":10001}`},
{"enable", []string{"10001"}, "enable", `{"integration_id":10001}`},
{"disable", []string{"10001"}, "disable", `{"integration_id":10001}`},
{"key-rotate", []string{"10001"}, "key/rotate", `{"integration_id":10001}`},
} {
t.Run(tc.verb, func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
args := append([]string{"channel", "integration-" + tc.verb, "--json"}, tc.args...)
_, err := execCommand(args...)
if err != nil {
t.Fatal(err)
}
if stub.lastPath != "/channel/integration/"+tc.path || stub.requests != 1 {
t.Fatalf("path = %s, requests = %d", stub.lastPath, stub.requests)
}
var want map[string]any
err = json.Unmarshal([]byte(tc.body), &want)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(stub.lastBody, want) {
t.Fatalf("body = %#v, want %#v", stub.lastBody, want)
}
})
}
}

func TestDedicatedIntegrationListPreservesOwnershipAndKey(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{"items": []any{map[string]any{"integration_id": 10001, "channel_id": 20001, "name": "Primary", "integration_key": "fixture-key", "settings": map[string]any{"password": "******"}}}, "p": 1, "limit": 100, "total": 1}
out, err := execCommand("channel", "integration-list", "--channel-id", "20001", "--json")
if err != nil {
t.Fatal(err)
}
var got struct {
Items []struct {
IntegrationID int64 `json:"integration_id"`
ChannelID int64 `json:"channel_id"`
IntegrationKey string `json:"integration_key"`
Settings map[string]any `json:"settings"`
} `json:"items"`
Total int `json:"total"`
}
err = json.Unmarshal([]byte(out), &got)
if err != nil {
t.Fatal(err)
}
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("unexpected page: %s", out)
}
item := got.Items[0]
if item.IntegrationID != 10001 || item.ChannelID != 20001 || item.IntegrationKey != "fixture-key" || item.Settings["password"] != "******" {
t.Fatalf("response fields changed: %s", out)
}
}

func TestDedicatedIntegrationUpdatePreservesEmptyAndOmittedSettings(t *testing.T) {
for _, tc := range []struct {
body string
present bool
}{
{`{"integration_id":10001}`, false},
{`{"integration_id":10001,"settings":null}`, false},
{`{"integration_id":10001,"settings":{}}`, true},
} {
t.Run(tc.body, func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
_, err := execCommand("channel", "integration-update", "--data", tc.body, "--json")
if err != nil {
t.Fatal(err)
}
settings, present := stub.lastBody["settings"]
if present != tc.present {
t.Fatalf("settings presence = %v, want %v: %#v", present, tc.present, stub.lastBody)
}
if present && !reflect.DeepEqual(settings, map[string]any{}) {
t.Fatalf("empty settings changed: %#v", settings)
}
})
}
}

func TestDedicatedIntegrationListRequiresChannel(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
_, err := execCommand("channel", "integration-list", "--json")
if err == nil || stub.requests != 0 {
t.Fatalf("error = %v, requests = %d", err, stub.requests)
}
}

func TestDedicatedIntegrationForbiddenDoesNotCallPublicAPI(t *testing.T) {
saveAndResetGlobals(t)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/channel/integration/info" {
t.Errorf("unexpected fallback: %s", r.URL.Path)
}
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":{"code":"AccessDenied","message":"Channel access denied"}}`))
}))
t.Cleanup(server.Close)
newClientFn = func() (*flashduty.Client, error) {
return flashduty.NewClient("test-key", flashduty.WithBaseURL(server.URL))
}
_, err := execCommand("channel", "integration-info", "10001", "--json")
if flashduty.ErrorCodeOf(err) != flashduty.ErrorCodeAccessDenied || calls != 1 {
t.Fatalf("error = %v, calls = %d", err, calls)
}
}
2 changes: 1 addition & 1 deletion internal/cli/zz_generated_channels.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading