feat: centralize scattered configs and add dws config list
This commit is contained in:
@@ -17,9 +17,20 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CONFIG_DIR",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "覆盖默认配置目录 (~/.dws)",
|
||||
DefaultValue: "~/.dws",
|
||||
Example: "/opt/dws/config",
|
||||
})
|
||||
}
|
||||
|
||||
// Build-time variables injected via ldflags when available.
|
||||
var (
|
||||
buildTime = "unknown"
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "配置管理",
|
||||
Long: "管理 DWS CLI 的配置项。查看所有支持的环境变量及其当前值。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newConfigListCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出所有可用配置项",
|
||||
Long: "显示 DWS CLI 支持的全部环境变量配置项,包括名称、分类、描述和默认值。",
|
||||
RunE: runConfigList,
|
||||
}
|
||||
cmd.Flags().String("category", "", "按分类过滤 (core|auth|network|security|runtime|debug|external)")
|
||||
cmd.Flags().Bool("show-values", false, "显示配置项的当前实际值 (敏感信息会脱敏)")
|
||||
cmd.Flags().Bool("show-hidden", false, "包含隐藏的内部调试配置项")
|
||||
cmd.Flags().Bool("json", false, "以 JSON 格式输出")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigList(cmd *cobra.Command, _ []string) error {
|
||||
category, _ := cmd.Flags().GetString("category")
|
||||
showValues, _ := cmd.Flags().GetBool("show-values")
|
||||
showHidden, _ := cmd.Flags().GetBool("show-hidden")
|
||||
jsonOut, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
var items []configmeta.ConfigItem
|
||||
if category != "" {
|
||||
items = configmeta.ByCategory(configmeta.Category(category))
|
||||
} else {
|
||||
items = configmeta.All()
|
||||
}
|
||||
|
||||
if !showHidden {
|
||||
items = filterVisible(items)
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return writeConfigJSON(cmd, items, showValues)
|
||||
}
|
||||
return writeConfigTable(cmd, items, showValues)
|
||||
}
|
||||
|
||||
func filterVisible(items []configmeta.ConfigItem) []configmeta.ConfigItem {
|
||||
out := make([]configmeta.ConfigItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Hidden {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeConfigJSON(cmd *cobra.Command, items []configmeta.ConfigItem, showValues bool) error {
|
||||
type jsonItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
DefaultValue string `json:"default_value,omitempty"`
|
||||
Example string `json:"example,omitempty"`
|
||||
Sensitive bool `json:"sensitive,omitempty"`
|
||||
CurrentValue string `json:"current_value,omitempty"`
|
||||
IsSet bool `json:"is_set"`
|
||||
}
|
||||
|
||||
result := make([]jsonItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
ji := jsonItem{
|
||||
Name: item.Name,
|
||||
Category: string(item.Category),
|
||||
Description: item.Description,
|
||||
DefaultValue: item.DefaultValue,
|
||||
Example: item.Example,
|
||||
Sensitive: item.Sensitive,
|
||||
}
|
||||
val, ok := configmeta.Resolve(item.Name)
|
||||
ji.IsSet = ok
|
||||
if showValues && ok {
|
||||
ji.CurrentValue = val
|
||||
}
|
||||
result = append(result, ji)
|
||||
}
|
||||
|
||||
return output.WriteJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"kind": "config_list",
|
||||
"count": len(result),
|
||||
"configs": result,
|
||||
})
|
||||
}
|
||||
|
||||
func writeConfigTable(cmd *cobra.Command, items []configmeta.ConfigItem, showValues bool) error {
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
if len(items) == 0 {
|
||||
_, _ = fmt.Fprintln(w, "没有找到匹配的配置项。")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
|
||||
if showValues {
|
||||
_, _ = fmt.Fprintln(tw, "分类\t配置项\t描述\t默认值\t当前值")
|
||||
_, _ = fmt.Fprintln(tw, "────\t──────\t────\t──────\t──────")
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(tw, "分类\t配置项\t描述\t默认值")
|
||||
_, _ = fmt.Fprintln(tw, "────\t──────\t────\t──────")
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
def := item.DefaultValue
|
||||
if def == "" {
|
||||
def = "(空)"
|
||||
}
|
||||
if showValues {
|
||||
val, ok := configmeta.Resolve(item.Name)
|
||||
display := "(未设置)"
|
||||
if ok {
|
||||
display = val
|
||||
}
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
item.Category, item.Name, item.Description, def, display)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
item.Category, item.Name, item.Description, def)
|
||||
}
|
||||
}
|
||||
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintf(w, "\n共 %d 个配置项。使用 --show-values 查看当前值,--show-hidden 显示隐藏项。\n", len(items))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
func seedTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
configmeta.Reset()
|
||||
t.Cleanup(configmeta.Reset)
|
||||
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CONFIG_DIR", Category: configmeta.CategoryCore,
|
||||
Description: "覆盖默认配置目录", DefaultValue: "~/.dws",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_SECRET", Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppSecret", Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CATALOG_FIXTURE", Category: configmeta.CategoryDebug,
|
||||
Description: "目录 Fixture 路径", Hidden: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigListTable(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CONFIG_DIR") {
|
||||
t.Error("expected DWS_CONFIG_DIR in output")
|
||||
}
|
||||
if !strings.Contains(out, "DWS_CLIENT_SECRET") {
|
||||
t.Error("expected DWS_CLIENT_SECRET in output")
|
||||
}
|
||||
// Hidden items should be excluded by default
|
||||
if strings.Contains(out, "DWS_CATALOG_FIXTURE") {
|
||||
t.Error("expected DWS_CATALOG_FIXTURE to be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListShowHidden(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--show-hidden"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CATALOG_FIXTURE") {
|
||||
t.Error("expected DWS_CATALOG_FIXTURE with --show-hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListCategory(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--category", "auth"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CLIENT_SECRET") {
|
||||
t.Error("expected DWS_CLIENT_SECRET for auth category")
|
||||
}
|
||||
if strings.Contains(out, "DWS_CONFIG_DIR") {
|
||||
t.Error("DWS_CONFIG_DIR should not appear for auth category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListJSON(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--json", "--show-hidden"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
if result["kind"] != "config_list" {
|
||||
t.Errorf("expected kind=config_list, got %v", result["kind"])
|
||||
}
|
||||
count, ok := result["count"].(float64)
|
||||
if !ok || count != 3 {
|
||||
t.Errorf("expected count=3, got %v", result["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListShowValues(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", "/custom/dir")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "supersecret123")
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--show-values"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "/custom/dir") {
|
||||
t.Error("expected actual value for DWS_CONFIG_DIR")
|
||||
}
|
||||
if strings.Contains(out, "supersecret123") {
|
||||
t.Error("sensitive value should be masked")
|
||||
}
|
||||
if !strings.Contains(out, "当前值") {
|
||||
t.Error("expected '当前值' column header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListEmpty(t *testing.T) {
|
||||
configmeta.Reset()
|
||||
defer configmeta.Reset()
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "没有找到") {
|
||||
t.Error("expected empty message")
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newAuthCommand(),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newConfigCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newUpgradeCommand(),
|
||||
@@ -629,6 +630,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"cache": true,
|
||||
"config": true,
|
||||
"completion": true,
|
||||
"skill": true,
|
||||
"version": true,
|
||||
|
||||
@@ -33,9 +33,51 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "启用 MCP 响应内容安全扫描",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN_ENFORCE",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "内容安全扫描发现问题时阻断响应",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN_REPORT",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "在 JSON 输出中包含安全扫描报告",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_AGENT",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-agent 头",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_TRACE_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-trace-id 头",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_SESSION_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-session-id 头",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_MESSAGE_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-message-id 头",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
runtimeContentScanEnv = "DWS_RUNTIME_CONTENT_SCAN"
|
||||
runtimeContentScanEnforceEnv = "DWS_RUNTIME_CONTENT_SCAN_ENFORCE"
|
||||
|
||||
@@ -29,9 +29,20 @@ import (
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_SKILL_API_HOST",
|
||||
Category: configmeta.CategoryNetwork,
|
||||
Description: "覆盖 Skill API 地址",
|
||||
DefaultValue: "https://mcp.dingtalk.com",
|
||||
Example: "https://custom-mcp.example.com",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// legacySkillAPIHost is the legacy skill market host used by the old cli.
|
||||
legacySkillAPIHost = "https://mcp.dingtalk.com"
|
||||
|
||||
@@ -21,8 +21,19 @@ import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_PERF_DEBUG",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "启用性能计时输出到 stderr",
|
||||
Example: "1",
|
||||
})
|
||||
}
|
||||
|
||||
// PerfDebugEnv is the environment variable to enable performance timing output.
|
||||
const PerfDebugEnv = "DWS_PERF_DEBUG"
|
||||
|
||||
|
||||
@@ -19,9 +19,27 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_ID",
|
||||
Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppKey (DingTalk 应用凭证)",
|
||||
DefaultValue: "(内置)",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_SECRET",
|
||||
Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppSecret (DingTalk 应用凭证)",
|
||||
DefaultValue: "(内置)",
|
||||
Sensitive: true,
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// AuthorizeURL is the DingTalk OAuth authorization page.
|
||||
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
|
||||
|
||||
@@ -28,9 +28,27 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CACHE_DIR",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "覆盖缓存目录",
|
||||
DefaultValue: "~/.dws/cache",
|
||||
Example: "/tmp/dws-cache",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CATALOG_FIXTURE",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "使用本地 JSON 文件替代在线目录发现",
|
||||
Example: "/path/to/catalog.json",
|
||||
Hidden: true,
|
||||
})
|
||||
}
|
||||
|
||||
// CatalogDegradedReason identifies why catalog discovery returned empty.
|
||||
type CatalogDegradedReason string
|
||||
|
||||
|
||||
@@ -27,8 +27,24 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_TENANT",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "缓存分区的租户标识",
|
||||
DefaultValue: "default",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_AUTH_IDENTITY",
|
||||
Category: configmeta.CategorySecurity,
|
||||
Description: "缓存分区的认证身份标识",
|
||||
DefaultValue: "default",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
tenantEnv = "DWS_TENANT"
|
||||
authIdentityEnv = "DWS_AUTH_IDENTITY"
|
||||
|
||||
@@ -20,9 +20,27 @@ import (
|
||||
"strings"
|
||||
|
||||
registryassets "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/registry"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_SKILLS_PERSONAS_FILE",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "覆盖内置 personas.yaml 的本地文件路径",
|
||||
Example: "/path/to/personas.yaml",
|
||||
Hidden: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_SKILLS_RECIPES_FILE",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "覆盖内置 recipes.yaml 的本地文件路径",
|
||||
Example: "/path/to/recipes.yaml",
|
||||
Hidden: true,
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
PersonaRegistryPathEnv = "DWS_SKILLS_PERSONAS_FILE"
|
||||
RecipeRegistryPathEnv = "DWS_SKILLS_RECIPES_FILE"
|
||||
|
||||
@@ -37,9 +37,20 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_LANG",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "界面语言 (en/zh),回退到 LANG",
|
||||
DefaultValue: "en",
|
||||
Example: "zh",
|
||||
})
|
||||
}
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFS embed.FS
|
||||
|
||||
|
||||
@@ -34,9 +34,27 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_ALLOW_HTTP_ENDPOINTS",
|
||||
Category: configmeta.CategorySecurity,
|
||||
Description: "允许非 HTTPS 的 MCP 端点 (仅限 loopback)",
|
||||
DefaultValue: "(禁用)",
|
||||
Example: "1",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_TRUSTED_DOMAINS",
|
||||
Category: configmeta.CategoryNetwork,
|
||||
Description: "信任的 HTTPS 域名白名单 (逗号分隔,* 信任所有)",
|
||||
DefaultValue: "*.dingtalk.com",
|
||||
Example: "*.dingtalk.com,custom.example.com",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
trustedDomainsEnv = "DWS_TRUSTED_DOMAINS"
|
||||
defaultTrustedDomains = "*.dingtalk.com"
|
||||
|
||||
@@ -14,8 +14,32 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_UPGRADE_URL",
|
||||
Category: configmeta.CategoryNetwork,
|
||||
Description: "覆盖 GitHub API 地址 (镜像/测试)",
|
||||
DefaultValue: "https://api.github.com",
|
||||
Example: "https://mirror.example.com/api",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "GITHUB_TOKEN",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "GitHub API Token (提升 API 限额)",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "GH_TOKEN",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "GitHub API Token 备选 (GITHUB_TOKEN 为空时使用)",
|
||||
Sensitive: true,
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
gitHubAPIBase = "https://api.github.com"
|
||||
defaultOwner = "DingTalk-Real-AI"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package configmeta provides a central registry of all environment-variable
|
||||
// based configuration items used by the DWS CLI. Each package registers its
|
||||
// own items via init(), and the "dws config list" command reads the registry
|
||||
// to present a unified view to the developer.
|
||||
package configmeta
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Category groups related configuration items for display purposes.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryCore Category = "core"
|
||||
CategoryAuth Category = "auth"
|
||||
CategoryNetwork Category = "network"
|
||||
CategorySecurity Category = "security"
|
||||
CategoryRuntime Category = "runtime"
|
||||
CategoryDebug Category = "debug"
|
||||
CategoryExternal Category = "external"
|
||||
)
|
||||
|
||||
// categoryOrder defines the display order for categories.
|
||||
var categoryOrder = map[Category]int{
|
||||
CategoryCore: 0,
|
||||
CategoryAuth: 1,
|
||||
CategoryNetwork: 2,
|
||||
CategorySecurity: 3,
|
||||
CategoryRuntime: 4,
|
||||
CategoryDebug: 5,
|
||||
CategoryExternal: 6,
|
||||
}
|
||||
|
||||
// ConfigItem describes a single environment-variable configuration item.
|
||||
type ConfigItem struct {
|
||||
Name string // Environment variable name, e.g. "DWS_CONFIG_DIR"
|
||||
Category Category // Logical grouping
|
||||
Description string // Short human-readable description
|
||||
DefaultValue string // Description of the default value
|
||||
Example string // Example value for documentation
|
||||
Sensitive bool // If true, actual value is masked in output
|
||||
Hidden bool // If true, omitted from default list output
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
items []ConfigItem
|
||||
)
|
||||
|
||||
// Register adds a configuration item to the global registry.
|
||||
// Duplicate names are silently ignored (first registration wins).
|
||||
func Register(item ConfigItem) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, existing := range items {
|
||||
if existing.Name == item.Name {
|
||||
return
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
// All returns every registered configuration item sorted by category
|
||||
// (display order) then by name.
|
||||
func All() []ConfigItem {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
out := make([]ConfigItem, len(items))
|
||||
copy(out, items)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ci, cj := categoryOrder[out[i].Category], categoryOrder[out[j].Category]
|
||||
if ci != cj {
|
||||
return ci < cj
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ByCategory returns registered items that match the given category.
|
||||
func ByCategory(cat Category) []ConfigItem {
|
||||
all := All()
|
||||
var out []ConfigItem
|
||||
for _, item := range all {
|
||||
if item.Category == cat {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Resolve returns the current value of the named environment variable.
|
||||
// For sensitive items the value is masked. Returns ("", false) when the
|
||||
// variable is not set.
|
||||
func Resolve(name string) (string, bool) {
|
||||
val, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
for _, item := range items {
|
||||
if item.Name == name && item.Sensitive {
|
||||
return maskValue(val), true
|
||||
}
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
// Categories returns all known category values in display order.
|
||||
func Categories() []Category {
|
||||
cats := make([]Category, 0, len(categoryOrder))
|
||||
for c := range categoryOrder {
|
||||
cats = append(cats, c)
|
||||
}
|
||||
sort.Slice(cats, func(i, j int) bool {
|
||||
return categoryOrder[cats[i]] < categoryOrder[cats[j]]
|
||||
})
|
||||
return cats
|
||||
}
|
||||
|
||||
func maskValue(v string) string {
|
||||
if len(v) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(v) <= 4 {
|
||||
return strings.Repeat("*", len(v))
|
||||
}
|
||||
return v[:2] + strings.Repeat("*", len(v)-4) + v[len(v)-2:]
|
||||
}
|
||||
|
||||
// Reset clears the registry. Intended for testing only.
|
||||
func Reset() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
items = nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package configmeta
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterAndAll(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register(ConfigItem{Name: "ZZZ_LAST", Category: CategoryDebug, Description: "last"})
|
||||
Register(ConfigItem{Name: "AAA_FIRST", Category: CategoryCore, Description: "first"})
|
||||
Register(ConfigItem{Name: "MMM_MID", Category: CategoryAuth, Description: "mid"})
|
||||
|
||||
all := All()
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", len(all))
|
||||
}
|
||||
// core < auth < debug
|
||||
if all[0].Name != "AAA_FIRST" {
|
||||
t.Errorf("expected AAA_FIRST first, got %s", all[0].Name)
|
||||
}
|
||||
if all[1].Name != "MMM_MID" {
|
||||
t.Errorf("expected MMM_MID second, got %s", all[1].Name)
|
||||
}
|
||||
if all[2].Name != "ZZZ_LAST" {
|
||||
t.Errorf("expected ZZZ_LAST third, got %s", all[2].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateIgnored(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register(ConfigItem{Name: "DUP", Category: CategoryCore, Description: "original"})
|
||||
Register(ConfigItem{Name: "DUP", Category: CategoryCore, Description: "duplicate"})
|
||||
|
||||
all := All()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(all))
|
||||
}
|
||||
if all[0].Description != "original" {
|
||||
t.Errorf("expected original description, got %q", all[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByCategory(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register(ConfigItem{Name: "A", Category: CategoryCore})
|
||||
Register(ConfigItem{Name: "B", Category: CategoryAuth})
|
||||
Register(ConfigItem{Name: "C", Category: CategoryCore})
|
||||
|
||||
core := ByCategory(CategoryCore)
|
||||
if len(core) != 2 {
|
||||
t.Fatalf("expected 2 core items, got %d", len(core))
|
||||
}
|
||||
|
||||
empty := ByCategory(CategoryDebug)
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("expected 0 debug items, got %d", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNonSensitive(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register(ConfigItem{Name: "TEST_VAR_PLAIN", Category: CategoryCore})
|
||||
|
||||
t.Setenv("TEST_VAR_PLAIN", "hello")
|
||||
val, ok := Resolve("TEST_VAR_PLAIN")
|
||||
if !ok || val != "hello" {
|
||||
t.Errorf("expected (hello, true), got (%q, %v)", val, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSensitiveMasked(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register(ConfigItem{Name: "TEST_SECRET", Category: CategoryAuth, Sensitive: true})
|
||||
|
||||
t.Setenv("TEST_SECRET", "abcdefgh")
|
||||
val, ok := Resolve("TEST_SECRET")
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if val == "abcdefgh" {
|
||||
t.Error("sensitive value should be masked")
|
||||
}
|
||||
// ab****gh
|
||||
if val != "ab****gh" {
|
||||
t.Errorf("unexpected masked value: %q", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUnset(t *testing.T) {
|
||||
Reset()
|
||||
defer Reset()
|
||||
|
||||
Register(ConfigItem{Name: "UNSET_VAR", Category: CategoryCore})
|
||||
os.Unsetenv("UNSET_VAR")
|
||||
|
||||
_, ok := Resolve("UNSET_VAR")
|
||||
if ok {
|
||||
t.Error("expected ok=false for unset variable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"ab", "**"},
|
||||
{"abcd", "****"},
|
||||
{"abcde", "ab*de"},
|
||||
{"abcdefghij", "ab******ij"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := maskValue(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("maskValue(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategories(t *testing.T) {
|
||||
cats := Categories()
|
||||
if len(cats) != 7 {
|
||||
t.Fatalf("expected 7 categories, got %d", len(cats))
|
||||
}
|
||||
if cats[0] != CategoryCore {
|
||||
t.Errorf("expected core first, got %s", cats[0])
|
||||
}
|
||||
if cats[len(cats)-1] != CategoryExternal {
|
||||
t.Errorf("expected external last, got %s", cats[len(cats)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
Reset()
|
||||
Register(ConfigItem{Name: "X", Category: CategoryCore})
|
||||
Reset()
|
||||
if len(All()) != 0 {
|
||||
t.Error("expected empty registry after Reset")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user