From dc5b5581f6e97df9a979c34a9be9050936b13230 Mon Sep 17 00:00:00 2001 From: pika <759349196@qq.com> Date: Thu, 2 Apr 2026 17:28:14 +0800 Subject: [PATCH] add version info, profiles, and release packaging --- .github/workflows/release.yml | 42 ++----- CHANGELOG.md | 18 +++ Makefile | 17 ++- README.md | 30 +++-- cmd/cloudcanal/main.go | 115 +++++++++++++++++ cmd/cloudcanal/main_test.go | 107 ++++++++++++++++ docs/cloudcanal-cli-usage.md | 56 +++++++-- internal/app/runtime.go | 215 ++++++++++++++++++++++++++++---- internal/buildinfo/info.go | 21 ++++ internal/config/config.go | 204 +++++++++++++++++++++++------- internal/config/wizard.go | 68 +++------- internal/i18n/i18n.go | 24 +++- internal/repl/completion.go | 2 +- internal/repl/help.go | 102 ++++++++++++++- internal/repl/registry.go | 30 +++++ internal/repl/shell.go | 142 +++++++++++++++++++-- internal/repl/text.go | 46 ++++++- internal/repl/ux.go | 17 +-- scripts/build_release_assets.sh | 117 +++++++++++++++++ scripts/install.sh | 6 + test/config/config_test.go | 76 ++++++++--- test/config/wizard_test.go | 47 +++---- test/repl/alignment_test.go | 2 +- test/repl/completion_test.go | 5 +- test/repl/shell_test.go | 175 +++++++++++++++++++++++++- 25 files changed, 1427 insertions(+), 257 deletions(-) create mode 100644 cmd/cloudcanal/main_test.go create mode 100644 internal/buildinfo/info.go create mode 100755 scripts/build_release_assets.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62a0129..4903135 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,41 +25,10 @@ jobs: - name: Build release assets run: | - set -euo pipefail - mkdir -p dist - VERSION="${GITHUB_REF_NAME:-dev}" - for target in \ - "darwin amd64" \ - "darwin arm64" \ - "linux amd64" \ - "linux arm64" - do - read -r goos goarch <<<"$target" - workdir="$(mktemp -d)" - asset_name="cloudcanal_${goos}_${goarch}.tar.gz" - GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "$workdir/cloudcanal" ./cmd/cloudcanal - tar -C "$workdir" -czf "dist/$asset_name" cloudcanal - rm -rf "$workdir" - done - ( - cd dist - sha256sum cloudcanal_*.tar.gz > checksums.txt - ) - - - name: Generate release notes from CHANGELOG - run: | - set -euo pipefail - version="${GITHUB_REF_NAME#v}" - output="dist/release-notes.md" - awk -v version="$version" ' - $0 ~ "^## \\[" version "\\]" { in_section = 1 } - /^## \[/ && in_section && $0 !~ "^## \\[" version "\\]" { exit } - in_section { print } - ' CHANGELOG.md > "$output" - - if [[ ! -s "$output" ]]; then - echo "Release notes for version ${version} not found in CHANGELOG.md" >&2 - exit 1 + if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then + VERSION="${GITHUB_REF_NAME#v}" make release-assets + else + make release-assets fi - name: Publish GitHub Release @@ -68,4 +37,7 @@ jobs: files: | dist/cloudcanal_*.tar.gz dist/checksums.txt + dist/install.sh + dist/uninstall.sh + dist/release-notes.md body_path: dist/release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b8151b9..95d341d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [0.1.2] - 2026-04-02 + +### Added + +- Added `version` and `--version` commands with `version`, `commit`, and `buildTime` output. +- Added profile-aware configuration management with `config profiles list|use|add|remove`. +- Added build metadata injection and release asset packaging via a shared `make release-assets` flow. + +### Changed + +- Switched CLI configuration storage to `language + currentProfile + profiles` schema under `~/.cloudcanal-cli/config.json`. +- Updated `config show`, REPL prompt, help text, completion, and docs to expose the active profile context. +- Enhanced release delivery to publish installer assets and print installed build metadata after installation. + +### Removed + +- Removed support for silently reusing the legacy single-profile config format; users are now prompted to reinitialize into the profile-based schema. + ## [0.1.1] - 2026-04-02 ### Changed diff --git a/Makefile b/Makefile index 825e3a0..588e07c 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,20 @@ BIN ?= bin/cloudcanal PKG ?= ./... TEST_PKG ?= ./test/... COVER_PROFILE ?= coverage.out +DIST_DIR ?= dist +VERSION ?= dev +COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown) +BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +GO_BUILD_FLAGS ?= +EXTRA_LDFLAGS ?= +BUILDINFO_PKG := github.com/ClouGence/cloudcanal-openapi-cli/internal/buildinfo +LDFLAGS ?= -X $(BUILDINFO_PKG).Version=$(VERSION) -X $(BUILDINFO_PKG).Commit=$(COMMIT) -X $(BUILDINFO_PKG).BuildTime=$(BUILD_TIME) $(EXTRA_LDFLAGS) -.PHONY: build test vet test-race cover ci clean +.PHONY: build test vet test-race cover ci release-assets clean build: mkdir -p $(dir $(BIN)) - $(GO) build -o $(BIN) ./cmd/cloudcanal + $(GO) build $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/cloudcanal test: $(GO) test $(PKG) @@ -25,5 +33,8 @@ cover: ci: test vet test-race cover build +release-assets: + ./scripts/build_release_assets.sh + clean: - rm -rf $(dir $(BIN)) $(COVER_PROFILE) + rm -rf $(dir $(BIN)) $(DIST_DIR) $(COVER_PROFILE) diff --git a/README.md b/README.md index c5a45a9..0910a18 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,13 @@ cloudcanal 单次命令: ```bash +cloudcanal version +cloudcanal --version cloudcanal --help cloudcanal jobs --help +cloudcanal config profiles list +cloudcanal config profiles use dev +cloudcanal config profiles add test cloudcanal config lang set zh cloudcanal jobs list cloudcanal jobs show 123 @@ -64,20 +69,29 @@ cloudcanal jobs list --type SYNC --output json ```json { - "apiBaseUrl": "https://cc.example.com", - "accessKey": "your-ak", - "secretKey": "your-sk", - "language": "en" + "language": "en", + "currentProfile": "dev", + "profiles": { + "dev": { + "apiBaseUrl": "https://cc.example.com", + "accessKey": "your-ak", + "secretKey": "your-sk" + } + } } ``` -如果你需要调整网络行为,也可以追加这些可选项: +如果你需要调整网络行为,也可以在具体 profile 下追加这些可选项: ```json { - "httpTimeoutSeconds": 15, - "httpReadMaxRetries": 2, - "httpReadRetryBackoffMillis": 300 + "profiles": { + "dev": { + "httpTimeoutSeconds": 15, + "httpReadMaxRetries": 2, + "httpReadRetryBackoffMillis": 300 + } + } } ``` diff --git a/cmd/cloudcanal/main.go b/cmd/cloudcanal/main.go index a3ec2af..bb919f4 100644 --- a/cmd/cloudcanal/main.go +++ b/cmd/cloudcanal/main.go @@ -1,11 +1,14 @@ package main import ( + "encoding/json" + "errors" "fmt" "os" "strings" "github.com/ClouGence/cloudcanal-openapi-cli/internal/app" + "github.com/ClouGence/cloudcanal-openapi-cli/internal/buildinfo" "github.com/ClouGence/cloudcanal-openapi-cli/internal/config" "github.com/ClouGence/cloudcanal-openapi-cli/internal/console" "github.com/ClouGence/cloudcanal-openapi-cli/internal/i18n" @@ -61,6 +64,9 @@ func handleEarlyCommands(args []string) (bool, int) { fmt.Println(helpText) return true, 0 } + if handled, exitCode := handleVersionCommand(args); handled { + return true, exitCode + } if len(args) == 0 { return false, 0 @@ -84,3 +90,112 @@ func handleEarlyCommands(args []string) (bool, int) { return false, 0 } } + +func handleVersionCommand(args []string) (bool, int) { + filtered, format, err := extractOutputFormat(args) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return true, 1 + } + if len(filtered) == 0 { + return false, 0 + } + + switch { + case len(filtered) == 1 && strings.EqualFold(filtered[0], "--version"): + return true, printVersion(format) + case strings.EqualFold(filtered[0], "version"): + if len(filtered) != 1 { + fmt.Fprintln(os.Stderr, versionUsageText()) + return true, 1 + } + return true, printVersion(format) + case containsVersionFlag(filtered): + fmt.Fprintln(os.Stderr, versionFlagErrorText()) + return true, 1 + default: + return false, 0 + } +} + +func printVersion(format string) int { + info := buildinfo.Current() + if format == "json" { + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return 1 + } + fmt.Println(string(data)) + return 0 + } + + fmt.Println("version: " + info.Version) + fmt.Println("commit: " + info.Commit) + fmt.Println("buildTime: " + info.BuildTime) + return 0 +} + +func extractOutputFormat(args []string) ([]string, string, error) { + format := "text" + filtered := make([]string, 0, len(args)) + seen := false + + for i := 0; i < len(args); i++ { + token := args[i] + switch { + case token == "--output": + if i+1 >= len(args) { + return nil, "", errors.New(i18n.T("parser.outputOptionRequiresValue")) + } + if seen { + return nil, "", errors.New(i18n.T("parser.duplicateOption", "output")) + } + value := strings.ToLower(strings.TrimSpace(args[i+1])) + if value != "text" && value != "json" { + return nil, "", errors.New(i18n.T("parser.outputOptionInvalid")) + } + format = value + seen = true + i++ + case strings.HasPrefix(token, "--output="): + if seen { + return nil, "", errors.New(i18n.T("parser.duplicateOption", "output")) + } + _, value, _ := strings.Cut(token, "=") + value = strings.ToLower(strings.TrimSpace(value)) + if value != "text" && value != "json" { + return nil, "", errors.New(i18n.T("parser.outputOptionInvalid")) + } + format = value + seen = true + default: + filtered = append(filtered, token) + } + } + + return filtered, format, nil +} + +func containsVersionFlag(args []string) bool { + for _, arg := range args { + if strings.EqualFold(arg, "--version") { + return true + } + } + return false +} + +func versionUsageText() string { + if i18n.CurrentLanguage() == i18n.Chinese { + return "用法:version" + } + return "Usage: version" +} + +func versionFlagErrorText() string { + if i18n.CurrentLanguage() == i18n.Chinese { + return "--version 只能单独使用,或与 --output 一起使用" + } + return "--version can only be used by itself or with --output" +} diff --git a/cmd/cloudcanal/main_test.go b/cmd/cloudcanal/main_test.go new file mode 100644 index 0000000..854f13d --- /dev/null +++ b/cmd/cloudcanal/main_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHandleEarlyCommandsSupportsVersionWithLegacyConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, ".cloudcanal-cli", "config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + legacyConfig := `{"language":"zh","apiBaseUrl":"https://cc.example.com","accessKey":"ak","secretKey":"sk"}` + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + stdout, stderr := captureProcessOutput(t, func() (bool, int) { + return handleEarlyCommands([]string{"version"}) + }) + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + for _, want := range []string{"version: ", "commit: ", "buildTime: "} { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout missing %q in %q", want, stdout) + } + } +} + +func TestHandleEarlyCommandsSupportsVersionFlagJSONWithInvalidConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(home, ".cloudcanal-cli", "config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(configPath, []byte("{invalid"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + stdout, stderr := captureProcessOutput(t, func() (bool, int) { + return handleEarlyCommands([]string{"--version", "--output", "json"}) + }) + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("json.Unmarshal(stdout) error = %v, stdout = %q", err, stdout) + } + for _, key := range []string{"version", "commit", "buildTime"} { + if _, ok := payload[key]; !ok { + t.Fatalf("payload missing %q: %#v", key, payload) + } + } +} + +func captureProcessOutput(t *testing.T, fn func() (bool, int)) (string, string) { + t.Helper() + + originalStdout := os.Stdout + originalStderr := os.Stderr + + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe(stdout) error = %v", err) + } + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe(stderr) error = %v", err) + } + + os.Stdout = stdoutWriter + os.Stderr = stderrWriter + + handled, exitCode := fn() + + _ = stdoutWriter.Close() + _ = stderrWriter.Close() + os.Stdout = originalStdout + os.Stderr = originalStderr + + stdoutBytes, err := io.ReadAll(stdoutReader) + if err != nil { + t.Fatalf("ReadAll(stdout) error = %v", err) + } + stderrBytes, err := io.ReadAll(stderrReader) + if err != nil { + t.Fatalf("ReadAll(stderr) error = %v", err) + } + _ = stdoutReader.Close() + _ = stderrReader.Close() + + if !handled || exitCode != 0 { + t.Fatalf("handled=%v exitCode=%d, want true/0", handled, exitCode) + } + + return strings.TrimSpace(string(stdoutBytes)), strings.TrimSpace(string(stderrBytes)) +} diff --git a/docs/cloudcanal-cli-usage.md b/docs/cloudcanal-cli-usage.md index 5cda3de..49dfbb0 100644 --- a/docs/cloudcanal-cli-usage.md +++ b/docs/cloudcanal-cli-usage.md @@ -13,8 +13,12 @@ cloudcanal 单次命令模式: ```bash +cloudcanal version +cloudcanal --version cloudcanal --help cloudcanal jobs --help +cloudcanal config profiles list +cloudcanal config profiles use dev cloudcanal jobs list cloudcanal jobs create --body-file create-job.json cloudcanal datasources list --type MYSQL @@ -63,22 +67,29 @@ curl -fsSL https://raw.githubusercontent.com/ClouGence/cloudcanal-openapi-cli/ma ```json { - "apiBaseUrl": "https://cc.example.com", - "accessKey": "your-ak", - "secretKey": "your-sk", "language": "en", - "httpTimeoutSeconds": 15, - "httpReadMaxRetries": 2, - "httpReadRetryBackoffMillis": 300 + "currentProfile": "dev", + "profiles": { + "dev": { + "apiBaseUrl": "https://cc.example.com", + "accessKey": "your-ak", + "secretKey": "your-sk", + "httpTimeoutSeconds": 15, + "httpReadMaxRetries": 2, + "httpReadRetryBackoffMillis": 300 + } + } } ``` 说明: +- `language` 是全局 CLI 文案语言,支持 `en` 和 `zh` +- `currentProfile` 是当前激活的环境 +- `profiles` 下保存每套环境的连接信息和网络参数 - `apiBaseUrl` 必须包含 `http://` 或 `https://` - `accessKey` 是访问密钥 ID - `secretKey` 是访问密钥 Secret,不会在 `config show` 中明文展示 -- `language` 是 CLI 文案语言,支持 `en` 和 `zh` - `httpTimeoutSeconds` 是单次 HTTP 请求超时秒数,默认 `10` - `httpReadMaxRetries` 是只读请求的最大重试次数,默认 `0` - `httpReadRetryBackoffMillis` 是只读请求的首次退避毫秒数,默认 `250` @@ -97,6 +108,7 @@ curl -fsSL https://raw.githubusercontent.com/ClouGence/cloudcanal-openapi-cli/ma - `help job-config` - `help schemas` - `help config` +- `help version` 绝大多数命令组也支持 `--help`,例如: @@ -106,6 +118,16 @@ cloudcanal jobs list --help cloudcanal config --help ``` +`version` / `--version` + +显示当前 CLI 的: + +- `version` +- `commit` +- `buildTime` + +也支持 `--output json`。 + ## 复杂请求体 对于和 SDK 一一对应、字段较多的接口,CLI 统一支持: @@ -123,11 +145,27 @@ cloudcanal config --help `config show` -显示当前配置,`accessKey` 会做掩码处理,同时会显示当前 `language`。 +显示当前配置,`accessKey` 会做掩码处理,同时会显示当前 `currentProfile` 和全局 `language`。 `config init` -重新执行初始化向导,更新配置。 +重新执行当前 profile 的初始化向导,更新配置。 + +`config profiles list` + +显示所有 profile,并标记当前正在使用的环境。 + +`config profiles use ` + +切换当前 profile。 + +`config profiles add ` + +新增 profile,并立即进入初始化向导。 + +`config profiles remove ` + +删除非当前 profile。 `config lang show` diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 9529267..cc4cd48 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -1,6 +1,8 @@ package app import ( + "errors" + "github.com/ClouGence/cloudcanal-openapi-cli/internal/cluster" "github.com/ClouGence/cloudcanal-openapi-cli/internal/config" "github.com/ClouGence/cloudcanal-openapi-cli/internal/console" @@ -16,6 +18,9 @@ import ( type RuntimeContext interface { Config() config.AppConfig + CurrentProfile() string + Language() string + ProfileSummaries() []config.ProfileSummary DataJobs() datajob.Operations DataSources() datasource.Operations Clusters() cluster.Operations @@ -24,19 +29,24 @@ type RuntimeContext interface { JobConfigs() jobconfig.Operations Schemas() ccschema.Operations Reinitialize(io console.IO) (bool, error) + AddProfile(name string, io console.IO) (bool, error) + UseProfile(name string) error + RemoveProfile(name string) error SetLanguage(language string) error } type Runtime struct { - configService *config.Service - config config.AppConfig - dataJobs datajob.Operations - dataSources datasource.Operations - clusters cluster.Operations - workers worker.Operations - consoleJobs consolejob.Operations - jobConfigs jobconfig.Operations - schemas ccschema.Operations + configService *config.Service + state config.State + currentProfile string + config config.AppConfig + dataJobs datajob.Operations + dataSources datasource.Operations + clusters cluster.Operations + workers worker.Operations + consoleJobs consolejob.Operations + jobConfigs jobconfig.Operations + schemas ccschema.Operations } func NewRuntime(configService *config.Service) *Runtime { @@ -48,12 +58,16 @@ func (r *Runtime) InitializeIfNeeded(io console.IO) (bool, error) { return r.Reinitialize(io) } - cfg, err := r.configService.Load() + state, err := r.configService.Load() if err != nil { - io.Println(i18n.T("runtime.invalidConfig", err.Error())) + if errors.Is(err, config.ErrLegacyFormat) { + io.Println(i18n.T("runtime.legacyConfig")) + } else { + io.Println(i18n.T("runtime.invalidConfig", err.Error())) + } return r.Reinitialize(io) } - if err := r.activate(cfg); err != nil { + if err := r.activateState(state); err != nil { io.Println(i18n.T("runtime.invalidConfig", err.Error())) return r.Reinitialize(io) } @@ -61,8 +75,61 @@ func (r *Runtime) InitializeIfNeeded(io console.IO) (bool, error) { } func (r *Runtime) Reinitialize(io console.IO) (bool, error) { - _ = i18n.SetLanguage(r.config.NormalizedLanguage()) - wizard := config.NewWizard(io, r.configService, r.validateConfig, r.config) + state := r.state + if state.Language == "" { + state.Language = r.Language() + } + _ = i18n.SetLanguage(state.NormalizedLanguage()) + + profileName := r.currentProfile + if profileName == "" { + profileName = config.DefaultProfileName + } + + initial := r.config + if existing, ok := state.Profiles[profileName]; ok { + initial = existing + } + + wizard := config.NewWizard(io, r.validateConfig, profileName, initial) + cfg, err := wizard.Run() + if err != nil { + return false, err + } + if cfg == nil { + io.Println(i18n.T("runtime.initCancelled")) + return false, nil + } + + state.Language = state.NormalizedLanguage() + if state.Profiles == nil { + state.Profiles = make(map[string]config.AppConfig) + } + state.CurrentProfile = profileName + state.Profiles[profileName] = *cfg + if err := r.saveAndActivate(state); err != nil { + return false, err + } + io.Println(i18n.T("wizard.savedTo", r.configService.Path())) + return true, nil +} + +func (r *Runtime) AddProfile(name string, io console.IO) (bool, error) { + if err := config.ValidateProfileName(name); err != nil { + return false, err + } + + state := r.state + profileName := config.NormalizeProfileName(name) + if state.Profiles == nil { + state.Profiles = make(map[string]config.AppConfig) + } + if _, exists := state.Profiles[profileName]; exists { + return false, errors.New(i18n.T("config.profileExists", profileName)) + } + + _ = i18n.SetLanguage(state.NormalizedLanguage()) + wizard := config.NewWizard(io, r.validateConfig, profileName, config.AppConfig{}) cfg, err := wizard.Run() if err != nil { return false, err @@ -71,16 +138,85 @@ func (r *Runtime) Reinitialize(io console.IO) (bool, error) { io.Println(i18n.T("runtime.initCancelled")) return false, nil } - if err := r.activate(*cfg); err != nil { + + state.Profiles[profileName] = *cfg + if state.CurrentProfile == "" { + state.CurrentProfile = profileName + } + if err := r.configService.Save(state); err != nil { return false, err } + if state.ActiveProfileName() == profileName { + if err := r.activateState(state); err != nil { + return false, err + } + } else { + r.state = state + } + io.Println(i18n.T("wizard.savedTo", r.configService.Path())) return true, nil } +func (r *Runtime) UseProfile(name string) error { + if err := config.ValidateProfileName(name); err != nil { + return err + } + + state := r.state + profileName := config.NormalizeProfileName(name) + if _, ok := state.Profiles[profileName]; !ok { + return errors.New(i18n.T("config.profileNotFound", profileName)) + } + + next := state + next.CurrentProfile = profileName + if err := r.prepareState(next); err != nil { + return err + } + if err := r.configService.Save(next); err != nil { + return err + } + return r.activateState(next) +} + +func (r *Runtime) RemoveProfile(name string) error { + if err := config.ValidateProfileName(name); err != nil { + return err + } + + state := r.state + profileName := config.NormalizeProfileName(name) + if _, ok := state.Profiles[profileName]; !ok { + return errors.New(i18n.T("config.profileNotFound", profileName)) + } + if state.ActiveProfileName() == profileName { + return errors.New(i18n.T("config.profileRemoveActive", profileName)) + } + + delete(state.Profiles, profileName) + if err := r.configService.Save(state); err != nil { + return err + } + r.state = state + return nil +} + func (r *Runtime) Config() config.AppConfig { return r.config } +func (r *Runtime) CurrentProfile() string { + return r.currentProfile +} + +func (r *Runtime) Language() string { + return r.state.NormalizedLanguage() +} + +func (r *Runtime) ProfileSummaries() []config.ProfileSummary { + return r.state.Summaries() +} + func (r *Runtime) DataJobs() datajob.Operations { return r.dataJobs } @@ -110,17 +246,25 @@ func (r *Runtime) Schemas() ccschema.Operations { } func (r *Runtime) SetLanguage(language string) error { - cfg := r.config - cfg.Language = language - cfg = cfg.WithDefaults() - if err := r.configService.Save(cfg); err != nil { + normalized := i18n.NormalizeLanguage(language) + if normalized == "" { + return errors.New(i18n.T("config.languageUnsupported")) + } + + state := r.state + if state.Profiles == nil { + return errors.New(i18n.T("config.noProfilesConfigured")) + } + state.Language = normalized + if err := r.configService.Save(state); err != nil { return err } - return r.activate(cfg) + r.state = state + return i18n.SetLanguage(normalized) } func (r *Runtime) validateConfig(cfg config.AppConfig) error { - _ = i18n.SetLanguage(cfg.NormalizedLanguage()) + _ = i18n.SetLanguage(r.Language()) client, err := openapi.NewClient(cfg) if err != nil { return err @@ -128,13 +272,36 @@ func (r *Runtime) validateConfig(cfg config.AppConfig) error { return client.ProbeAuthentication() } -func (r *Runtime) activate(cfg config.AppConfig) error { - cfg = cfg.WithDefaults() - _ = i18n.SetLanguage(cfg.NormalizedLanguage()) +func (r *Runtime) saveAndActivate(state config.State) error { + if err := r.configService.Save(state); err != nil { + return err + } + return r.activateState(state) +} + +func (r *Runtime) prepareState(state config.State) error { + _, cfg, err := state.ActiveProfile() + if err != nil { + return err + } + _, err = openapi.NewClient(cfg) + return err +} + +func (r *Runtime) activateState(state config.State) error { + if err := r.prepareState(state); err != nil { + return err + } + + profileName, cfg, _ := state.ActiveProfile() + _ = i18n.SetLanguage(state.NormalizedLanguage()) client, err := openapi.NewClient(cfg) if err != nil { return err } + + r.state = state + r.currentProfile = profileName r.config = cfg r.dataJobs = datajob.NewService(client) r.dataSources = datasource.NewService(client) diff --git a/internal/buildinfo/info.go b/internal/buildinfo/info.go new file mode 100644 index 0000000..df11c3c --- /dev/null +++ b/internal/buildinfo/info.go @@ -0,0 +1,21 @@ +package buildinfo + +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildTime string `json:"buildTime"` +} + +var ( + Version = "dev" + Commit = "unknown" + BuildTime = "unknown" +) + +func Current() Info { + return Info{ + Version: Version, + Commit: Commit, + BuildTime: BuildTime, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index bd1b599..067a544 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,62 +3,80 @@ package config import ( "encoding/json" "errors" - "github.com/ClouGence/cloudcanal-openapi-cli/internal/i18n" + "fmt" "net/url" "os" "path/filepath" + "sort" "strings" "time" + + "github.com/ClouGence/cloudcanal-openapi-cli/internal/i18n" ) const ( defaultHTTPTimeoutSeconds = 10 defaultHTTPReadRetryBackoffMillis = 250 + DefaultProfileName = "dev" ) +var ErrLegacyFormat = errors.New("legacy config format") + type AppConfig struct { APIBaseURL string `json:"apiBaseUrl"` AccessKey string `json:"accessKey"` SecretKey string `json:"secretKey"` - Language string `json:"language,omitempty"` HTTPTimeoutSeconds int `json:"httpTimeoutSeconds,omitempty"` HTTPReadMaxRetries int `json:"httpReadMaxRetries,omitempty"` HTTPReadRetryBackoffMillis int `json:"httpReadRetryBackoffMillis,omitempty"` } +type State struct { + Language string `json:"language,omitempty"` + CurrentProfile string `json:"currentProfile,omitempty"` + Profiles map[string]AppConfig `json:"profiles,omitempty"` +} + +type ProfileSummary struct { + Name string `json:"name"` + APIBaseURL string `json:"apiBaseUrl"` + Current bool `json:"current"` +} + func (c AppConfig) Validate() error { - language := c.NormalizedLanguage() + return c.ValidateForLanguage(i18n.CurrentLanguage()) +} + +func (c AppConfig) ValidateForLanguage(language string) error { + normalizedLanguage := normalizeLanguage(language) if strings.TrimSpace(c.APIBaseURL) == "" { - return errors.New(i18n.TFor(language, "config.apiBaseUrlRequired")) + return errors.New(i18n.TFor(normalizedLanguage, "config.apiBaseUrlRequired")) } if strings.TrimSpace(c.AccessKey) == "" { - return errors.New(i18n.TFor(language, "config.accessKeyRequired")) + return errors.New(i18n.TFor(normalizedLanguage, "config.accessKeyRequired")) } if strings.TrimSpace(c.SecretKey) == "" { - return errors.New(i18n.TFor(language, "config.secretKeyRequired")) - } - if normalized := i18n.NormalizeLanguage(c.Language); normalized == "" && strings.TrimSpace(c.Language) != "" { - return errors.New(i18n.T("config.languageUnsupported")) + return errors.New(i18n.TFor(normalizedLanguage, "config.secretKeyRequired")) } if c.HTTPTimeoutSeconds < 0 { - return errors.New(i18n.TFor(language, "config.httpTimeoutSecondsInvalid")) + return errors.New(i18n.TFor(normalizedLanguage, "config.httpTimeoutSecondsInvalid")) } if c.HTTPReadMaxRetries < 0 { - return errors.New(i18n.TFor(language, "config.httpReadMaxRetriesInvalid")) + return errors.New(i18n.TFor(normalizedLanguage, "config.httpReadMaxRetriesInvalid")) } if c.HTTPReadRetryBackoffMillis < 0 { - return errors.New(i18n.TFor(language, "config.httpReadRetryBackoffMillisInvalid")) + return errors.New(i18n.TFor(normalizedLanguage, "config.httpReadRetryBackoffMillisInvalid")) } parsed, err := url.Parse(strings.TrimSpace(c.APIBaseURL)) if err != nil { - return errors.New(i18n.TFor(language, "config.apiBaseUrlInvalid")) + return errors.New(i18n.TFor(normalizedLanguage, "config.apiBaseUrlInvalid")) } if parsed.Scheme != "http" && parsed.Scheme != "https" { - return errors.New(i18n.TFor(language, "config.apiBaseUrlScheme")) + return errors.New(i18n.TFor(normalizedLanguage, "config.apiBaseUrlScheme")) } if strings.TrimSpace(parsed.Host) == "" { - return errors.New(i18n.TFor(language, "config.apiBaseUrlHost")) + return errors.New(i18n.TFor(normalizedLanguage, "config.apiBaseUrlHost")) } return nil } @@ -68,16 +86,7 @@ func (c AppConfig) NormalizedBaseURL() string { return strings.TrimRight(value, "/") } -func (c AppConfig) NormalizedLanguage() string { - normalized := i18n.NormalizeLanguage(c.Language) - if normalized == "" { - return i18n.DefaultLanguage() - } - return normalized -} - func (c AppConfig) WithDefaults() AppConfig { - c.Language = c.NormalizedLanguage() return c } @@ -110,6 +119,67 @@ func (c AppConfig) HTTPReadRetryBackoffMillisValue() int { return c.HTTPReadRetryBackoffMillis } +func (s State) NormalizedLanguage() string { + return normalizeLanguage(s.Language) +} + +func (s State) ActiveProfileName() string { + return NormalizeProfileName(s.CurrentProfile) +} + +func (s State) Validate() error { + language := s.NormalizedLanguage() + if normalized := i18n.NormalizeLanguage(s.Language); normalized == "" && strings.TrimSpace(s.Language) != "" { + return errors.New(i18n.TFor(language, "config.languageUnsupported")) + } + if len(s.Profiles) == 0 { + return errors.New(i18n.TFor(language, "config.noProfilesConfigured")) + } + current := s.ActiveProfileName() + if current == "" { + return errors.New(i18n.TFor(language, "config.currentProfileRequired")) + } + if _, ok := s.Profiles[current]; !ok { + return errors.New(i18n.TFor(language, "config.currentProfileMissing", current)) + } + for name, cfg := range s.Profiles { + if NormalizeProfileName(name) == "" { + return errors.New(i18n.TFor(language, "config.profileNameRequired")) + } + if err := cfg.ValidateForLanguage(language); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + return nil +} + +func (s State) ActiveProfile() (string, AppConfig, error) { + if err := s.Validate(); err != nil { + return "", AppConfig{}, err + } + name := s.ActiveProfileName() + return name, s.Profiles[name].WithDefaults(), nil +} + +func (s State) Summaries() []ProfileSummary { + names := make([]string, 0, len(s.Profiles)) + for name := range s.Profiles { + names = append(names, name) + } + sort.Strings(names) + + summaries := make([]ProfileSummary, 0, len(names)) + current := s.ActiveProfileName() + for _, name := range names { + summaries = append(summaries, ProfileSummary{ + Name: name, + APIBaseURL: s.Profiles[name].APIBaseURL, + Current: name == current, + }) + } + return summaries +} + type Service struct { path string } @@ -138,7 +208,7 @@ func (s *Service) Exists() bool { return err == nil } -func (s *Service) Load() (AppConfig, error) { +func (s *Service) Load() (State, error) { return s.loadFromPath(s.path) } @@ -154,41 +224,87 @@ func (s *Service) LoadLanguage() string { if err := json.Unmarshal(data, &payload); err != nil { return i18n.DefaultLanguage() } - - normalized := i18n.NormalizeLanguage(payload.Language) - if normalized == "" { - return i18n.DefaultLanguage() - } - return normalized + return normalizeLanguage(payload.Language) } -func (s *Service) loadFromPath(path string) (AppConfig, error) { +func (s *Service) loadFromPath(path string) (State, error) { data, err := os.ReadFile(path) if err != nil { - return AppConfig{}, err + return State{}, err } - var cfg AppConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return AppConfig{}, errors.New(i18n.T("config.invalidJSON")) + if isLegacyConfig(data) { + return State{}, fmt.Errorf("%w: %s", ErrLegacyFormat, i18n.T("config.legacyFormat")) } - cfg = cfg.WithDefaults() - if err := cfg.Validate(); err != nil { - return AppConfig{}, err + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return State{}, errors.New(i18n.T("config.invalidJSON")) } - return cfg, nil + state = normalizeState(state) + if err := state.Validate(); err != nil { + return State{}, err + } + return state, nil } -func (s *Service) Save(cfg AppConfig) error { - cfg = cfg.WithDefaults() - if err := cfg.Validate(); err != nil { +func (s *Service) Save(state State) error { + state = normalizeState(state) + if err := state.Validate(); err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } - content, err := json.MarshalIndent(cfg, "", " ") + content, err := json.MarshalIndent(state, "", " ") if err != nil { return err } return os.WriteFile(s.path, content, 0o600) } + +func NormalizeProfileName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func ValidateProfileName(name string) error { + if NormalizeProfileName(name) == "" { + return errors.New(i18n.T("config.profileNameRequired")) + } + return nil +} + +func normalizeLanguage(language string) string { + normalized := i18n.NormalizeLanguage(language) + if normalized == "" { + return i18n.DefaultLanguage() + } + return normalized +} + +func normalizeState(state State) State { + normalized := State{ + Language: normalizeLanguage(state.Language), + CurrentProfile: NormalizeProfileName(state.CurrentProfile), + Profiles: make(map[string]AppConfig, len(state.Profiles)), + } + for name, cfg := range state.Profiles { + normalized.Profiles[NormalizeProfileName(name)] = cfg.WithDefaults() + } + return normalized +} + +func isLegacyConfig(data []byte) bool { + var payload struct { + APIBaseURL json.RawMessage `json:"apiBaseUrl"` + AccessKey json.RawMessage `json:"accessKey"` + SecretKey json.RawMessage `json:"secretKey"` + Profiles json.RawMessage `json:"profiles"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return false + } + if len(payload.Profiles) != 0 { + return false + } + return len(payload.APIBaseURL) != 0 || len(payload.AccessKey) != 0 || len(payload.SecretKey) != 0 +} diff --git a/internal/config/wizard.go b/internal/config/wizard.go index d5fba79..9c30685 100644 --- a/internal/config/wizard.go +++ b/internal/config/wizard.go @@ -2,55 +2,46 @@ package config import ( "errors" - "github.com/ClouGence/cloudcanal-openapi-cli/internal/console" - "github.com/ClouGence/cloudcanal-openapi-cli/internal/i18n" "io" "strings" + + "github.com/ClouGence/cloudcanal-openapi-cli/internal/console" + "github.com/ClouGence/cloudcanal-openapi-cli/internal/i18n" ) type Validator func(AppConfig) error type Wizard struct { - io console.IO - service *Service - validator Validator - initial AppConfig + io console.IO + validator Validator + initial AppConfig + profileName string } -func NewWizard(io console.IO, service *Service, validator Validator, initial AppConfig) *Wizard { +func NewWizard(io console.IO, validator Validator, profileName string, initial AppConfig) *Wizard { return &Wizard{ - io: io, - service: service, - validator: validator, - initial: initial, + io: io, + validator: validator, + initial: initial, + profileName: NormalizeProfileName(profileName), } } func (w *Wizard) Run() (*AppConfig, error) { current := w.initial.WithDefaults() - _ = i18n.SetLanguage(current.Language) - w.io.Println(i18n.T("wizard.title")) + profileName := w.profileName + if profileName == "" { + profileName = DefaultProfileName + } + + w.io.Println(i18n.T("wizard.title", profileName)) w.io.Println(i18n.T("wizard.cancelHint")) - w.io.Println(i18n.T("wizard.languageHint")) w.io.Println(i18n.T("wizard.apiHostHint")) if w.hasInitialValue(w.initial) { w.io.Println(i18n.T("wizard.keepCurrent")) } for { - language, cancelled, err := w.promptLanguage(current.Language) - if err != nil { - if errors.Is(err, io.EOF) { - return nil, nil - } - return nil, err - } - if cancelled { - return nil, nil - } - current.Language = language - _ = i18n.SetLanguage(language) - apiBaseURL, cancelled, err := w.promptRequired("apiHost", current.APIBaseURL, validateAPIBaseURL) if err != nil { if errors.Is(err, io.EOF) { @@ -88,7 +79,6 @@ func (w *Wizard) Run() (*AppConfig, error) { APIBaseURL: apiBaseURL, AccessKey: accessKey, SecretKey: secretKey, - Language: language, } if err := current.Validate(); err != nil { @@ -101,10 +91,6 @@ func (w *Wizard) Run() (*AppConfig, error) { w.io.Println(i18n.T("wizard.reuseValues")) continue } - if err := w.service.Save(current); err != nil { - return nil, err - } - w.io.Println(i18n.T("wizard.savedTo", w.service.Path())) return ¤t, nil } } @@ -169,26 +155,10 @@ func (w *Wizard) promptWithDefault(label, current string) (string, bool, error) return trimmed, false, nil } -func (w *Wizard) promptLanguage(current string) (string, bool, error) { - for { - value, cancelled, err := w.promptWithDefault("language", current) - if err != nil || cancelled { - return "", cancelled, err - } - normalized := i18n.NormalizeLanguage(value) - if normalized == "" { - w.io.Println(i18n.T("wizard.invalidField", "language", i18n.T("config.languageUnsupported"))) - continue - } - return normalized, false, nil - } -} - func (w *Wizard) hasInitialValue(cfg AppConfig) bool { return strings.TrimSpace(cfg.APIBaseURL) != "" || strings.TrimSpace(cfg.AccessKey) != "" || - strings.TrimSpace(cfg.SecretKey) != "" || - strings.TrimSpace(cfg.Language) != "" + strings.TrimSpace(cfg.SecretKey) != "" } func validateAPIBaseURL(value string) error { diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 4738b1e..19d955a 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -29,8 +29,10 @@ var messages = map[string]map[string]string{ "common.didYouMean": "Did you mean: %s", "common.supportedLanguages": "Supported languages: en, zh", "runtime.invalidConfig": "Existing configuration is invalid: %s", + "runtime.legacyConfig": "Existing configuration uses the legacy single-profile format. Re-run initialization to create the new profile-based config.", "runtime.initCancelled": "Initialization cancelled.", "runtime.configUpdated": "Configuration updated.", + "config.currentProfileLabel": "currentProfile", "config.apiBaseUrlLabel": "apiBaseUrl", "config.accessKeyLabel": "accessKey", "config.languageLabel": "language", @@ -38,6 +40,14 @@ var messages = map[string]map[string]string{ "config.accessKeyRequired": "accessKey is required", "config.secretKeyRequired": "secretKey is required", "config.languageUnsupported": "language must be en or zh", + "config.legacyFormat": "configuration file uses the legacy single-profile format", + "config.noProfilesConfigured": "at least one profile must be configured", + "config.currentProfileRequired": "currentProfile is required", + "config.currentProfileMissing": "currentProfile %s does not exist", + "config.profileNameRequired": "profile name is required", + "config.profileExists": "profile %s already exists", + "config.profileNotFound": "profile %s does not exist", + "config.profileRemoveActive": "cannot remove the active profile %s", "config.httpTimeoutLabel": "httpTimeoutSeconds", "config.httpReadMaxRetriesLabel": "httpReadMaxRetries", "config.httpReadRetryBackoffMillisLabel": "httpReadRetryBackoffMillis", @@ -48,7 +58,7 @@ var messages = map[string]map[string]string{ "config.apiBaseUrlScheme": "apiBaseUrl must start with http:// or https://", "config.apiBaseUrlHost": "apiBaseUrl must contain a host", "config.invalidJSON": "configuration file is not valid JSON", - "wizard.title": "CloudCanal CLI initialization", + "wizard.title": "CloudCanal CLI profile initialization (%s)", "wizard.cancelHint": "Type exit at any prompt to cancel.", "wizard.apiHostHint": "apiHost must be a full URL, for example: https://cc.example.com", "wizard.languageHint": "Language supports en or zh. Press Enter to use the default language.", @@ -101,8 +111,10 @@ var messages = map[string]map[string]string{ "common.didYouMean": "你是不是想输入:%s", "common.supportedLanguages": "支持的语言:en、zh", "runtime.invalidConfig": "现有配置无效:%s", + "runtime.legacyConfig": "现有配置仍是旧的单环境格式,请重新初始化并生成新的 profile 配置。", "runtime.initCancelled": "已取消初始化。", "runtime.configUpdated": "配置已更新。", + "config.currentProfileLabel": "currentProfile", "config.apiBaseUrlLabel": "apiBaseUrl", "config.accessKeyLabel": "accessKey", "config.languageLabel": "language", @@ -110,6 +122,14 @@ var messages = map[string]map[string]string{ "config.accessKeyRequired": "accessKey 不能为空", "config.secretKeyRequired": "secretKey 不能为空", "config.languageUnsupported": "language 只支持 en 或 zh", + "config.legacyFormat": "配置文件仍是旧的单环境格式", + "config.noProfilesConfigured": "至少要配置一个 profile", + "config.currentProfileRequired": "currentProfile 不能为空", + "config.currentProfileMissing": "currentProfile %s 不存在", + "config.profileNameRequired": "profile 名称不能为空", + "config.profileExists": "profile %s 已存在", + "config.profileNotFound": "profile %s 不存在", + "config.profileRemoveActive": "不能删除当前正在使用的 profile %s", "config.httpTimeoutLabel": "httpTimeoutSeconds", "config.httpReadMaxRetriesLabel": "httpReadMaxRetries", "config.httpReadRetryBackoffMillisLabel": "httpReadRetryBackoffMillis", @@ -120,7 +140,7 @@ var messages = map[string]map[string]string{ "config.apiBaseUrlScheme": "apiBaseUrl 必须以 http:// 或 https:// 开头", "config.apiBaseUrlHost": "apiBaseUrl 必须包含主机名", "config.invalidJSON": "配置文件不是合法的 JSON", - "wizard.title": "CloudCanal CLI 初始化", + "wizard.title": "CloudCanal CLI profile 初始化(%s)", "wizard.cancelHint": "任意提示下输入 exit 可取消。", "wizard.apiHostHint": "apiHost 必须是完整 URL,例如:https://cc.example.com", "wizard.languageHint": "language 支持 en 或 zh,直接回车使用默认语言。", diff --git a/internal/repl/completion.go b/internal/repl/completion.go index 80083e2..6ef22fb 100644 --- a/internal/repl/completion.go +++ b/internal/repl/completion.go @@ -102,7 +102,7 @@ func completeContext(context []string, prefix string, replMode bool) []string { candidates = append(candidates, visibleReplOnlyCommands...) } if prefix == "" || strings.HasPrefix(prefix, "--") { - candidates = append(candidates, "--help", "--output") + candidates = append(candidates, "--help", "--output", "--version") } return matchCandidates(candidates, prefix) } diff --git a/internal/repl/help.go b/internal/repl/help.go index 9b231fd..017899b 100644 --- a/internal/repl/help.go +++ b/internal/repl/help.go @@ -47,6 +47,7 @@ CloudCanal CLI 帮助 help job-config 查看数据任务规格命令说明 help schemas 查看 Schema 查询命令说明 help config 查看配置命令说明 + help version 查看版本命令说明 常用命令: jobs list 列出数据任务 @@ -58,8 +59,11 @@ CloudCanal CLI 帮助 consolejobs show 查看 ConsoleJob 详情 job-config specs 查看任务规格 schemas list-trans-objs-by-meta 查看映射对象 + version 查看当前版本信息 config show 查看当前配置 config init 重新执行初始化向导 + config profiles list 查看所有 profile + config profiles use dev 切换当前 profile config lang show 查看当前语言 config lang set zh 切换为中文日志 config lang set en 切换为英文日志 @@ -86,6 +90,7 @@ Help topics: help job-config Show data job spec commands help schemas Show schema lookup commands help config Show configuration commands + help version Show version command details Common commands: jobs list List data jobs @@ -97,8 +102,11 @@ Common commands: consolejobs show Show console job details job-config specs List data job specs schemas list-trans-objs-by-meta List transfer objects by metadata + version Show build version information config show Show current config config init Re-run the initialization wizard + config profiles list List configured profiles + config profiles use dev Switch the active profile config lang show Show current language config lang set zh Switch CLI messages to Chinese config lang set en Switch CLI messages to English @@ -434,10 +442,22 @@ func (s *Shell) helpConfig() string { config 命令 config show - 查看当前配置,包括 apiBaseUrl、accessKey 掩码和当前 language。 + 查看当前配置,包括 currentProfile、apiBaseUrl、accessKey 掩码和当前 language。 config init - 重新进入初始化向导,更新 API 地址、密钥和 language。 + 重新进入当前 profile 的初始化向导,更新 API 地址和密钥。 + +config profiles list + 查看所有 profile,并标记当前正在使用的环境。 + +config profiles use + 切换当前 profile。 + +config profiles add + 新增 profile,并立即进入初始化向导。 + +config profiles remove + 删除非当前 profile。 config lang show 查看当前 CLI 文案语言。 @@ -451,10 +471,22 @@ config lang set config commands config show - Show current config, including apiBaseUrl, masked accessKey, and current language. + Show current config, including currentProfile, apiBaseUrl, masked accessKey, and current language. config init - Re-run the initialization wizard to update API URL, credentials, and language. + Re-run the initialization wizard for the active profile to update API URL and credentials. + +config profiles list + List all profiles and mark the active one. + +config profiles use + Switch the active profile. + +config profiles add + Add a profile and open the initialization wizard immediately. + +config profiles remove + Remove a non-active profile. config lang show Show the current CLI message language. @@ -464,6 +496,68 @@ config lang set `) } +func (s *Shell) helpProfiles() string { + if s.isChinese() { + return strings.TrimSpace(` +config profiles 命令 + +config profiles list + 查看所有 profile,并标记当前正在使用的环境。 + +config profiles use + 切换当前 profile。 + +config profiles add + 新增 profile,并立即进入初始化向导。 + +config profiles remove + 删除非当前 profile。 +`) + } + + return strings.TrimSpace(` +config profiles commands + +config profiles list + List all profiles and mark the active one. + +config profiles use + Switch the active profile. + +config profiles add + Add a profile and open the initialization wizard immediately. + +config profiles remove + Remove a non-active profile. +`) +} + +func (s *Shell) helpVersion() string { + if s.isChinese() { + return strings.TrimSpace(` +version 命令 + +version + 显示当前 CLI 的 version、commit 和 buildTime。 + +说明: + 也支持 cloudcanal --version。 + 两种方式都支持追加 --output json。 +`) + } + + return strings.TrimSpace(` +version command + +version + Show the current CLI version, commit, and buildTime. + +Notes: + cloudcanal --version is also supported. + Both forms support --output json. +`) +} + func (s *Shell) helpLanguage() string { if s.isChinese() { return fmt.Sprintf(strings.TrimSpace(` diff --git a/internal/repl/registry.go b/internal/repl/registry.go index e5f10d9..3c35c43 100644 --- a/internal/repl/registry.go +++ b/internal/repl/registry.go @@ -122,9 +122,17 @@ var ( children: []*commandSpec{ {name: "show", visible: true, usage: (*Shell).usageConfigShow}, {name: "init", visible: true, usage: (*Shell).usageConfigInit}, + newProfilesCommand(), newLanguageCommand("lang", true), }, }, + { + name: "version", + visible: true, + visibleInHelp: true, + help: (*Shell).helpVersion, + usage: (*Shell).usageVersion, + }, newLanguageCommand("lang", false, "language"), { name: "completion", @@ -165,6 +173,21 @@ func newLanguageCommand(name string, visible bool, aliases ...string) *commandSp } } +func newProfilesCommand() *commandSpec { + return &commandSpec{ + name: "profiles", + visible: true, + help: (*Shell).helpProfiles, + usage: (*Shell).usageConfigProfiles, + children: []*commandSpec{ + {name: "list", visible: true, usage: (*Shell).usageConfigProfiles}, + {name: "use", visible: true, usage: (*Shell).usageConfigProfiles}, + {name: "add", visible: true, usage: (*Shell).usageConfigProfiles}, + {name: "remove", visible: true, usage: (*Shell).usageConfigProfiles}, + }, + } +} + func init() { mustSetCommandRun("jobs", (*Shell).handleJobs) mustSetCommandRun("datasources", (*Shell).handleDataSources) @@ -175,6 +198,7 @@ func init() { mustSetCommandRun("schemas", (*Shell).handleSchemas) mustSetCommandRun("config", (*Shell).handleConfig) mustSetCommandRun("lang", (*Shell).handleLang) + mustSetCommandRun("version", (*Shell).handleVersion) mustSetCommandRun("completion", (*Shell).handleCompletion) mustSetCommandRun("clear", runClearScreen) mustSetCommandRun("__complete", runHiddenCompletion) @@ -214,12 +238,18 @@ func init() { mustSetCommandRunPath([]string{"config", "show"}, (*Shell).runConfigShow) mustSetCommandRunPath([]string{"config", "init"}, (*Shell).runConfigInit) + mustSetCommandRunPath([]string{"config", "profiles", "list"}, (*Shell).runProfilesList) + mustSetCommandRunPath([]string{"config", "profiles", "use"}, (*Shell).runProfilesUse) + mustSetCommandRunPath([]string{"config", "profiles", "add"}, (*Shell).runProfilesAdd) + mustSetCommandRunPath([]string{"config", "profiles", "remove"}, (*Shell).runProfilesRemove) mustSetCommandRunPath([]string{"config", "lang", "show"}, (*Shell).runLanguageShow) mustSetCommandRunPath([]string{"config", "lang", "set"}, (*Shell).runLanguageSet) mustSetCommandRunPath([]string{"lang", "show"}, (*Shell).runLanguageShow) mustSetCommandRunPath([]string{"lang", "set"}, (*Shell).runLanguageSet) + mustSetCommandRunPath([]string{"version"}, (*Shell).runVersion) + mustSetCommandRunPath([]string{"completion", "zsh"}, (*Shell).runCompletionZsh) mustSetCommandRunPath([]string{"completion", "bash"}, (*Shell).runCompletionBash) } diff --git a/internal/repl/shell.go b/internal/repl/shell.go index 23fdde6..56fa975 100644 --- a/internal/repl/shell.go +++ b/internal/repl/shell.go @@ -2,6 +2,8 @@ package repl import ( "github.com/ClouGence/cloudcanal-openapi-cli/internal/app" + "github.com/ClouGence/cloudcanal-openapi-cli/internal/buildinfo" + "github.com/ClouGence/cloudcanal-openapi-cli/internal/config" "github.com/ClouGence/cloudcanal-openapi-cli/internal/console" "github.com/ClouGence/cloudcanal-openapi-cli/internal/i18n" "github.com/ClouGence/cloudcanal-openapi-cli/internal/util" @@ -10,8 +12,6 @@ import ( "strings" ) -const prompt = "cloudcanal> " - type Shell struct { io console.IO runtime app.RuntimeContext @@ -19,7 +19,7 @@ type Shell struct { } func NewShell(io console.IO, runtime app.RuntimeContext) *Shell { - _ = i18n.SetLanguage(runtime.Config().NormalizedLanguage()) + _ = i18n.SetLanguage(runtime.Language()) shell := &Shell{io: io, runtime: runtime, outputFormat: outputText} if completable, ok := io.(console.TabCompletable); ok { completable.SetCompleter(shell.completeLine) @@ -37,7 +37,7 @@ func (s *Shell) ExecuteArgs(args []string) error { func (s *Shell) Run() error { s.io.Println(i18n.T("common.typeHelp")) for { - line, err := s.io.ReadLine(prompt) + line, err := s.io.ReadLine(s.prompt()) if err != nil { if err == io.EOF { s.io.Println("") @@ -108,6 +108,10 @@ func (s *Shell) handleLang(tokens []string) error { return s.dispatchRegisteredCommand(tokens) } +func (s *Shell) handleVersion(tokens []string) error { + return s.dispatchRegisteredCommand(tokens) +} + func (s *Shell) runConfigShow(tokens []string) error { if len(tokens) != 2 { s.io.Println(s.usageConfigShow()) @@ -117,17 +121,19 @@ func (s *Shell) runConfigShow(tokens []string) error { cfg := s.runtime.Config() if s.isJSONOutput() { return s.printJSON(map[string]any{ + "currentProfile": s.runtime.CurrentProfile(), "apiBaseUrl": cfg.APIBaseURL, "accessKeyMasked": util.MaskSecret(cfg.AccessKey), - "language": cfg.NormalizedLanguage(), + "language": s.runtime.Language(), "httpTimeoutSeconds": cfg.HTTPTimeoutSecondsValue(), "httpReadMaxRetries": cfg.HTTPReadMaxRetriesValue(), "httpReadRetryBackoffMillis": cfg.HTTPReadRetryBackoffMillisValue(), }) } + s.io.Println(i18n.T("config.currentProfileLabel") + ": " + s.runtime.CurrentProfile()) s.io.Println(i18n.T("config.apiBaseUrlLabel") + ": " + cfg.APIBaseURL) s.io.Println(i18n.T("config.accessKeyLabel") + ": " + util.MaskSecret(cfg.AccessKey)) - s.io.Println(i18n.T("config.languageLabel") + ": " + cfg.NormalizedLanguage()) + s.io.Println(i18n.T("config.languageLabel") + ": " + s.runtime.Language()) s.io.Println(i18n.T("config.httpTimeoutLabel") + ": " + strconv.Itoa(cfg.HTTPTimeoutSecondsValue())) s.io.Println(i18n.T("config.httpReadMaxRetriesLabel") + ": " + strconv.Itoa(cfg.HTTPReadMaxRetriesValue())) s.io.Println(i18n.T("config.httpReadRetryBackoffMillisLabel") + ": " + strconv.Itoa(cfg.HTTPReadRetryBackoffMillisValue())) @@ -157,11 +163,11 @@ func (s *Shell) runLanguageShow(tokens []string) error { } if s.isJSONOutput() { return s.printJSON(map[string]any{ - "language": s.runtime.Config().NormalizedLanguage(), + "language": s.runtime.Language(), "supported": []string{"en", "zh"}, }) } - s.io.Println(i18n.T("lang.current", s.runtime.Config().NormalizedLanguage())) + s.io.Println(i18n.T("lang.current", s.runtime.Language())) s.io.Println(i18n.T("common.supportedLanguages")) return nil } @@ -177,11 +183,117 @@ func (s *Shell) runLanguageSet(tokens []string) error { } if s.isJSONOutput() { return s.printJSON(map[string]any{ - "language": s.runtime.Config().NormalizedLanguage(), - "message": i18n.T("lang.updated", i18n.DisplayName(s.runtime.Config().NormalizedLanguage())), + "language": s.runtime.Language(), + "message": i18n.T("lang.updated", i18n.DisplayName(s.runtime.Language())), + }) + } + s.io.Println(i18n.T("lang.updated", i18n.DisplayName(s.runtime.Language()))) + return nil +} + +func (s *Shell) runProfilesList(tokens []string) error { + if len(tokens) != 3 { + s.io.Println(s.usageConfigProfiles()) + return nil + } + + summaries := s.runtime.ProfileSummaries() + if s.isJSONOutput() { + return s.printJSON(map[string]any{ + "currentProfile": s.runtime.CurrentProfile(), + "profiles": summaries, + }) + } + + rows := make([][]string, 0, len(summaries)) + for _, summary := range summaries { + current := "" + if summary.Current { + current = "*" + } + rows = append(rows, []string{current, summary.Name, summary.APIBaseURL}) + } + s.io.Println(util.FormatTable(s.profileListHeaders(), rows)) + return nil +} + +func (s *Shell) runProfilesUse(tokens []string) error { + if len(tokens) != 4 { + s.io.Println(s.usageConfigProfiles()) + return nil + } + + name := config.NormalizeProfileName(tokens[3]) + if err := s.runtime.UseProfile(name); err != nil { + return err + } + if s.isJSONOutput() { + return s.printJSON(map[string]any{ + "profile": name, + "message": s.profileUsedMessage(name), }) } - s.io.Println(i18n.T("lang.updated", i18n.DisplayName(s.runtime.Config().NormalizedLanguage()))) + s.io.Println(s.profileUsedMessage(name)) + return nil +} + +func (s *Shell) runProfilesAdd(tokens []string) error { + if len(tokens) != 4 { + s.io.Println(s.usageConfigProfiles()) + return nil + } + + name := config.NormalizeProfileName(tokens[3]) + added, err := s.runtime.AddProfile(name, s.io) + if err != nil { + return err + } + if !added { + return nil + } + if s.isJSONOutput() { + return s.printJSON(map[string]any{ + "profile": name, + "message": s.profileAddedMessage(name), + }) + } + s.io.Println(s.profileAddedMessage(name)) + return nil +} + +func (s *Shell) runProfilesRemove(tokens []string) error { + if len(tokens) != 4 { + s.io.Println(s.usageConfigProfiles()) + return nil + } + + name := config.NormalizeProfileName(tokens[3]) + if err := s.runtime.RemoveProfile(name); err != nil { + return err + } + if s.isJSONOutput() { + return s.printJSON(map[string]any{ + "profile": name, + "message": s.profileRemovedMessage(name), + }) + } + s.io.Println(s.profileRemovedMessage(name)) + return nil +} + +func (s *Shell) runVersion(tokens []string) error { + if len(tokens) != 1 { + s.io.Println(s.usageVersion()) + return nil + } + + info := buildinfo.Current() + if s.isJSONOutput() { + return s.printJSON(info) + } + s.io.Println("version: " + info.Version) + s.io.Println("commit: " + info.Commit) + s.io.Println("buildTime: " + info.BuildTime) return nil } @@ -191,3 +303,11 @@ func languageValueIndex(tokens []string) int { } return 2 } + +func (s *Shell) prompt() string { + currentProfile := s.runtime.CurrentProfile() + if strings.TrimSpace(currentProfile) == "" { + return "cloudcanal> " + } + return "cloudcanal[" + currentProfile + "]> " +} diff --git a/internal/repl/text.go b/internal/repl/text.go index 895e823..6727321 100644 --- a/internal/repl/text.go +++ b/internal/repl/text.go @@ -25,9 +25,16 @@ func usageBlock(title string, commands ...string) string { func (s *Shell) usageConfig() string { if s.isChinese() { - return usageBlock("用法:", "config show", "config init", "config lang show", "config lang set ") + return usageBlock("用法:", "config show", "config init", "config profiles list", "config profiles use ", "config profiles add ", "config profiles remove ", "config lang show", "config lang set ") } - return usageBlock("Usage:", "config show", "config init", "config lang show", "config lang set ") + return usageBlock("Usage:", "config show", "config init", "config profiles list", "config profiles use ", "config profiles add ", "config profiles remove ", "config lang show", "config lang set ") +} + +func (s *Shell) usageVersion() string { + if s.isChinese() { + return "用法:version" + } + return "Usage: version" } func (s *Shell) usageJobsGroup() string { @@ -281,6 +288,13 @@ func (s *Shell) usageConfigLang() string { return usageBlock("Usage:", "config lang show", "config lang set ") } +func (s *Shell) usageConfigProfiles() string { + if s.isChinese() { + return usageBlock("用法:", "config profiles list", "config profiles use ", "config profiles add ", "config profiles remove ") + } + return usageBlock("Usage:", "config profiles list", "config profiles use ", "config profiles add ", "config profiles remove ") +} + func (s *Shell) usageCompletion() string { if s.isChinese() { return "用法:completion [commandName]" @@ -288,6 +302,34 @@ func (s *Shell) usageCompletion() string { return "Usage: completion [commandName]" } +func (s *Shell) profileListHeaders() []string { + if s.isChinese() { + return []string{"当前", "profile", "apiBaseUrl"} + } + return []string{"CURRENT", "PROFILE", "API BASE URL"} +} + +func (s *Shell) profileUsedMessage(name string) string { + if s.isChinese() { + return fmt.Sprintf("当前 profile 已切换为 %s。", name) + } + return fmt.Sprintf("Current profile switched to %s.", name) +} + +func (s *Shell) profileAddedMessage(name string) string { + if s.isChinese() { + return fmt.Sprintf("Profile %s 已添加。", name) + } + return fmt.Sprintf("Profile %s added.", name) +} + +func (s *Shell) profileRemovedMessage(name string) string { + if s.isChinese() { + return fmt.Sprintf("Profile %s 已移除。", name) + } + return fmt.Sprintf("Profile %s removed.", name) +} + func (s *Shell) actionMessage(kind string, id int64) string { if s.isChinese() { switch kind { diff --git a/internal/repl/ux.go b/internal/repl/ux.go index c0e9b0f..d563e59 100644 --- a/internal/repl/ux.go +++ b/internal/repl/ux.go @@ -126,20 +126,15 @@ func RenderCommandHelp(tokens []string) (string, bool) { return shell.renderHelp(nil), true } - if len(tokens) >= 2 && isHelpToken(tokens[1]) { - if spec := findRootCommand(tokens[0]); canRenderHelp(spec) { - return commandHelpText(shell, spec), true - } - return "", false - } - - if len(tokens) < 3 || !isHelpToken(tokens[2]) { + if !isHelpToken(tokens[len(tokens)-1]) { return "", false } - parent := findRootCommand(tokens[0]) - if parent == nil { + path := tokens[:len(tokens)-1] + spec, consumed := findCommandPath(path) + if spec == nil || consumed != len(path) { return "", false } - return commandUsageOrHelpText(shell, findChildCommand(parent, tokens[1]), parent), true + parent := findCommandParent(path, consumed) + return commandUsageOrHelpText(shell, spec, parent), true } diff --git a/scripts/build_release_assets.sh b/scripts/build_release_assets.sh new file mode 100755 index 0000000..f639d44 --- /dev/null +++ b/scripts/build_release_assets.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$SCRIPT_DIR/lib/log.sh" + +DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" +VERSION="${VERSION:-}" +COMMIT="${COMMIT:-$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || printf 'unknown\n')}" +BUILD_TIME="${BUILD_TIME:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")}" + +if [[ -z "$VERSION" ]]; then + if [[ "${GITHUB_REF_NAME:-}" == v* ]]; then + VERSION="${GITHUB_REF_NAME#v}" + else + VERSION="dev" + fi +fi + +checksum_file() { + local file_path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file_path" + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file_path" + return 0 + fi + + log_error "A SHA-256 tool is required (sha256sum or shasum)" + exit 1 +} + +generate_release_notes() { + local output="$DIST_DIR/release-notes.md" + + awk -v version="$VERSION" ' + $0 ~ "^## \\[" version "\\]" { in_section = 1 } + /^## \[/ && in_section && $0 !~ "^## \\[" version "\\]" { exit } + in_section { print } + ' "$ROOT_DIR/CHANGELOG.md" > "$output" + + if [[ -s "$output" ]]; then + return 0 + fi + + if [[ "$VERSION" == "dev" ]]; then + cat > "$output" < checksums.txt + for asset in cloudcanal_*.tar.gz install.sh uninstall.sh release-notes.md; do + checksum_file "$asset" >> checksums.txt + done +) + +log_success "Release assets ready in $DIST_DIR" +print_run_summary "Release asset build completed" diff --git a/scripts/install.sh b/scripts/install.sh index 6fa8ff9..5836a45 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -287,6 +287,11 @@ ensure_completion_block() { log_success "Updated $INSTALL_SHELL_RC" } +show_installed_version() { + log_info "Installed build metadata:" + "$INSTALL_BIN_PATH" version | sed 's/^/ /' +} + trap cleanup EXIT log_info "CloudCanal OpenAPI CLI release install started" @@ -297,6 +302,7 @@ install_binary ensure_completion_files ensure_path_block ensure_completion_block +show_installed_version log_info "Open a new shell or source $INSTALL_SHELL_RC, then run: $APP_NAME jobs list" log_success "Release install completed" diff --git a/test/config/config_test.go b/test/config/config_test.go index f691607..7a83262 100644 --- a/test/config/config_test.go +++ b/test/config/config_test.go @@ -1,6 +1,7 @@ package config_test import ( + "errors" "os" "path/filepath" "testing" @@ -8,16 +9,27 @@ import ( "github.com/ClouGence/cloudcanal-openapi-cli/internal/config" ) -func TestServiceSaveAndLoad(t *testing.T) { +func TestServiceSaveAndLoadState(t *testing.T) { dir := t.TempDir() service := config.NewService(filepath.Join(dir, "config.json")) - cfg := config.AppConfig{ - APIBaseURL: "https://cc.example.com", - AccessKey: "access-key", - SecretKey: "secret-key", - } - if err := service.Save(cfg); err != nil { + state := config.State{ + Language: "zh", + CurrentProfile: "prod", + Profiles: map[string]config.AppConfig{ + "dev": { + APIBaseURL: "https://dev.example.com", + AccessKey: "dev-ak", + SecretKey: "dev-sk", + }, + "prod": { + APIBaseURL: "https://cc.example.com", + AccessKey: "access-key", + SecretKey: "secret-key", + }, + }, + } + if err := service.Save(state); err != nil { t.Fatalf("Save() error = %v", err) } @@ -25,8 +37,14 @@ func TestServiceSaveAndLoad(t *testing.T) { if err != nil { t.Fatalf("Load() error = %v", err) } - if loaded.APIBaseURL != cfg.APIBaseURL || loaded.AccessKey != cfg.AccessKey || loaded.SecretKey != cfg.SecretKey || loaded.Language != "en" { - t.Fatalf("loaded config = %+v, want %+v", loaded, cfg) + if loaded.Language != "zh" { + t.Fatalf("Language = %q, want zh", loaded.Language) + } + if loaded.CurrentProfile != "prod" { + t.Fatalf("CurrentProfile = %q, want prod", loaded.CurrentProfile) + } + if got := loaded.Profiles["prod"].APIBaseURL; got != "https://cc.example.com" { + t.Fatalf("prod apiBaseUrl = %q, want https://cc.example.com", got) } } @@ -43,10 +61,10 @@ func TestServiceRejectsInvalidJSON(t *testing.T) { } } -func TestServiceRejectsMissingField(t *testing.T) { +func TestServiceRejectsMissingCurrentProfile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") - if err := os.WriteFile(path, []byte(`{"apiBaseUrl":"https://cc.example.com","accessKey":"ak"}`), 0o600); err != nil { + if err := os.WriteFile(path, []byte(`{"language":"en","profiles":{"dev":{"apiBaseUrl":"https://cc.example.com","accessKey":"ak","secretKey":"sk"}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) } @@ -56,16 +74,44 @@ func TestServiceRejectsMissingField(t *testing.T) { } } -func TestServiceLoadLanguageFromPartialConfig(t *testing.T) { +func TestServiceLoadLanguageFromNewAndLegacyConfig(t *testing.T) { + testCases := []struct { + name string + content string + want string + }{ + {name: "new schema", content: `{"language":"zh","currentProfile":"dev","profiles":{"dev":{"apiBaseUrl":"https://cc.example.com","accessKey":"ak","secretKey":"sk"}}}`, want: "zh"}, + {name: "legacy schema", content: `{"language":"zh","apiBaseUrl":"https://cc.example.com","accessKey":"ak","secretKey":"sk"}`, want: "zh"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(tc.content), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + service := config.NewService(path) + if got := service.LoadLanguage(); got != tc.want { + t.Fatalf("LoadLanguage() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestServiceDetectsLegacyFormat(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") - if err := os.WriteFile(path, []byte(`{"language":"zh"}`), 0o600); err != nil { + content := `{"language":"en","apiBaseUrl":"https://cc.example.com","accessKey":"ak","secretKey":"sk"}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) } service := config.NewService(path) - if got := service.LoadLanguage(); got != "zh" { - t.Fatalf("LoadLanguage() = %q, want zh", got) + _, err := service.Load() + if !errors.Is(err, config.ErrLegacyFormat) { + t.Fatalf("Load() error = %v, want legacy format error", err) } } diff --git a/test/config/wizard_test.go b/test/config/wizard_test.go index 7bddd7f..8267267 100644 --- a/test/config/wizard_test.go +++ b/test/config/wizard_test.go @@ -2,8 +2,6 @@ package config_test import ( "errors" - "os" - "path/filepath" "strings" "testing" @@ -11,15 +9,12 @@ import ( "github.com/ClouGence/cloudcanal-openapi-cli/test/testsupport" ) -func TestWizardSavesConfigAfterSuccessfulValidation(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - service := config.NewService(path) - io := testsupport.NewTestConsole("", "https://cc.example.com", "test-ak", "test-sk") +func TestWizardReturnsConfigAfterSuccessfulValidation(t *testing.T) { + io := testsupport.NewTestConsole("https://cc.example.com", "test-ak", "test-sk") - wizard := config.NewWizard(io, service, func(cfg config.AppConfig) error { + wizard := config.NewWizard(io, func(cfg config.AppConfig) error { return nil - }, config.AppConfig{}) + }, "dev", config.AppConfig{}) cfg, err := wizard.Run() if err != nil { @@ -28,23 +23,17 @@ func TestWizardSavesConfigAfterSuccessfulValidation(t *testing.T) { if cfg == nil { t.Fatal("Run() returned nil config") } - if cfg.Language != "en" { - t.Fatalf("Language = %q, want en", cfg.Language) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("saved config missing: %v", err) + if cfg.APIBaseURL != "https://cc.example.com" { + t.Fatalf("APIBaseURL = %q, want https://cc.example.com", cfg.APIBaseURL) } } func TestWizardDoesNotPersistOnValidationFailureThenExit(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - service := config.NewService(path) - io := testsupport.NewTestConsole("", "https://cc.example.com", "test-ak", "test-sk", "exit") + io := testsupport.NewTestConsole("https://cc.example.com", "test-ak", "test-sk", "exit") - wizard := config.NewWizard(io, service, func(cfg config.AppConfig) error { + wizard := config.NewWizard(io, func(cfg config.AppConfig) error { return errors.New("authentication failed") - }, config.AppConfig{}) + }, "prod", config.AppConfig{}) cfg, err := wizard.Run() if err != nil { @@ -53,23 +42,17 @@ func TestWizardDoesNotPersistOnValidationFailureThenExit(t *testing.T) { if cfg != nil { t.Fatalf("Run() config = %+v, want nil", *cfg) } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("config file exists unexpectedly, err = %v", err) - } - if out := io.Output(); out == "" || !strings.Contains(out, "Configuration validation failed") || !strings.Contains(out, "language [en]: ") { - t.Fatalf("wizard output missing validation failure: %q", out) + if out := io.Output(); out == "" || !strings.Contains(out, "Configuration validation failed") || !strings.Contains(out, "apiHost [https://cc.example.com]: ") { + t.Fatalf("wizard output missing validation failure reuse prompts: %q", out) } } func TestWizardReusesCurrentValuesAndDoesNotPrintSecret(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - service := config.NewService(path) - io := testsupport.NewTestConsole("", "", "", "") + io := testsupport.NewTestConsole("", "", "") - wizard := config.NewWizard(io, service, func(cfg config.AppConfig) error { + wizard := config.NewWizard(io, func(cfg config.AppConfig) error { return nil - }, config.AppConfig{ + }, "prod", config.AppConfig{ APIBaseURL: "https://cc.example.com", AccessKey: "current-ak", SecretKey: "current-sk", @@ -88,8 +71,8 @@ func TestWizardReusesCurrentValuesAndDoesNotPrintSecret(t *testing.T) { out := io.Output() for _, want := range []string{ + "CloudCanal CLI profile initialization (prod)", "Press Enter to keep the current value.", - "language [en]: ", "apiHost [https://cc.example.com]: ", "ak [current-ak]: ", "sk [hidden]: ", diff --git a/test/repl/alignment_test.go b/test/repl/alignment_test.go index 83981f5..d0da471 100644 --- a/test/repl/alignment_test.go +++ b/test/repl/alignment_test.go @@ -77,8 +77,8 @@ func newAlignmentRuntime() *fakeRuntime { APIBaseURL: "https://cc.example.com", AccessKey: "abcdefghijkl", SecretKey: "qrstuvwxyz1234", - Language: "zh", }, + language: "zh", dataJobs: &fakeDataJobs{ jobs: []datajob.Job{ { diff --git a/test/repl/completion_test.go b/test/repl/completion_test.go index c497081..a512501 100644 --- a/test/repl/completion_test.go +++ b/test/repl/completion_test.go @@ -33,11 +33,14 @@ func TestCompletionCandidatesSuggestCommandsFlagsAndValues(t *testing.T) { args []string want []string }{ - {name: "top level", args: []string{""}, want: []string{"help", "jobs", "config", "schemas"}}, + {name: "top level", args: []string{""}, want: []string{"help", "jobs", "config", "schemas", "version"}}, {name: "top level help flag", args: []string{"--h"}, want: []string{"--help"}}, + {name: "top level version flag", args: []string{"--v"}, want: []string{"--version"}}, {name: "top level global flag", args: []string{"--o"}, want: []string{"--output"}}, {name: "jobs help flag", args: []string{"jobs", "--h"}, want: []string{"--help"}}, {name: "config subcommand", args: []string{"config", "la"}, want: []string{"lang"}}, + {name: "config profile group", args: []string{"config", "pr"}, want: []string{"profiles"}}, + {name: "config profile subcommand", args: []string{"config", "profiles", ""}, want: []string{"add", "list", "remove", "use"}}, {name: "config lang value", args: []string{"config", "lang", "set", ""}, want: []string{"en", "zh"}}, {name: "jobs subcommand", args: []string{"jobs", "re"}, want: []string{"replay"}}, {name: "job-config alias path", args: []string{"jobconfig", "sp"}, want: []string{"specs"}}, diff --git a/test/repl/shell_test.go b/test/repl/shell_test.go index 6020a6b..533680f 100644 --- a/test/repl/shell_test.go +++ b/test/repl/shell_test.go @@ -246,6 +246,7 @@ func TestShellHandlesHappyPathCommands(t *testing.T) { "Current language: en", "语言已切换为 中文。", "Language switched to English.", + "currentProfile: dev", "apiBaseUrl: https://cc.example.com", "accessKey: abcd****ijkl", "language: en", @@ -468,7 +469,7 @@ func TestShellIgnoresPromptAbortInInteractiveMode(t *testing.T) { if strings.Contains(out, "prompt aborted") || strings.Contains(out, "Fatal error") { t.Fatalf("output should not contain prompt abort error in %q", out) } - if got := strings.Count(out, "cloudcanal> "); got != 1 { + if got := strings.Count(out, "cloudcanal[dev]> "); got != 1 { t.Fatalf("prompt count = %d, want 1 in %q", got, out) } } @@ -495,7 +496,9 @@ func TestShellHelpOverviewHidesInternalCommands(t *testing.T) { "jobs list", "datasources list", "config init", + "config profiles list", "config lang show", + "version Show build version information", "TAB Complete commands and options", "Ctrl+C Exit interactive mode", "exit Leave interactive mode", @@ -564,6 +567,15 @@ func TestShellSupportsHelpFlags(t *testing.T) { if !strings.Contains(io.Output(), "config lang commands") { t.Fatalf("output missing config lang help in %q", io.Output()) } + + io = testsupport.NewTestConsole() + shell = repl.NewShell(io, runtime) + if err := shell.ExecuteArgs([]string{"config", "profiles", "--help"}); err != nil { + t.Fatalf("ExecuteArgs(config profiles --help) error = %v", err) + } + if !strings.Contains(io.Output(), "config profiles commands") { + t.Fatalf("output missing config profiles help in %q", io.Output()) + } } func TestShellSuggestsClosestCommands(t *testing.T) { @@ -624,7 +636,8 @@ func TestShellSupportsAliasDispatch(t *testing.T) { jobConfigs := &fakeJobConfigs{} schemas := &fakeSchemas{} runtime := &fakeRuntime{ - cfg: config.AppConfig{APIBaseURL: "https://cc.example.com", AccessKey: "abcdefghijkl", SecretKey: "qrstuvwxyz1234", Language: "en"}, + cfg: config.AppConfig{APIBaseURL: "https://cc.example.com", AccessKey: "abcdefghijkl", SecretKey: "qrstuvwxyz1234"}, + language: "en", dataJobs: &fakeDataJobs{}, dataSources: &fakeDataSources{}, clusters: &fakeClusters{}, @@ -653,8 +666,8 @@ func TestShellSupportsAliasDispatch(t *testing.T) { if err := shell.ExecuteArgs([]string{"language", "set", "zh"}); err != nil { t.Fatalf("ExecuteArgs(language set zh) error = %v", err) } - if runtime.cfg.Language != "zh" { - t.Fatalf("language alias did not update runtime language: %q", runtime.cfg.Language) + if runtime.language != "zh" { + t.Fatalf("language alias did not update runtime language: %q", runtime.language) } } @@ -742,8 +755,107 @@ func TestShellOutputsJSONForCommandsAndErrors(t *testing.T) { } } +func TestShellSupportsVersionCommand(t *testing.T) { + runtime := &fakeRuntime{ + cfg: config.AppConfig{APIBaseURL: "https://cc.example.com", AccessKey: "abcdefghijkl", SecretKey: "qrstuvwxyz1234"}, + dataJobs: &fakeDataJobs{}, + dataSources: &fakeDataSources{}, + clusters: &fakeClusters{}, + workers: &fakeWorkers{}, + consoleJobs: &fakeConsoleJobs{}, + jobConfigs: &fakeJobConfigs{}, + } + + io := testsupport.NewTestConsole() + shell := repl.NewShell(io, runtime) + if err := shell.ExecuteArgs([]string{"version"}); err != nil { + t.Fatalf("ExecuteArgs(version) error = %v", err) + } + for _, want := range []string{"version: ", "commit: ", "buildTime: "} { + if !strings.Contains(io.Output(), want) { + t.Fatalf("version output missing %q in %q", want, io.Output()) + } + } + + io = testsupport.NewTestConsole() + shell = repl.NewShell(io, runtime) + if err := shell.ExecuteArgs([]string{"version", "--output", "json"}); err != nil { + t.Fatalf("ExecuteArgs(version --output json) error = %v", err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(io.Output()), &payload); err != nil { + t.Fatalf("json.Unmarshal(version output) error = %v, output = %q", err, io.Output()) + } + for _, key := range []string{"version", "commit", "buildTime"} { + if _, ok := payload[key]; !ok { + t.Fatalf("version payload missing %q: %#v", key, payload) + } + } +} + +func TestShellSupportsProfileCommands(t *testing.T) { + runtime := &fakeRuntime{ + cfg: config.AppConfig{APIBaseURL: "https://cc.example.com", AccessKey: "abcdefghijkl", SecretKey: "qrstuvwxyz1234"}, + currentProfile: "dev", + profileSummaries: []config.ProfileSummary{ + {Name: "dev", APIBaseURL: "https://dev.example.com", Current: true}, + {Name: "test", APIBaseURL: "https://test.example.com", Current: false}, + }, + dataJobs: &fakeDataJobs{}, + dataSources: &fakeDataSources{}, + clusters: &fakeClusters{}, + workers: &fakeWorkers{}, + consoleJobs: &fakeConsoleJobs{}, + jobConfigs: &fakeJobConfigs{}, + } + + io := testsupport.NewTestConsole() + shell := repl.NewShell(io, runtime) + if err := shell.ExecuteArgs([]string{"config", "profiles", "list"}); err != nil { + t.Fatalf("ExecuteArgs(config profiles list) error = %v", err) + } + for _, want := range []string{"CURRENT", "PROFILE", "dev", "test"} { + if !strings.Contains(io.Output(), want) { + t.Fatalf("profile list output missing %q in %q", want, io.Output()) + } + } + + io = testsupport.NewTestConsole() + shell = repl.NewShell(io, runtime) + if err := shell.ExecuteArgs([]string{"config", "profiles", "use", "test"}); err != nil { + t.Fatalf("ExecuteArgs(config profiles use test) error = %v", err) + } + if runtime.currentProfile != "test" { + t.Fatalf("currentProfile = %q, want test", runtime.currentProfile) + } + if !strings.Contains(io.Output(), "Current profile switched to test.") { + t.Fatalf("use output = %q, want switch message", io.Output()) + } + + io = testsupport.NewTestConsole() + shell = repl.NewShell(io, runtime) + if err := shell.ExecuteArgs([]string{"config", "profiles", "add", "prod"}); err != nil { + t.Fatalf("ExecuteArgs(config profiles add prod) error = %v", err) + } + if !strings.Contains(io.Output(), "Profile prod added.") { + t.Fatalf("add output = %q, want added message", io.Output()) + } + + io = testsupport.NewTestConsole() + shell = repl.NewShell(io, runtime) + if err := shell.ExecuteArgs([]string{"config", "profiles", "remove", "prod"}); err != nil { + t.Fatalf("ExecuteArgs(config profiles remove prod) error = %v", err) + } + if !strings.Contains(io.Output(), "Profile prod removed.") { + t.Fatalf("remove output = %q, want removed message", io.Output()) + } +} + type fakeRuntime struct { cfg config.AppConfig + language string + currentProfile string + profileSummaries []config.ProfileSummary dataJobs datajob.Operations dataSources datasource.Operations clusters cluster.Operations @@ -791,6 +903,31 @@ func (f *fakeRuntime) Config() config.AppConfig { return f.cfg } +func (f *fakeRuntime) CurrentProfile() string { + if f.currentProfile != "" { + return f.currentProfile + } + return config.DefaultProfileName +} + +func (f *fakeRuntime) Language() string { + if f.language != "" { + return f.language + } + return i18n.DefaultLanguage() +} + +func (f *fakeRuntime) ProfileSummaries() []config.ProfileSummary { + if len(f.profileSummaries) > 0 { + return f.profileSummaries + } + return []config.ProfileSummary{{ + Name: f.CurrentProfile(), + APIBaseURL: f.cfg.APIBaseURL, + Current: true, + }} +} + func (f *fakeRuntime) DataJobs() datajob.Operations { return f.dataJobs } @@ -824,8 +961,36 @@ func (f *fakeRuntime) Reinitialize(io console.IO) (bool, error) { return f.reinitializeValue, nil } +func (f *fakeRuntime) AddProfile(name string, io console.IO) (bool, error) { + f.profileSummaries = append(f.profileSummaries, config.ProfileSummary{Name: name, Current: false}) + return true, nil +} + +func (f *fakeRuntime) UseProfile(name string) error { + f.currentProfile = name + if len(f.profileSummaries) == 0 { + f.profileSummaries = []config.ProfileSummary{{Name: name, APIBaseURL: f.cfg.APIBaseURL, Current: true}} + return nil + } + for i := range f.profileSummaries { + f.profileSummaries[i].Current = f.profileSummaries[i].Name == name + } + return nil +} + +func (f *fakeRuntime) RemoveProfile(name string) error { + filtered := make([]config.ProfileSummary, 0, len(f.profileSummaries)) + for _, summary := range f.profileSummaries { + if summary.Name != name { + filtered = append(filtered, summary) + } + } + f.profileSummaries = filtered + return nil +} + func (f *fakeRuntime) SetLanguage(language string) error { - f.cfg.Language = language + f.language = language _ = i18n.SetLanguage(language) return nil }