diff --git a/internal/app/config.go b/internal/app/config.go index 68f3ae0a..6606dc84 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -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" diff --git a/internal/app/config_command.go b/internal/app/config_command.go new file mode 100644 index 00000000..4d6e5042 --- /dev/null +++ b/internal/app/config_command.go @@ -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 +} diff --git a/internal/app/config_command_test.go b/internal/app/config_command_test.go new file mode 100644 index 00000000..332255ae --- /dev/null +++ b/internal/app/config_command_test.go @@ -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") + } +} diff --git a/internal/app/root.go b/internal/app/root.go index 174900a4..a9c361eb 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -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, diff --git a/internal/app/runner.go b/internal/app/runner.go index f04fd973..36ac7e3d 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -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" diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index dbb35918..8f81169c 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -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" diff --git a/internal/app/timing.go b/internal/app/timing.go index 85f7bacf..5015ec7e 100644 --- a/internal/app/timing.go +++ b/internal/app/timing.go @@ -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" diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 0621ec02..0014862d 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -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" diff --git a/internal/cli/loader.go b/internal/cli/loader.go index 046bb42b..d90c4a25 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -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 diff --git a/internal/discovery/service.go b/internal/discovery/service.go index fe47751c..c30d2713 100644 --- a/internal/discovery/service.go +++ b/internal/discovery/service.go @@ -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" diff --git a/internal/generator/registry.go b/internal/generator/registry.go index 67c9d453..74bff149 100644 --- a/internal/generator/registry.go +++ b/internal/generator/registry.go @@ -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" diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 896b2cde..e93dadeb 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -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 diff --git a/internal/transport/client.go b/internal/transport/client.go index cbfaf9cc..b5ae8c74 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -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" diff --git a/internal/upgrade/github.go b/internal/upgrade/github.go index cccff7d7..20972e62 100644 --- a/internal/upgrade/github.go +++ b/internal/upgrade/github.go @@ -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" diff --git a/pkg/configmeta/registry.go b/pkg/configmeta/registry.go new file mode 100644 index 00000000..431adbcb --- /dev/null +++ b/pkg/configmeta/registry.go @@ -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 +} diff --git a/pkg/configmeta/registry_test.go b/pkg/configmeta/registry_test.go new file mode 100644 index 00000000..3df1da8c --- /dev/null +++ b/pkg/configmeta/registry_test.go @@ -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") + } +}