diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg deleted file mode 100644 index c742bb27..00000000 --- a/.github/badges/coverage.svg +++ /dev/null @@ -1 +0,0 @@ -coverage: 52.8%coverage52.8% \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d2e7c57..638ad57d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,3 +141,41 @@ jobs: - name: Generated Drift run: ./scripts/policy/check-generated-drift.sh + + edition-tests: + name: Edition Contract Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run edition contract tests + run: go test -v -count=1 ./pkg/editiontest/... + + notify-downstream: + name: Notify Wukong Overlay + needs: [test, policy, edition-tests] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + steps: + - name: Trigger downstream CI + run: | + # Trigger internal GitLab CI pipeline via webhook. + # WUKONG_TRIGGER_TOKEN is a repository secret. + if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then + curl --fail --silent --show-error \ + -X POST \ + -F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \ + -F "ref=main" \ + -F "variables[UPSTREAM_SHA]=${{ github.sha }}" \ + "${{ secrets.WUKONG_TRIGGER_URL }}" + echo "Downstream CI triggered." + else + echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification." + fi diff --git a/.gitignore b/.gitignore index c4615d59..e58ba18f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ test/cli_compat/testdata/ credentials* plans _docs +dws.zip diff --git a/Makefile b/Makefile index 0b8e42dc..28a0d30d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GO ?= go -.PHONY: all help build rebuild test lint fmt policy package release publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -34,6 +34,9 @@ policy: @./scripts/policy/check-open-source-assets.sh @./scripts/policy/check-command-surface.sh --strict +edition-test: + $(GO) test -v -count=1 ./pkg/editiontest/... + package: @./scripts/dev/build-all.sh @./scripts/release/post-goreleaser.sh diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 9229acc6..bfaf4445 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -24,8 +24,9 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -48,7 +49,9 @@ func buildAuthCommand() *cobra.Command { }, } - cmd.AddCommand(newAuthLoginCommand()) + if !edition.Get().HideAuthLogin { + cmd.AddCommand(newAuthLoginCommand()) + } cmd.AddCommand( newAuthLogoutCommand(), newAuthStatusCommand(), @@ -206,7 +209,9 @@ func newAuthLogoutCommand() *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 已清除所有认证信息") - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + if !edition.Get().IsEmbedded { + fmt.Fprintln(w, "请运行 dws auth login 重新登录") + } return nil }, } @@ -236,6 +241,8 @@ func newAuthStatusCommand() *cobra.Command { tokenData = updatedData refreshed = true } + } else if edition.Get().AutoPurgeToken { + _ = authpkg.DeleteTokenData(configDir) } } if authStatusAuthenticated(tokenData) { @@ -263,7 +270,9 @@ func newAuthStatusCommand() *cobra.Command { } } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录") - fmt.Fprintln(w, "运行 dws auth login 进行登录") + if !edition.Get().IsEmbedded { + fmt.Fprintln(w, "运行 dws auth login 进行登录") + } } return nil }, @@ -341,7 +350,9 @@ func newAuthResetCommand() *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 认证信息已重置") - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + if !edition.Get().IsEmbedded { + fmt.Fprintln(w, "请运行 dws auth login 重新登录") + } return nil }, } diff --git a/internal/app/config.go b/internal/app/config.go index 12015c95..68f3ae0a 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -16,6 +16,8 @@ package app import ( "os" "path/filepath" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) // Build-time variables injected via ldflags when available. @@ -28,7 +30,9 @@ func defaultConfigDir() string { if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" { return envDir } - + if fn := edition.Get().ConfigDir; fn != nil { + return fn() + } homeDir, err := os.UserHomeDir() if err != nil { return exeRelativeConfigDir() diff --git a/internal/app/legacy.go b/internal/app/legacy.go index 7b77ce82..7de9fcd4 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -30,16 +30,25 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command { + if fn := edition.Get().StaticServers; fn != nil { + injectStaticServers(fn()) + // Static servers provided by the edition hook — skip Market discovery + // entirely. The overlay registers its own product commands via + // RegisterExtraCommands; we only add the open-source helpers here. + commands := helpers.NewPublicCommands(runner) + return mergeTopLevelCommands(commands) + } + var commands []*cobra.Command - // Generate commands dynamically from the market discovery API. if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 { commands = append(commands, dynamicCmds...) } @@ -47,6 +56,26 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob return mergeTopLevelCommands(commands) } +// injectStaticServers converts edition.ServerInfo entries into +// market.ServerDescriptor and feeds them into SetDynamicServers so the +// direct-runtime endpoint resolver can find them. +func injectStaticServers(servers []edition.ServerInfo) { + descriptors := make([]market.ServerDescriptor, 0, len(servers)) + for _, s := range servers { + descriptors = append(descriptors, market.ServerDescriptor{ + Key: s.ID, + DisplayName: s.Name, + Endpoint: s.Endpoint, + CLI: market.CLIOverlay{ + ID: s.ID, + Command: s.ID, + Prefixes: s.Prefixes, + }, + }) + } + SetDynamicServers(descriptors) +} + // loadDynamicCommands loads the server registry and generates CLI commands // dynamically from CLIOverlay metadata. It consults the disk cache first. // Within the short revalidation window it uses the cached registry directly; diff --git a/internal/app/root.go b/internal/app/root.go index 331a0c0b..e9c3683d 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -29,7 +29,6 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" @@ -41,6 +40,8 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" "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/edition" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -287,6 +288,12 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...) root.AddCommand(newLegacyHiddenCommands(runner)...) + if fn := edition.Get().RegisterExtraCommands; fn != nil { + caller := newToolCallerAdapter(runner, flags) + fn(root, caller) + deduplicateCommands(root) + } + hideNonDirectRuntimeCommands(root) configureRootHelp(root) // Set custom flag error handler for better UX @@ -465,24 +472,51 @@ func newVersionCommand() *cobra.Command { Example: " dws version\n dws version --format json", DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - format, err := cmd.Flags().GetString("format") - if err != nil { - return apperrors.NewInternal("failed to read format flag") + wantJSON := cmd.Flags().Changed("format") + if wantJSON { + format, _ := cmd.Flags().GetString("format") + wantJSON = (format == "json") } - payload := map[string]any{ - "version": Version(), - "go": "1.24+", + + editionName := edition.Get().Name + if editionName == "" { + editionName = "open" } - if format == "json" { + ver := RawVersion() + bt := BuildTime() + gc := GitCommit() + goVer := "1.24+" + + arch := "MCP Dynamic Aggregation" + + if wantJSON { + payload := map[string]any{ + "version": ver, + "edition": editionName, + "architecture": arch, + "go": goVer, + } + if bt != "unknown" { + payload["build"] = bt + } + if gc != "unknown" { + payload["commit"] = gc + } return output.WriteJSON(cmd.OutOrStdout(), payload) } - _, err = fmt.Fprintf( - cmd.OutOrStdout(), - "版本: %s\nGo: %s\n", - Version(), - "1.24+", - ) - return err + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "%-16s%s\n", "Version:", ver) + fmt.Fprintf(w, "%-16s%s\n", "Edition:", editionName) + if bt != "unknown" { + fmt.Fprintf(w, "%-16s%s\n", "Build:", bt) + } + if gc != "unknown" { + fmt.Fprintf(w, "%-16s%s\n", "Commit:", gc) + } + fmt.Fprintf(w, "%-16s%s\n", "Architecture:", arch) + fmt.Fprintf(w, "%-16s%s\n", "Go:", goVer) + return nil }, } } @@ -579,17 +613,30 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo } // hideNonDirectRuntimeCommands marks top-level product commands as hidden -// unless they correspond to a product discovered via dynamic server discovery. +// unless they correspond to a product discovered via dynamic server discovery +// or listed in the edition's VisibleProducts hook. // Public utility commands (auth, cache, completion, version) are always kept // visible; explicitly hidden commands stay hidden. func hideNonDirectRuntimeCommands(root *cobra.Command) { - allowedProducts := DirectRuntimeProductIDs() + var allowedProducts map[string]bool + if fn := edition.Get().VisibleProducts; fn != nil { + products := fn() + allowedProducts = make(map[string]bool, len(products)) + for _, p := range products { + allowedProducts[p] = true + } + } else { + allowedProducts = DirectRuntimeProductIDs() + } staticCommands := map[string]bool{ "auth": true, "cache": true, "completion": true, "version": true, "help": true, + "recovery": true, + "schema": true, + "mcp": true, } for _, cmd := range root.Commands() { name := cmd.Name() @@ -606,6 +653,24 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) { } } +// deduplicateCommands removes duplicate top-level commands, keeping the last +// registered one. This ensures overlay commands take precedence over +// open-source defaults when both register the same product name. +func deduplicateCommands(root *cobra.Command) { + seen := make(map[string]*cobra.Command) + var dups []*cobra.Command + for _, cmd := range root.Commands() { + name := cmd.Name() + if prev, ok := seen[name]; ok { + dups = append(dups, prev) + } + seen[name] = cmd + } + for _, dup := range dups { + root.RemoveCommand(dup) + } +} + func cacheStoreFromEnv() *cache.Store { cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv)) return cache.NewStore(cacheDir) diff --git a/internal/app/root_execute_test.go b/internal/app/root_execute_test.go index 6a3294c4..178f31f4 100644 --- a/internal/app/root_execute_test.go +++ b/internal/app/root_execute_test.go @@ -172,8 +172,8 @@ func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("Execute(version) error = %v", err) } - if !strings.Contains(out.String(), "\"version\"") { - t.Fatalf("version output missing version key:\n%s", out.String()) + if !strings.Contains(out.String(), "Version:") { + t.Fatalf("version output missing Version line:\n%s", out.String()) } } @@ -213,8 +213,8 @@ func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing if elapsed := time.Since(start); elapsed >= 200*time.Millisecond { t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed) } - if !strings.Contains(out.String(), "\"version\"") { - t.Fatalf("version output missing version key:\n%s", out.String()) + if !strings.Contains(out.String(), "Version:") { + t.Fatalf("version output missing Version line:\n%s", out.String()) } } diff --git a/internal/app/root_help.go b/internal/app/root_help.go index 9dfe9dc7..f005d05a 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -5,6 +5,7 @@ import ( "strings" "text/tabwriter" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -53,7 +54,16 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command { return nil } - allowed := DirectRuntimeProductIDs() + var allowed map[string]bool + if fn := edition.Get().VisibleProducts; fn != nil { + products := fn() + allowed = make(map[string]bool, len(products)) + for _, p := range products { + allowed[p] = true + } + } else { + allowed = DirectRuntimeProductIDs() + } if len(allowed) == 0 { return nil } diff --git a/internal/app/runner.go b/internal/app/runner.go index f72cd6cb..8c817805 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -31,6 +31,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "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/edition" ) const ( @@ -191,6 +192,11 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, _, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", callDuration) } if err != nil { + if isAuthError(err) { + if fn := edition.Get().OnAuthError; fn != nil { + _ = fn(defaultConfigDir(), err) + } + } captureRuntimeFailure(invocation, err, err) return executor.Result{}, err } @@ -335,6 +341,14 @@ func runtimeFlagEnabled(raw string, defaultValue bool) bool { } } +func isAuthError(err error) bool { + var appErr *apperrors.Error + if errors.As(err, &appErr) { + return appErr.Category == apperrors.CategoryAuth + } + return false +} + func productEndpointOverride(productID string) (string, bool) { key := "DINGTALK_" + strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(productID), "-", "_")) + "_MCP_URL" value := strings.TrimSpace(os.Getenv(key)) @@ -365,6 +379,9 @@ func resolveIdentityHeaders() map[string]string { headers[k] = v } } + if fn := edition.Get().MergeHeaders; fn != nil { + headers = fn(headers) + } return headers } diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go new file mode 100644 index 00000000..95daf49f --- /dev/null +++ b/internal/app/tool_caller_adapter.go @@ -0,0 +1,104 @@ +// 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 ( + "context" + "encoding/json" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +// toolCallerAdapter bridges executor.Runner to the public edition.ToolCaller +// interface so that private overlays can invoke MCP tools without importing +// internal packages. +type toolCallerAdapter struct { + runner executor.Runner + flags *GlobalFlags +} + +func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller { + return &toolCallerAdapter{runner: runner, flags: flags} +} + +func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args) + result, err := a.runner.Run(ctx, inv) + if err != nil { + return nil, err + } + return convertResult(result), nil +} + +func (a *toolCallerAdapter) Format() string { + if a.flags != nil { + return a.flags.Format + } + return "json" +} + +func (a *toolCallerAdapter) DryRun() bool { + return a.flags != nil && a.flags.DryRun +} + +func convertResult(r executor.Result) *edition.ToolResult { + resp := r.Response + if resp == nil { + return &edition.ToolResult{} + } + + // The runtime runner stores MCP response content under "content". + contentRaw, ok := resp["content"] + if !ok { + // Dry-run or echo mode: serialize the whole response as text. + data, _ := json.Marshal(resp) + return &edition.ToolResult{ + Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, + } + } + + // Content may be a []any of {type, text} blocks from the MCP response, + // or a single map for mock mode. + switch v := contentRaw.(type) { + case []any: + blocks := make([]edition.ContentBlock, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + blocks = append(blocks, edition.ContentBlock{ + Type: strVal(m, "type"), + Text: strVal(m, "text"), + }) + } + } + return &edition.ToolResult{Content: blocks} + case map[string]any: + data, _ := json.Marshal(v) + return &edition.ToolResult{ + Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, + } + default: + data, _ := json.Marshal(contentRaw) + return &edition.ToolResult{ + Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, + } + } +} + +func strVal(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/internal/app/version.go b/internal/app/version.go index fb6f7e79..f93c9ebc 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -15,6 +15,21 @@ package app var version = "dev" +// SetVersion overrides the version, build time and git commit strings. +// Called by pkg/cli.SetVersion for overlay modules that inject their own +// version info via ldflags. +func SetVersion(v, bt, gc string) { + if v != "" { + version = v + } + if bt != "" { + buildTime = bt + } + if gc != "" { + gitCommit = gc + } +} + // Version returns the current CLI version string, including build metadata // when injected via ldflags (buildTime, gitCommit). func Version() string { @@ -23,3 +38,12 @@ func Version() string { } return version } + +// RawVersion returns the bare version string without build metadata. +func RawVersion() string { return version } + +// BuildTime returns the build timestamp injected via ldflags. +func BuildTime() string { return buildTime } + +// GitCommit returns the git commit hash injected via ldflags. +func GitCommit() string { return gitCommit } diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 21b44a59..ffaf82ad 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -26,8 +26,8 @@ import ( "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/fatih/color" ) diff --git a/internal/auth/filelock.go b/internal/auth/filelock.go index de266939..f5ca8ce1 100644 --- a/internal/auth/filelock.go +++ b/internal/auth/filelock.go @@ -21,7 +21,7 @@ import ( "sync" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) const ( diff --git a/internal/auth/identity.go b/internal/auth/identity.go index 66f287fa..c800610d 100644 --- a/internal/auth/identity.go +++ b/internal/auth/identity.go @@ -25,7 +25,8 @@ import ( "os" "path/filepath" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) const identityFile = "identity.json" @@ -82,8 +83,11 @@ func (id *Identity) Headers() map[string]string { if id.Source != "" { h["x-dws-source"] = id.Source } - // Constant headers for MCP gateway tracking - h["x-dingtalk-scenario-code"] = "com.dingtalk.cli" + scenarioCode := "com.dingtalk.cli" + if sc := edition.Get().ScenarioCode; sc != "" { + scenarioCode = sc + } + h["x-dingtalk-scenario-code"] = scenarioCode h["x-dingtalk-source"] = "github" return h } diff --git a/internal/auth/manager.go b/internal/auth/manager.go index c74cec3e..41c6298b 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -15,8 +15,8 @@ package auth import ( "fmt" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "log/slog" "os" "path/filepath" diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 8df9b9d8..ca8344b5 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -23,7 +23,7 @@ import ( "net/url" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { diff --git a/internal/auth/secure_store.go b/internal/auth/secure_store.go index addecc33..89af75ab 100644 --- a/internal/auth/secure_store.go +++ b/internal/auth/secure_store.go @@ -21,8 +21,8 @@ import ( "path/filepath" "sync" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/security" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) const secureDataFile = ".data" diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 01e1fc41..60caea9b 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -25,12 +25,12 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert" "github.com/spf13/cobra" ) diff --git a/internal/cli/loader.go b/internal/cli/loader.go index c05439b8..085a3c78 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -22,11 +22,11 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir" "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" ) const ( diff --git a/internal/compat/registry.go b/internal/compat/registry.go index cf6a40e7..3d47da67 100644 --- a/internal/compat/registry.go +++ b/internal/compat/registry.go @@ -26,10 +26,10 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert" "github.com/spf13/cobra" ) diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 0489bc54..c315037d 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -26,9 +26,9 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/spf13/cobra" ) diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index d42d5e25..5d578e40 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -14,15 +14,14 @@ package helpers import ( - "fmt" "strconv" "strings" - "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -88,7 +87,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command { Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - title := flagOrFallback(cmd, "title", "subject", "content") + title := cmdutil.FlagOrFallback(cmd, "title", "subject", "content") if strings.TrimSpace(title) == "" { return apperrors.NewValidation("--title is required") } @@ -103,7 +102,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command { "executorIds": executorIds, } if v, _ := cmd.Flags().GetString("due"); v != "" { - ms, err := parseISOTimeToMillis("due", v) + ms, err := cmdutil.ParseISOTimeToMillis("due", v) if err != nil { return err } @@ -270,7 +269,7 @@ func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command { inner["subject"] = v } if v, _ := cmd.Flags().GetString("due"); v != "" { - ms, err := parseISOTimeToMillis("due", v) + ms, err := cmdutil.ParseISOTimeToMillis("due", v) if err != nil { return err } @@ -444,16 +443,6 @@ func newTodoTaskDeleteCommand(runner executor.Runner) *cobra.Command { // ── helpers ──────────────────────────────────────────────── -// flagOrFallback returns the first non-empty value among the given flag names. -func flagOrFallback(cmd *cobra.Command, names ...string) string { - for _, name := range names { - if v, _ := cmd.Flags().GetString(name); strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - // parseExecutorIds splits "id1,id2" into []string for the MCP executorIds array. func parseExecutorIds(s string) []string { s = strings.TrimSpace(s) @@ -470,25 +459,6 @@ func parseExecutorIds(s string) []string { return ids } -// parseISOTimeToMillis parses an ISO-8601 datetime string and returns Unix -// milliseconds. It supports timezone offsets (e.g. +08:00) and UTC "Z" suffix. -func parseISOTimeToMillis(flagName, value string) (int64, error) { - formats := []string{ - time.RFC3339, - "2006-01-02T15:04:05Z07:00", - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - } - for _, layout := range formats { - if t, err := time.Parse(layout, value); err == nil { - return t.UnixMilli(), nil - } - } - return 0, apperrors.NewValidation( - fmt.Sprintf("--%s format error, use ISO-8601 e.g. 2026-03-10T18:00:00+08:00", flagName), - ) -} - // ── list pagination helpers ──────────────────────────────── func normalizePage(raw string) string { diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 3f3dd0a6..a4c5b459 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -24,7 +24,7 @@ import ( "strconv" "sync" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) const ( diff --git a/internal/market/registry.go b/internal/market/registry.go index 070b622e..8b4ab679 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -26,8 +26,8 @@ import ( "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) const ( diff --git a/internal/output/sanitize.go b/internal/output/sanitize.go index 0d20df5d..95f333dd 100644 --- a/internal/output/sanitize.go +++ b/internal/output/sanitize.go @@ -13,7 +13,7 @@ package output -import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate" +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate" // SanitizeForTerminal strips ANSI escape sequences, control characters, and // dangerous Unicode from text before it is printed to a terminal. diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go index 1cd4207a..21378917 100644 --- a/internal/pipeline/handlers/paramname.go +++ b/internal/pipeline/handlers/paramname.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) // ParamNameHandler performs fuzzy correction on flag names that are @@ -101,7 +102,7 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri ambiguous := false for _, candidate := range candidates { - dist := levenshtein(bare, candidate) + dist := cmdutil.LevenshteinDist(bare, candidate) if dist < bestDist { bestDist = dist bestMatch = candidate @@ -117,48 +118,3 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri return "--" + bestMatch + suffix, true } - -// levenshtein computes the edit distance between two strings using -// the standard dynamic programming approach with O(min(m,n)) space. -func levenshtein(a, b string) int { - if a == b { - return 0 - } - - la, lb := len(a), len(b) - if la == 0 { - return lb - } - if lb == 0 { - return la - } - - // Ensure a is the shorter string for O(min) space. - if la > lb { - a, b = b, a - la, lb = lb, la - } - - prev := make([]int, la+1) - curr := make([]int, la+1) - for i := range prev { - prev[i] = i - } - - for j := 1; j <= lb; j++ { - curr[0] = j - for i := 1; i <= la; i++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - curr[i] = min( - prev[i]+1, // deletion - curr[i-1]+1, // insertion - prev[i-1]+cost, // substitution - ) - } - prev, curr = curr, prev - } - return prev[la] -} diff --git a/internal/pipeline/handlers/paramname_test.go b/internal/pipeline/handlers/paramname_test.go index ef3f6c07..b569b14b 100644 --- a/internal/pipeline/handlers/paramname_test.go +++ b/internal/pipeline/handlers/paramname_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) func TestLevenshtein(t *testing.T) { @@ -40,12 +41,11 @@ func TestLevenshtein(t *testing.T) { for _, tt := range tests { t.Run(tt.a+"→"+tt.b, func(t *testing.T) { - got := levenshtein(tt.a, tt.b) + got := cmdutil.LevenshteinDist(tt.a, tt.b) if got != tt.want { - t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + t.Errorf("LevenshteinDist(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) } - // Verify symmetry. - gotRev := levenshtein(tt.b, tt.a) + gotRev := cmdutil.LevenshteinDist(tt.b, tt.a) if gotRev != got { t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev) } diff --git a/internal/security/storage.go b/internal/security/storage.go index 43269e80..bd066720 100644 --- a/internal/security/storage.go +++ b/internal/security/storage.go @@ -20,7 +20,7 @@ import ( "path/filepath" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) // DataFileName is the encrypted token data file name. diff --git a/internal/transport/client.go b/internal/transport/client.go index 072434a3..9b566fd7 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -30,11 +30,11 @@ import ( "log/slog" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "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/internal/validate" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate" ) const ( diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go new file mode 100644 index 00000000..dd2f44f9 --- /dev/null +++ b/pkg/cli/cli.go @@ -0,0 +1,30 @@ +// 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 cli exposes public entry points for the DWS CLI so that external +// overlay modules can embed and launch the CLI after customising edition hooks. +package cli + +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + +// SetVersion overrides the version, build time and git commit strings +// that are displayed by `dws version` and `dws --version`. +// Typically called by overlay main.go with values injected via ldflags. +func SetVersion(v, buildTime, gitCommit string) { + app.SetVersion(v, buildTime, gitCommit) +} + +// Execute runs the root CLI command and returns the process exit code. +func Execute() int { + return app.Execute() +} diff --git a/pkg/cmdutil/flagfix.go b/pkg/cmdutil/flagfix.go new file mode 100644 index 00000000..610937a9 --- /dev/null +++ b/pkg/cmdutil/flagfix.go @@ -0,0 +1,166 @@ +// 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 cmdutil + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// FlagFixResult holds the result of SuggestFlagFix analysis. +type FlagFixResult struct { + Suggestion string + AutoFixFlag string + AutoFixValue string +} + +// CommonFlagAliases maps commonly misused flag names to their correct equivalents. +var CommonFlagAliases = map[string]string{ + "json": "format json", + "output": "format", + "out": "format", + "o": "format", + "silent": "quiet", + "dry": "dry-run", + "force": "yes", + "f": "yes", + "timeout-seconds": "timeout", + "device-flow": "device", + "deviceflow": "device", +} + +// DetectNumericTypeError checks if err is a Cobra/pflag numeric type +// validation error. Returns the flag name and the bad value if detected. +func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) { + msg := err.Error() + if !strings.Contains(msg, "strconv.Parse") { + return "", "", false + } + const argPrefix = "invalid argument \"" + argIdx := strings.Index(msg, argPrefix) + if argIdx < 0 { + return "", "", false + } + afterArg := msg[argIdx+len(argPrefix):] + argEnd := strings.Index(afterArg, "\"") + if argEnd < 0 { + return "", "", false + } + badVal := afterArg[:argEnd] + + const marker = "\" for \"--" + idx := strings.Index(msg, marker) + if idx < 0 { + return "", "", false + } + rest := msg[idx+len(marker):] + endIdx := strings.Index(rest, "\" flag") + if endIdx < 0 { + return "", "", false + } + return rest[:endIdx], badVal, true +} + +// SuggestFlagFix detects flag-value concatenation errors, common flag aliases, +// and Levenshtein-close typos. +func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult { + msg := flagErr.Error() + const prefix = "unknown flag: --" + idx := strings.Index(msg, prefix) + if idx < 0 { + return FlagFixResult{} + } + body := strings.TrimSpace(msg[idx+len(prefix):]) + + if alias, ok := CommonFlagAliases[body]; ok { + return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s? Run '%s --help' for options", alias, cmd.CommandPath())} + } + + var bestFlag, bestValue string + cmd.Flags().VisitAll(func(f *pflag.Flag) { + name := f.Name + if strings.HasPrefix(body, name) && len(body) > len(name) { + if len(name) > len(bestFlag) { + bestFlag = name + bestValue = body[len(name):] + } + } + }) + if bestFlag != "" { + canAutoFix := len(bestValue) > 0 + suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue) + if canAutoFix { + return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue} + } + return FlagFixResult{Suggestion: suggestion} + } + + bestName, bestDist := "", 999 + cmd.Flags().VisitAll(func(f *pflag.Flag) { + d := LevenshteinDist(body, f.Name) + if d < bestDist { + bestDist = d + bestName = f.Name + } + }) + threshold := LevenshteinThreshold(len(body)) + if bestDist > 0 && bestDist <= threshold && bestName != "" { + return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)} + } + + return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())} +} + +// LevenshteinThreshold returns the max edit distance allowed based on string length. +func LevenshteinThreshold(nameLen int) int { + if nameLen <= 3 { + return 1 + } + if nameLen <= 8 { + return 2 + } + return 3 +} + +// LevenshteinDist returns the edit distance between two strings. +func LevenshteinDist(a, b string) int { + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + dp := make([]int, lb+1) + for j := 0; j <= lb; j++ { + dp[j] = j + } + for i := 1; i <= la; i++ { + prev := dp[0] + dp[0] = i + for j := 1; j <= lb; j++ { + temp := dp[j] + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + dp[j] = min(dp[j]+1, min(dp[j-1]+1, prev+cost)) + prev = temp + } + } + return dp[lb] +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go new file mode 100644 index 00000000..45e54027 --- /dev/null +++ b/pkg/cmdutil/flags.go @@ -0,0 +1,153 @@ +// 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 cmdutil provides reusable CLI helper functions for Cobra commands. +// Both the open-source CLI and private overlays import this package to avoid +// duplicating flag validation, time parsing, and UX helpers. +package cmdutil + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// GroupRunE is a reusable RunE for parent (group) commands that have no +// business logic of their own. With args it returns an error listing available +// subcommands; without args it shows help. +func GroupRunE(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + var names []string + for _, sub := range cmd.Commands() { + if !sub.Hidden { + names = append(names, sub.Name()) + } + } + return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help", + args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath()) + } + return cmd.Help() +} + +// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint. +func HintSubCmd(use, hint string) *cobra.Command { + return &cobra.Command{ + Use: use, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return fmt.Errorf("ambiguous command %q for %q\n hint: %s", + use, cmd.Parent().CommandPath(), hint) + }, + } +} + +// MustGetFlag retrieves a string flag value, checking both local and inherited flags. +func MustGetFlag(cmd *cobra.Command, name string) string { + val, _ := cmd.Flags().GetString(name) + if val == "" { + val, _ = cmd.InheritedFlags().GetString(name) + } + return val +} + +// FlagOrFallback reads the primary flag; if empty, falls back through alias +// flags in order, returning the first non-empty value. +func FlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string { + if v, _ := cmd.Flags().GetString(primary); v != "" { + return v + } + for _, alias := range aliases { + if v, _ := cmd.Flags().GetString(alias); v != "" { + return v + } + } + return "" +} + +// MustFlagOrFallback works like FlagOrFallback but returns an error when all +// flags are empty. +func MustFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) { + v := FlagOrFallback(cmd, primary, aliases...) + if v == "" { + return "", fmt.Errorf("flag --%s is required\n hint: %s", primary, cmd.Example) + } + return v, nil +} + +// MustFlagWithHint returns an error with an explicit usage example when the +// flag is empty. +func MustFlagWithHint(cmd *cobra.Command, name, example string) (string, error) { + v := MustGetFlag(cmd, name) + if v == "" { + return "", fmt.Errorf("flag --%s is required\n hint: %s", name, example) + } + return v, nil +} + +// ValidateRequiredFlags checks that all named string flags are non-empty. +// Returns a formatted error listing all missing flags, or nil. +func ValidateRequiredFlags(cmd *cobra.Command, names ...string) error { + var missing []string + for _, name := range names { + v, _ := cmd.Flags().GetString(name) + if v == "" { + missing = append(missing, "--"+name) + } + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf("missing required flag(s): %s\n usage: %s\n example:\n%s", + strings.Join(missing, ", "), cmd.UseLine(), cmd.Example) +} + +// ValidateRequiredFlagWithAliases checks that at least one of the primary flag +// or its aliases is non-empty. +func ValidateRequiredFlagWithAliases(cmd *cobra.Command, primary string, aliases ...string) error { + if FlagOrFallback(cmd, primary, aliases...) != "" { + return nil + } + all := append([]string{primary}, aliases...) + return fmt.Errorf("missing required flag: --%s (or %s)\n usage: %s\n example:\n%s", + primary, "--"+strings.Join(all[1:], " / --"), cmd.UseLine(), cmd.Example) +} + +// ConfirmDelete asks for interactive confirmation before destructive operations. +// Returns true if --yes/-y flag is set or the user types "yes"/"y". +func ConfirmDelete(cmd *cobra.Command, resourceType, resourceName string) bool { + if yes, _ := cmd.Flags().GetBool("yes"); yes { + return true + } + for _, arg := range os.Args[1:] { + if arg == "--yes" || arg == "-y" { + return true + } + } + + fmt.Fprintf(cmd.ErrOrStderr(), "About to delete %s: %s\n", resourceType, resourceName) + fmt.Fprint(cmd.ErrOrStderr(), "Confirm deletion? (yes/no): ") + + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer == "yes" || answer == "y" { + return true + } + + fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled") + return false +} diff --git a/pkg/cmdutil/time.go b/pkg/cmdutil/time.go new file mode 100644 index 00000000..90f7e4e7 --- /dev/null +++ b/pkg/cmdutil/time.go @@ -0,0 +1,67 @@ +// 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 cmdutil + +import ( + "fmt" + "strings" + "time" +) + +// FlexTimeLayouts is the ordered list of time formats tried by ParseISOTimeToMillis. +var FlexTimeLayouts = []string{ + time.RFC3339, + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05-07:00", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04", + "2006-01-02", + "2006/01/02 15:04:05", + "2006/01/02", + "20060102", +} + +// ParseISOTimeToMillis parses a time string into a millisecond Unix timestamp. +// Supports RFC3339, UTC Z, timezone-less, space-separated, date-only, and more. +// When the input lacks an explicit timezone, Asia/Shanghai is assumed. +func ParseISOTimeToMillis(flagName, value string) (int64, error) { + value = strings.TrimSpace(value) + value = strings.Trim(value, "\"'") + if value == "" { + return 0, fmt.Errorf("flag --%s is required\n hint: example: 2026-03-10T14:00:00+08:00", flagName) + } + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.Local + } + for _, layout := range FlexTimeLayouts { + t, err := time.ParseInLocation(layout, value, loc) + if err == nil { + return t.UnixMilli(), nil + } + } + return 0, fmt.Errorf( + "cannot parse time for --%s (input: %q)\n hint: supported formats: 2026-03-23T14:00:00+08:00, 2026-03-23 14:00:00, 2026-03-23", + flagName, value) +} + +// ValidateTimeRange checks that endMs is strictly after startMs. +func ValidateTimeRange(startMs, endMs int64) error { + if endMs <= startMs { + return fmt.Errorf("--end must be after --start\n hint: swap the values or adjust the time range") + } + return nil +} diff --git a/internal/config/constants.go b/pkg/config/constants.go similarity index 100% rename from internal/config/constants.go rename to pkg/config/constants.go diff --git a/internal/config/constants_test.go b/pkg/config/constants_test.go similarity index 100% rename from internal/config/constants_test.go rename to pkg/config/constants_test.go diff --git a/internal/convert/slices.go b/pkg/convert/slices.go similarity index 100% rename from internal/convert/slices.go rename to pkg/convert/slices.go diff --git a/internal/convert/slices_test.go b/pkg/convert/slices_test.go similarity index 100% rename from internal/convert/slices_test.go rename to pkg/convert/slices_test.go diff --git a/pkg/edition/default.go b/pkg/edition/default.go new file mode 100644 index 00000000..682d9686 --- /dev/null +++ b/pkg/edition/default.go @@ -0,0 +1,23 @@ +// 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 edition + +// defaultHooks returns the open-source edition defaults. +// All function hooks are nil, which the internal code interprets as +// "use standard open-source behaviour". +func defaultHooks() *Hooks { + return &Hooks{ + Name: "open", + } +} diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go new file mode 100644 index 00000000..54466514 --- /dev/null +++ b/pkg/edition/edition.go @@ -0,0 +1,106 @@ +// 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 edition provides an extension-point mechanism that allows private +// overlay modules (e.g. an internal distribution) to customise CLI behaviour +// without modifying the open-source core. The open-source build uses the +// zero-value defaults; an overlay calls Override before Execute. +package edition + +import ( + "context" + "sync" + + "github.com/spf13/cobra" +) + +// ServerInfo describes a static MCP server endpoint. +type ServerInfo struct { + ID string + Name string + Endpoint string + Prefixes []string +} + +// ContentBlock is a single content item in a ToolResult. +type ContentBlock struct { + Type string + Text string +} + +// ToolResult holds the response from an MCP tool call. +type ToolResult struct { + Content []ContentBlock +} + +// ToolCaller abstracts MCP tool invocation so private overlays can call MCP +// tools without importing internal packages. The open-source core provides a +// concrete adapter wrapping executor.Runner. +type ToolCaller interface { + // CallTool invokes an MCP tool by product ID and tool name. + CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*ToolResult, error) + // Format returns the current output format ("json", "table", "raw"). + Format() string + // DryRun returns true when --dry-run is active. + DryRun() bool +} + +// Hooks groups all edition-specific behavioural overrides. Zero values +// fall back to open-source defaults so the struct is safe to use as-is. +type Hooks struct { + // --- identity --- + Name string // "open" (default) / overlay identifier + ScenarioCode string // injected into x-dingtalk-scenario-code header + + // --- runtime mode --- + IsEmbedded bool // true when running inside a host application + HideAuthLogin bool // true suppresses the "dws auth login" command + AutoPurgeToken bool // true deletes local token data on expiry + + // --- paths --- + ConfigDir func() string // custom config directory; nil → ~/.dws + + // --- HTTP headers --- + MergeHeaders func(base map[string]string) map[string]string + + // --- auth hooks --- + OnAuthError func(configDir string, err error) error + TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error) + + // --- product & endpoint --- + StaticServers func() []ServerInfo // non-nil → skip Market discovery + VisibleProducts func() []string // non-nil → override help visibility + RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands +} + +var ( + mu sync.RWMutex + current = defaultHooks() +) + +// Get returns the active edition hooks (never nil). +func Get() *Hooks { + mu.RLock() + defer mu.RUnlock() + return current +} + +// Override replaces the active edition hooks. Must be called before Execute. +func Override(h *Hooks) { + if h == nil { + return + } + mu.Lock() + defer mu.Unlock() + current = h +} diff --git a/pkg/editiontest/contract.go b/pkg/editiontest/contract.go new file mode 100644 index 00000000..cc43f11d --- /dev/null +++ b/pkg/editiontest/contract.go @@ -0,0 +1,88 @@ +// 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 editiontest provides contract-test helpers that any edition.Hooks +// implementation (open-source or overlay) must satisfy. It lives in a +// non-_test package so external modules can import and run these checks. +package editiontest + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +// RunContractTests validates that the supplied Hooks implementation +// satisfies the behavioural contract required by the open-source core. +// Call this from both the open-source test suite and any overlay's tests. +func RunContractTests(t *testing.T, h *edition.Hooks) { + t.Helper() + + if h == nil { + t.Fatal("Hooks must not be nil") + } + + t.Run("Name", func(t *testing.T) { + if h.Name == "" { + t.Error("Hooks.Name must be non-empty") + } + }) + + t.Run("ConfigDir", func(t *testing.T) { + if h.ConfigDir != nil { + dir := h.ConfigDir() + if dir == "" { + t.Error("ConfigDir() must return a non-empty path when set") + } + } + }) + + t.Run("MergeHeaders_preserves_base", func(t *testing.T) { + if h.MergeHeaders == nil { + return + } + base := map[string]string{"x-test": "value"} + merged := h.MergeHeaders(base) + if merged == nil { + t.Fatal("MergeHeaders must return a non-nil map") + } + if merged["x-test"] != "value" { + t.Error("MergeHeaders must preserve base headers") + } + }) + + t.Run("StaticServers_non_empty_when_set", func(t *testing.T) { + if h.StaticServers == nil { + return + } + servers := h.StaticServers() + if len(servers) == 0 { + t.Error("StaticServers() must return at least one server when set") + } + for i, s := range servers { + if s.ID == "" || s.Endpoint == "" { + t.Errorf("StaticServers()[%d]: ID and Endpoint must be non-empty", i) + } + } + }) + + t.Run("VisibleProducts_non_empty_when_set", func(t *testing.T) { + if h.VisibleProducts == nil { + return + } + products := h.VisibleProducts() + if len(products) == 0 { + t.Error("VisibleProducts() must return at least one product when set") + } + }) +} diff --git a/pkg/editiontest/contract_test.go b/pkg/editiontest/contract_test.go new file mode 100644 index 00000000..2ec86a0c --- /dev/null +++ b/pkg/editiontest/contract_test.go @@ -0,0 +1,25 @@ +// 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 editiontest_test + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/editiontest" +) + +func TestOpenSourceDefaultHooksContract(t *testing.T) { + editiontest.RunContractTests(t, edition.Get()) +} diff --git a/internal/validate/input.go b/pkg/validate/input.go similarity index 100% rename from internal/validate/input.go rename to pkg/validate/input.go diff --git a/internal/validate/input_test.go b/pkg/validate/input_test.go similarity index 100% rename from internal/validate/input_test.go rename to pkg/validate/input_test.go diff --git a/internal/validate/resource.go b/pkg/validate/resource.go similarity index 100% rename from internal/validate/resource.go rename to pkg/validate/resource.go diff --git a/internal/validate/resource_test.go b/pkg/validate/resource_test.go similarity index 100% rename from internal/validate/resource_test.go rename to pkg/validate/resource_test.go diff --git a/internal/validate/sanitize.go b/pkg/validate/sanitize.go similarity index 100% rename from internal/validate/sanitize.go rename to pkg/validate/sanitize.go diff --git a/internal/validate/sanitize_test.go b/pkg/validate/sanitize_test.go similarity index 100% rename from internal/validate/sanitize_test.go rename to pkg/validate/sanitize_test.go diff --git a/test/unit/open_source_policy_test.go b/test/unit/open_source_policy_test.go index e3507f9a..b6c3ab1d 100644 --- a/test/unit/open_source_policy_test.go +++ b/test/unit/open_source_policy_test.go @@ -30,6 +30,11 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) { "WriteToken" + "Marker", "Token" + "Marker", "tokenJSON" + "File", + "REWIND_" + "REQUEST_ID", + "REWIND_" + "SESSION_ID", + "REWIND_" + "MESSAGE_ID", + "MergeWukong" + "MCPHeaders", + "buildMode ==" + " \"real\"", } var matches []string @@ -39,7 +44,7 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) { } if d.IsDir() { switch d.Name() { - case ".git", ".worktrees", "node_modules", "dist": + case ".git", ".worktrees", "node_modules", "dist", "plans": return filepath.SkipDir } return nil