diff --git a/.github/workflows/arch-audit.yml b/.github/workflows/arch-audit.yml index 3db50f6057..691dca4964 100644 --- a/.github/workflows/arch-audit.yml +++ b/.github/workflows/arch-audit.yml @@ -62,19 +62,6 @@ jobs: go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md echo '```' >> report.md - - name: Circular dependency check - run: | - echo "## Circular Dependencies" >> report.md - go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \ - go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt - if [ -s cycles.txt ]; then - echo '```' >> report.md - cat cycles.txt >> report.md - echo '```' >> report.md - else - echo "No circular dependencies detected." >> report.md - fi - - name: E2E coverage gaps run: | echo "## E2E Coverage Gaps" >> report.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d79d3d182..a9856b4011 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,8 @@ jobs: env: QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }} run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV" + - name: Enforce layering ratchet + run: bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM" - name: Run golangci-lint run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM" - name: Run source-contract lint guards (lintcheck) diff --git a/AGENTS.md b/AGENTS.md index 0bfcd2093d..d5a00b2271 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,10 +62,21 @@ Both notices recommend the same fix command: `lark-cli update`. The skills notic | `internal/credential/` | Credential provider chain (extension → default) | | `extension/credential/` | Plugin-facing credential interfaces and env provider | | `internal/client/client.go` | APIClient: DoSDKRequest, DoStream | -| `internal/core/config.go` | Multi-profile config loading/saving | +| `brand/` | Brand (feishu/lark) and its endpoint hosts — repo root, so `extension/` may import it | +| `internal/workspace/` | Workspace detection plus the config and runtime directory paths | +| `internal/identity/` | The `--as` identity (user/bot) and the strict-mode policy | +| `internal/config/config.go` | Multi-profile config loading/saving | | `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) | | `internal/validate/path.go` | Path safety validation | +`internal/core` is gone. Besides the four packages above it also became +`internal/secret` (app secret storage and resolution) and `internal/risk` (the +read / write / high-risk-write vocabulary). Import the narrowest one you need: +`brand`, `internal/workspace`, `internal/identity`, `internal/secret` and +`internal/risk` do not import each other — only `internal/config` sits on top of +them — so asking for a config directory no longer drags in keychain, i18n and +validate. + ## Who Uses This CLI This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates. diff --git a/Makefile b/Makefile index a338519e3f..34888078b2 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,7 @@ fmt-check: script-test: bash scripts/resolve-changed-from.test.sh + bash scripts/check-layering-ratchet.test.sh bash scripts/ci-workflow.test.sh bash scripts/semantic-review-workflow.test.sh $(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js diff --git a/internal/core/types.go b/brand/brand.go similarity index 77% rename from internal/core/types.go rename to brand/brand.go index 29720d4c35..0e230dcdab 100644 --- a/internal/core/types.go +++ b/brand/brand.go @@ -1,27 +1,27 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package brand import "strings" -// LarkBrand represents the Lark platform brand. +// Brand represents the Lark platform brand. // "feishu" targets China-mainland, "lark" targets international. -// ParseBrand and ResolveEndpoints map unrecognized values to BrandFeishu. -type LarkBrand string +// ParseBrand and ResolveEndpoints map unrecognized values to Feishu. +type Brand string const ( - BrandFeishu LarkBrand = "feishu" - BrandLark LarkBrand = "lark" + Feishu Brand = "feishu" + Lark Brand = "lark" ) // ParseBrand normalizes a brand string (case-insensitive, whitespace-tolerant); -// anything other than "lark" normalizes to BrandFeishu. -func ParseBrand(value string) LarkBrand { +// anything other than "lark" normalizes to Feishu. +func ParseBrand(value string) Brand { if strings.ToLower(strings.TrimSpace(value)) == "lark" { - return BrandLark + return Lark } - return BrandFeishu + return Feishu } // OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts @@ -40,9 +40,9 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. -func ResolveEndpoints(brand LarkBrand) Endpoints { +func ResolveEndpoints(brand Brand) Endpoints { switch ParseBrand(string(brand)) { - case BrandLark: + case Lark: return Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", @@ -60,6 +60,6 @@ func ResolveEndpoints(brand LarkBrand) Endpoints { } // ResolveOpenBaseURL returns the Open API base URL for the given brand. -func ResolveOpenBaseURL(brand LarkBrand) string { +func ResolveOpenBaseURL(brand Brand) string { return ResolveEndpoints(brand).Open } diff --git a/internal/core/types_test.go b/brand/brand_test.go similarity index 79% rename from internal/core/types_test.go rename to brand/brand_test.go index aa196ff8b3..bd43221d7e 100644 --- a/internal/core/types_test.go +++ b/brand/brand_test.go @@ -1,12 +1,12 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package brand import "testing" func TestResolveEndpoints_Feishu(t *testing.T) { - ep := ResolveEndpoints(BrandFeishu) + ep := ResolveEndpoints(Feishu) if ep.Open != "https://open.feishu.cn" { t.Errorf("Open = %q, want feishu.cn", ep.Open) } @@ -22,7 +22,7 @@ func TestResolveEndpoints_Feishu(t *testing.T) { } func TestResolveEndpoints_Lark(t *testing.T) { - ep := ResolveEndpoints(BrandLark) + ep := ResolveEndpoints(Lark) if ep.Open != "https://open.larksuite.com" { t.Errorf("Open = %q, want larksuite.com", ep.Open) } @@ -50,10 +50,10 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } func TestResolveOpenBaseURL(t *testing.T) { - if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { + if got := ResolveOpenBaseURL(Feishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) } - if got := ResolveOpenBaseURL(BrandLark); got != "https://open.larksuite.com" { + if got := ResolveOpenBaseURL(Lark); got != "https://open.larksuite.com" { t.Errorf("ResolveOpenBaseURL(lark) = %q", got) } } @@ -61,15 +61,15 @@ func TestResolveOpenBaseURL(t *testing.T) { func TestParseBrand(t *testing.T) { cases := []struct { in string - want LarkBrand + want Brand }{ - {"", BrandFeishu}, - {"feishu", BrandFeishu}, - {"lark", BrandLark}, - {"LARK", BrandLark}, - {" lark ", BrandLark}, - {"Lark", BrandLark}, - {"xyz", BrandFeishu}, + {"", Feishu}, + {"feishu", Feishu}, + {"lark", Lark}, + {"LARK", Lark}, + {" lark ", Lark}, + {"Lark", Lark}, + {"xyz", Feishu}, } for _, c := range cases { if got := ParseBrand(c.in); got != c.want { @@ -83,11 +83,11 @@ func TestParseBrand(t *testing.T) { // unusual casing or whitespace still resolve to their intended endpoints. func TestResolveEndpoints_NormalizesBrand(t *testing.T) { for _, raw := range []string{"LARK", " lark ", "Lark"} { - if got := ResolveEndpoints(LarkBrand(raw)).Open; got != "https://open.larksuite.com" { + if got := ResolveEndpoints(Brand(raw)).Open; got != "https://open.larksuite.com" { t.Errorf("ResolveEndpoints(%q).Open = %q, want the lark endpoint", raw, got) } } - if got := ResolveEndpoints(LarkBrand("unexpected")).Open; got != "https://open.feishu.cn" { + if got := ResolveEndpoints(Brand("unexpected")).Open; got != "https://open.feishu.cn" { t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got) } } diff --git a/cmd/api/api.go b/cmd/api/api.go index 4d1a039f1a..c8d09ad2db 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -13,7 +13,8 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -33,7 +34,7 @@ type APIOptions struct { // Flags Params string Data string - As core.Identity + As identity.Identity Output string PageAll bool PageSize int @@ -87,7 +88,7 @@ Examples: opts.Path = args[1] opts.Cmd = cmd opts.Ctx = cmd.Context() - opts.As = core.Identity(asStr) + opts.As = identity.Identity(asStr) if runF != nil { return runF(opts) } @@ -304,7 +305,7 @@ func apiRun(opts *APIOptions) error { return nil } -func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error { +func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *APIOptions) error { return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts)) } diff --git a/cmd/api/api_paginate_test.go b/cmd/api/api_paginate_test.go index 11e576bfee..512bbd9dbb 100644 --- a/cmd/api/api_paginate_test.go +++ b/cmd/api/api_paginate_test.go @@ -13,11 +13,13 @@ import ( "net/http" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) @@ -44,10 +46,10 @@ func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, output.PendingNotice = nil t.Cleanup(func() { output.PendingNotice = previousNotice }) - config := &core.CliConfig{ + config := &configpkg.CliConfig{ AppID: "test-app", AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, } f, out, errOut, reg := cmdutil.TestFactory(t, config) ac, err := f.NewAPIClientWithConfig(config) @@ -62,7 +64,7 @@ func apiPaginateRequest() client.RawApiRequest { return client.RawApiRequest{ Method: "GET", URL: "/open-apis/test/v1/items", - As: core.AsBot, + As: identity.AsBot, } } diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index c269bb9bb4..f7d8bb09e8 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -16,11 +16,13 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcs "github.com/larksuite/cli/extension/contentsafety" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/spf13/cobra" ) @@ -40,8 +42,8 @@ func newTestRootCmd() *cobra.Command { } func TestApiCmd_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions @@ -60,7 +62,7 @@ func TestApiCmd_FlagParsing(t *testing.T) { if gotOpts.Path != "/open-apis/test" { t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path) } - if gotOpts.As != core.AsBot { + if gotOpts.As != identity.AsBot { t.Errorf("expected as=bot, got %s", gotOpts.As) } if !gotOpts.DryRun { @@ -69,8 +71,8 @@ func TestApiCmd_FlagParsing(t *testing.T) { } func TestApiCmd_DryRun(t *testing.T) { - f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -104,8 +106,8 @@ func TestApiCmd_DryRun(t *testing.T) { } func TestApiCmd_DryRunWithJq(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -122,8 +124,8 @@ func TestApiCmd_DryRunWithJq(t *testing.T) { // not panic. Symmetric to the typed-flag overlay path in cmd/service — both // write into the map ParseJSONMap returns. func TestApiCmd_NullParamsWithPageSize(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -137,8 +139,8 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) { } func TestApiCmd_BotMode(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) // Register API endpoint stub @@ -170,8 +172,8 @@ func TestApiCmd_BotMode(t *testing.T) { } func TestApiCmd_MissingArgs(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -183,8 +185,8 @@ func TestApiCmd_MissingArgs(t *testing.T) { } func TestApiCmd_EmptyMethodRejected(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -199,8 +201,8 @@ func TestApiCmd_EmptyMethodRejected(t *testing.T) { } func TestApiCmd_InvalidParamsJSON(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -212,8 +214,8 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) { } func TestApiValidArgsFunction(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -278,8 +280,8 @@ func TestApiValidArgsFunction(t *testing.T) { } func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2, }) cmd := newTestApiCmd(f, nil) @@ -296,8 +298,8 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) { } func TestApiCmd_PageLimitDefault(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions @@ -316,8 +318,8 @@ func TestApiCmd_PageLimitDefault(t *testing.T) { } func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) @@ -332,8 +334,8 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) { } func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions @@ -355,8 +357,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu, + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -405,8 +407,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { } func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) { - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu, + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: brand.Feishu, }) // Register a non-batch API that returns scalar data (no array field) @@ -449,8 +451,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) { } func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: brand.Feishu, }) // Non-batch API that returns a business error (code != 0) @@ -486,8 +488,8 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) { } func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) { - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu, + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: brand.Feishu, }) // Register a batch API that returns an array field @@ -519,8 +521,8 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) { } func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -561,8 +563,8 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) { } func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -627,8 +629,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) { extcs.Register(provider) t.Cleanup(func() { extcs.Register(nil) }) - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -678,8 +680,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) { extcs.Register(provider) t.Cleanup(func() { extcs.Register(nil) }) - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu, + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -723,8 +725,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) { extcs.Register(provider) t.Cleanup(func() { extcs.Register(nil) }) - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -809,8 +811,8 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) { } func TestApiCmd_JqFlag_Parsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions @@ -829,8 +831,8 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) { } func TestApiCmd_JqFlag_ShortForm(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions @@ -849,8 +851,8 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) { } func TestApiCmd_JqAndOutputConflict(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, func(opts *APIOptions) error { @@ -867,8 +869,8 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) { } func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -901,8 +903,8 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) { } func TestApiCmd_JqAndFormatConflict(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, func(opts *APIOptions) error { @@ -919,8 +921,8 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) { } func TestApiCmd_JqInvalidExpression(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, func(opts *APIOptions) error { @@ -937,8 +939,8 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) { } func TestApiCmd_PageAll_WithJq(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -968,8 +970,8 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) { } func TestApiCmd_MethodUppercase(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions @@ -988,8 +990,8 @@ func TestApiCmd_MethodUppercase(t *testing.T) { } func TestApiCmd_FileFlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions cmd := newTestApiCmd(f, func(opts *APIOptions) error { @@ -1007,8 +1009,8 @@ func TestApiCmd_FileFlagParsing(t *testing.T) { } func TestApiCmd_FileAndOutputConflict(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) @@ -1024,8 +1026,8 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) { } func TestApiCmd_FileWithGET(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) @@ -1041,8 +1043,8 @@ func TestApiCmd_FileWithGET(t *testing.T) { } func TestApiCmd_FileStdinConflictWithData(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) @@ -1064,8 +1066,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) { t.Fatal(err) } - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"}) @@ -1102,8 +1104,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) { // — there is no raw-payload passthrough; new Lark diagnostic fields require // a CLI release. func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu, + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "cli_test_perm", AppSecret: "secret", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -1141,8 +1143,8 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) { } func TestApiCmd_JsonFlag_Accepted(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *APIOptions @@ -1194,8 +1196,8 @@ func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]stri } func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) dir := t.TempDir() @@ -1223,8 +1225,8 @@ func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) { } func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) dir := t.TempDir() @@ -1258,8 +1260,8 @@ func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) { } func TestApiCmd_FileUpload_WithDataFields(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) dir := t.TempDir() @@ -1291,8 +1293,8 @@ func TestApiCmd_FileUpload_WithDataFields(t *testing.T) { } func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes")) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 288f16de5b..9689e8839b 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -16,8 +16,8 @@ import ( larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/identity" ) // NewCmdAuth creates the auth command with subcommands. @@ -130,7 +130,7 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo HttpMethod: http.MethodGet, ApiPath: larkauth.ApplicationInfoPath(appId), QueryParams: queryParams, - }, core.AsBot) + }, identity.AsBot) if err != nil { return nil, err } @@ -170,7 +170,7 @@ func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory } raw["code"] = code raw["msg"] = msg - cc := errclass.ClassifyContext{Identity: string(core.AsBot)} + cc := errclass.ClassifyContext{Identity: string(identity.AsBot)} if cfg, _ := f.Config(); cfg != nil { cc.Brand = string(cfg.Brand) cc.AppID = appId diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index f633a61433..d6bf82a1f5 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -12,10 +12,11 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" @@ -23,8 +24,8 @@ import ( ) func TestAuthLoginCmd_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *LoginOptions @@ -46,8 +47,8 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) { } func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil }) @@ -72,8 +73,8 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) { } func TestAuthCheckCmd_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *CheckOptions @@ -92,8 +93,8 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) { } func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *CheckOptions @@ -192,8 +193,8 @@ func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) { } func TestAuthStatusCmd_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *StatusOptions @@ -211,8 +212,8 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) { } func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *StatusOptions @@ -234,8 +235,8 @@ func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) { } func TestAuthStatusCmd_VerifyFlag(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *StatusOptions @@ -336,8 +337,8 @@ func TestDomainFlagCompletion(t *testing.T) { } func TestAuthScopesCmd_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *ScopesOptions @@ -356,8 +357,8 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) { } func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *ScopesOptions @@ -382,8 +383,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) { } func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu, + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "", Brand: brand.Feishu, }) tokenResolver := &authScopesTokenResolver{} f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil) @@ -438,8 +439,8 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) // getAppInfo classifies it as *errs.PermissionError carrying the server- // supplied MissingScopes — not a bare error wrapped as InternalError. func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) tokenResolver := &authScopesTokenResolver{} f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil) diff --git a/cmd/auth/check_test.go b/cmd/auth/check_test.go index 708e2925a9..49b3135f41 100644 --- a/cmd/auth/check_test.go +++ b/cmd/auth/check_test.go @@ -9,9 +9,10 @@ import ( "testing" "time" + "github.com/larksuite/cli/brand" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" "github.com/zalando/go-keyring" ) @@ -23,8 +24,8 @@ import ( // branch. These tests pin that contract end-to-end through the dispatcher. func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) { - f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, // UserOpenId left empty: triggers the not_logged_in branch. }) @@ -55,8 +56,8 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) { } func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) { - f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, UserOpenId: "ou_user", UserName: "tester", }) @@ -92,10 +93,10 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app", AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_user", UserName: "tester", } @@ -150,8 +151,8 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) { // Scope validation is a real input error, not a predicate negative // answer — it must surface as a typed ValidationError with the normal // stderr envelope, distinct from the silent ErrBare predicate path. - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) err := authCheckRun(&CheckOptions{Factory: f, Scope: " "}) diff --git a/cmd/auth/list.go b/cmd/auth/list.go index f345200a49..65f7512f99 100644 --- a/cmd/auth/list.go +++ b/cmd/auth/list.go @@ -12,7 +12,7 @@ import ( "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" ) @@ -45,7 +45,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co func authListRun(opts *ListOptions) error { f := opts.Factory - multi, _ := core.LoadMultiAppConfig() + multi, _ := configpkg.LoadMultiAppConfig() if multi == nil || len(multi.Apps) == 0 { if opts.JSON { output.PrintJson(f.IOStreams.Out, map[string]interface{}{ @@ -61,7 +61,7 @@ func authListRun(opts *ListOptions) error { // workspace-aware, so we pull the message+hint out of // NotConfiguredError() instead of hard-coding it. var cfgErr *errs.ConfigError - if errors.As(core.NotConfiguredError(), &cfgErr) { + if errors.As(configpkg.NotConfiguredError(), &cfgErr) { fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message) if cfgErr.Hint != "" { fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint) diff --git a/cmd/auth/list_test.go b/cmd/auth/list_test.go index 070e4fae15..3b6581db46 100644 --- a/cmd/auth/list_test.go +++ b/cmd/auth/list_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/workspace" ) // TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that @@ -69,9 +69,9 @@ func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) { func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - prev := core.CurrentWorkspace() - t.Cleanup(func() { core.SetCurrentWorkspace(prev) }) - core.SetCurrentWorkspace(core.WorkspaceOpenClaw) + prev := workspace.CurrentWorkspace() + t.Cleanup(func() { workspace.SetCurrentWorkspace(prev) }) + workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw) f, _, stderr, _ := cmdutil.TestFactory(t, nil) if err := authListRun(&ListOptions{Factory: f}); err != nil { diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 3b240b1765..f89fe7f577 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -13,12 +13,14 @@ import ( "github.com/spf13/cobra" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" @@ -55,7 +57,7 @@ send the verification URL (or QR code) to the user as your final message, end th run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode' to generate QR codes (supports ASCII and PNG formats).`, RunE: func(cmd *cobra.Command, args []string) error { - if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { + if mode := f.ResolveStrictMode(cmd.Context()); mode == identity.StrictModeBot { return errs.NewValidationError(errs.SubtypeInvalidArgument, "strict mode is %q, user login is disabled in this profile", mode). WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") @@ -72,7 +74,7 @@ to generate QR codes (supports ASCII and PNG formats).`, cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend") cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") - var helpBrand core.LarkBrand + var helpBrand brandpkg.Brand if f != nil && f.Config != nil { if cfg, err := f.Config(); err == nil && cfg != nil { helpBrand = cfg.Brand @@ -125,7 +127,7 @@ func authLoginRun(opts *LoginOptions) error { // Determine UI language from saved config var lang i18n.Lang - if multi, _ := core.LoadMultiAppConfig(); multi != nil { + if multi, _ := configpkg.LoadMultiAppConfig(); multi != nil { if app := multi.FindApp(config.ProfileName); app != nil { lang = app.Lang } @@ -391,7 +393,7 @@ func authLoginRun(opts *LoginOptions) error { // authLoginPollDeviceCode resumes the device flow by polling with a device code // obtained from a previous --no-wait call. -func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error { +func authLoginPollDeviceCode(opts *LoginOptions, config *configpkg.CliConfig, msg *loginMsg, log func(string, ...interface{})) error { f := opts.Factory httpClient, err := f.HttpClient() @@ -474,7 +476,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo // syncLoginUserToProfile persists the logged-in user info into the named profile. func syncLoginUserToProfile(profileName, appID, openID, userName string) error { - multi, err := core.LoadMultiAppConfig() + multi, err := configpkg.LoadMultiAppConfig() if err != nil { return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err) } @@ -484,9 +486,9 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error { return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName) } - oldUsers := append([]core.AppUser(nil), app.Users...) - app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}} - if err := core.SaveMultiAppConfig(multi); err != nil { + oldUsers := append([]configpkg.AppUser(nil), app.Users...) + app.Users = []configpkg.AppUser{{UserOpenId: openID, UserName: userName}} + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err) } @@ -499,7 +501,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error { } // findProfileByName returns the AppConfig matching profileName, or nil. -func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig { +func findProfileByName(multi *configpkg.MultiAppConfig, profileName string) *configpkg.AppConfig { for i := range multi.Apps { if multi.Apps[i].ProfileName() == profileName { return &multi.Apps[i] @@ -512,7 +514,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App // shortcut scopes for the given domain names. // Domains with auth_domain children are automatically expanded to include // their children's scopes. -func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string { +func collectScopesForDomains(domains []string, identity string, brand brandpkg.Brand) []string { scopeSet := make(map[string]bool) // 1. API scopes from from_meta projects @@ -553,7 +555,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB // allKnownDomains returns all valid auth domain names (from_meta projects + // shortcut services), excluding domains that have auth_domain set (they are // folded into their parent domain). -func allKnownDomains(brand core.LarkBrand) map[string]bool { +func allKnownDomains(brand brandpkg.Brand) map[string]bool { domains := make(map[string]bool) for _, p := range registry.ListFromMetaProjects() { if !registry.HasAuthDomain(p) { @@ -572,7 +574,7 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool { } // sortedKnownDomains returns all valid domain names sorted alphabetically. -func sortedKnownDomains(brand core.LarkBrand) []string { +func sortedKnownDomains(brand brandpkg.Brand) []string { m := allKnownDomains(brand) domains := make([]string, 0, len(m)) for d := range m { diff --git a/cmd/auth/login_brand_filter_test.go b/cmd/auth/login_brand_filter_test.go index b8eae24e53..d1ce334780 100644 --- a/cmd/auth/login_brand_filter_test.go +++ b/cmd/auth/login_brand_filter_test.go @@ -6,26 +6,26 @@ package auth import ( "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" ) func TestBrandFilter_AppsExcludedOnLark(t *testing.T) { - feishuDomains := allKnownDomains(core.BrandFeishu) + feishuDomains := allKnownDomains(brand.Feishu) if !feishuDomains["apps"] { t.Errorf("expected apps domain to be known on Feishu brand") } - larkDomains := allKnownDomains(core.BrandLark) + larkDomains := allKnownDomains(brand.Lark) if larkDomains["apps"] { t.Errorf("expected apps domain to be EXCLUDED on Lark brand") } - feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu) + feishuScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Feishu) if len(feishuScopes) == 0 { t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes)) } - larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark) + larkScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Lark) if len(larkScopes) != 0 { t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes) } diff --git a/cmd/auth/login_config_test.go b/cmd/auth/login_config_test.go index 63f0095da2..fb772ac0e0 100644 --- a/cmd/auth/login_config_test.go +++ b/cmd/auth/login_config_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) func setupLoginConfigDir(t *testing.T) { @@ -17,22 +17,22 @@ func setupLoginConfigDir(t *testing.T) { func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) { setupLoginConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "target", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "target", AppId: "app-target", - Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}}, + Users: []configpkg.AppUser{{UserOpenId: "ou_old", UserName: "old"}}, }, { Name: "other", AppId: "app-other", - Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}}, + Users: []configpkg.AppUser{{UserOpenId: "ou_other", UserName: "other"}}, }, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -40,7 +40,7 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) { t.Fatalf("syncLoginUserToProfile() error = %v", err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -54,13 +54,13 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) { func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) { setupLoginConfigDir(t) - multi := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + multi := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ Name: "default", AppId: "app-default", }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 70e01065cb..ccf381e681 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -10,9 +10,9 @@ import ( "github.com/charmbracelet/huh" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" @@ -102,7 +102,7 @@ func buildDomainMeta(name, lang string) domainMeta { } // runInteractiveLogin shows an interactive TUI form for domain and permission selection. -func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) { +func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand brandpkg.Brand) (*interactiveResult, error) { allDomains := getDomainMetadata(lang) // Build multi-select options diff --git a/cmd/auth/login_scope_cache.go b/cmd/auth/login_scope_cache.go index ad8036bdaa..234413c558 100644 --- a/cmd/auth/login_scope_cache.go +++ b/cmd/auth/login_scope_cache.go @@ -11,9 +11,9 @@ import ( "regexp" larkauth "github.com/larksuite/cli/internal/auth" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) @@ -25,7 +25,7 @@ type loginScopeCacheRecord struct { // loginScopeCacheDir returns the directory used to persist auth login --no-wait // requested scopes keyed by device_code. func loginScopeCacheDir() string { - return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes") + return filepath.Join(workspace.GetConfigDir(), "cache", "auth_login_scopes") } // loginScopeCachePath returns the cache file path for a given device_code. diff --git a/cmd/auth/login_strict_test.go b/cmd/auth/login_strict_test.go index 206929bbaf..5cedf921dc 100644 --- a/cmd/auth/login_strict_test.go +++ b/cmd/auth/login_strict_test.go @@ -9,11 +9,11 @@ import ( extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) { - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "a", AppSecret: "s", SupportedIdentities: uint8(extcred.SupportsBot), } @@ -39,7 +39,7 @@ func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) { } func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) { - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "a", AppSecret: "s", SupportedIdentities: uint8(extcred.SupportsUser), } @@ -62,7 +62,7 @@ func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) { } func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) var called bool cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index f2aa3389a9..e7fc8f3c75 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -14,9 +14,10 @@ import ( "strings" "testing" + brandpkg "github.com/larksuite/cli/brand" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" @@ -308,8 +309,8 @@ func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) { } func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { - f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "cli_test", AppSecret: "secret", Brand: brandpkg.Feishu, }) // TestFactory has IsTerminal=false by default opts := &LoginOptions{Factory: f, Ctx: context.Background()} @@ -600,21 +601,21 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ {Name: "default", AppId: "cli_test"}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } - f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) reg.Register(&httpmock.Stub{ @@ -696,7 +697,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) if stored.Scope != "offline_access" { t.Fatalf("stored scope = %q", stored.Scope) } - cfg, err := core.LoadMultiAppConfig() + cfg, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -716,21 +717,21 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ {Name: "default", AppId: "cli_test"}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) reg.Register(&httpmock.Stub{ @@ -847,15 +848,15 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { original := pollDeviceToken t.Cleanup(func() { pollDeviceToken = original }) - pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { + pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { return &larkauth.DeviceFlowResult{OK: true, Token: nil} } - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) err := authLoginRun(&LoginOptions{ @@ -886,15 +887,15 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { original := pollDeviceToken t.Cleanup(func() { pollDeviceToken = original }) - pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { + pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"} } - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) reg.Register(&httpmock.Stub{ @@ -956,11 +957,11 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { } func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) f.IOStreams.Out = failWriter{} @@ -993,11 +994,11 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { } func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) reg.Register(&httpmock.Stub{ @@ -1067,11 +1068,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) { } func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) { - f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) f.IOStreams.Out = failWriter{} @@ -1105,11 +1106,11 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t * } func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, }) reg.Register(&httpmock.Stub{ diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 7e82127edd..9f39c8ead3 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -11,8 +11,9 @@ import ( "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" ) // LogoutOptions holds all inputs for auth logout. @@ -44,7 +45,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr func authLogoutRun(opts *LogoutOptions) error { f := opts.Factory - multi, _ := core.LoadMultiAppConfig() + multi, _ := configpkg.LoadMultiAppConfig() if multi == nil || len(multi.Apps) == 0 { if opts.JSON { output.PrintJson(f.IOStreams.Out, map[string]interface{}{ @@ -73,7 +74,7 @@ func authLogoutRun(opts *LogoutOptions) error { } httpClient, httpErr := f.HttpClient() - appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain) + appSecret, secretErr := secret.ResolveSecretInput(app.AppSecret, f.Keychain) for _, user := range app.Users { if httpErr == nil && secretErr == nil { @@ -94,8 +95,8 @@ func authLogoutRun(opts *LogoutOptions) error { } } - app.Users = []core.AppUser{} - if err := core.SaveMultiAppConfig(multi); err != nil { + app.Users = []configpkg.AppUser{} + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } if opts.JSON { diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 613e470548..24c62ca103 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -9,22 +9,24 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/secret" "github.com/zalando/go-keyring" ) -func writeLogoutConfig(t *testing.T, users []core.AppUser) { +func writeLogoutConfig(t *testing.T, users []configpkg.AppUser) { t.Helper() - if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{ CurrentApp: "test-app", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { AppId: "test-app", - AppSecret: core.PlainSecret("test-secret"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("test-secret"), + Brand: brand.Feishu, Users: users, }, }, @@ -91,7 +93,7 @@ func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) + writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ AppId: "test-app", UserOpenId: "ou_user", @@ -127,7 +129,7 @@ func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) + writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ AppId: "test-app", UserOpenId: "ou_user", @@ -153,19 +155,19 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) { setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "default", AppId: "cli_test", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, - Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, + Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, }, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ @@ -177,11 +179,11 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) { t.Fatalf("SetStoredToken() error = %v", err) } - f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -210,7 +212,7 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) { if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil { t.Fatalf("expected stored token removed, got %#v", got) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -224,19 +226,19 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing. setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "default", AppId: "cli_test", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, - Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, + Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, }, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ @@ -247,11 +249,11 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing. t.Fatalf("SetStoredToken() error = %v", err) } - f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -280,7 +282,7 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing. if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil { t.Fatalf("expected stored token removed, got %#v", got) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -294,19 +296,19 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) { setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "default", AppId: "cli_test", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, - Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, + Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, }, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ @@ -318,11 +320,11 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) { t.Fatalf("SetStoredToken() error = %v", err) } - f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ ProfileName: "default", AppID: "cli_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -346,7 +348,7 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) { if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil { t.Fatalf("expected stored token removed, got %#v", got) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } diff --git a/cmd/auth/qrcode_test.go b/cmd/auth/qrcode_test.go index a171026c82..c8ce1f7645 100644 --- a/cmd/auth/qrcode_test.go +++ b/cmd/auth/qrcode_test.go @@ -11,14 +11,15 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" ) func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *QRCodeOptions @@ -45,8 +46,8 @@ func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) { } func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *QRCodeOptions diff --git a/cmd/auth/scopes_test.go b/cmd/auth/scopes_test.go index 9ab748d6ba..d6636ce67a 100644 --- a/cmd/auth/scopes_test.go +++ b/cmd/auth/scopes_test.go @@ -9,9 +9,10 @@ import ( "fmt" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) // stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun @@ -31,10 +32,10 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) { // and reach the getAppInfoFn call. func scopesTestFactory(t *testing.T) *ScopesOptions { t.Helper() - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ AppID: "test-app", AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, }) return &ScopesOptions{ Factory: f, diff --git a/cmd/auth/status_test.go b/cmd/auth/status_test.go index 7bf0608c77..f1516940fd 100644 --- a/cmd/auth/status_test.go +++ b/cmd/auth/status_test.go @@ -8,14 +8,15 @@ import ( "net/http" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" ) func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu, + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu, }) if err := authStatusRun(&StatusOptions{Factory: f}); err != nil { @@ -38,8 +39,8 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) { } func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ Method: http.MethodGet, diff --git a/cmd/build.go b/cmd/build.go index 55f89304c4..8f32451430 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -8,6 +8,7 @@ import ( "io" "io/fs" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/completion" @@ -25,7 +26,6 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/registry" @@ -44,7 +44,7 @@ type buildConfig struct { skipStrictMode bool skipService bool serviceCatalog *apicatalog.Catalog - startupBrand core.LarkBrand + startupBrand brandpkg.Brand } // WithStartupBrand initializes the API registry with the given brand before @@ -52,7 +52,7 @@ type buildConfig struct { // registry's sync.Once locks onto the Feishu default at first catalog access, // long before the lazily-resolved config brand is known — see // ResolveStartupBrand for the caller-side resolution. -func WithStartupBrand(brand core.LarkBrand) BuildOption { +func WithStartupBrand(brand brandpkg.Brand) BuildOption { return func(c *buildConfig) { c.startupBrand = brand } diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 67916b17e5..9f5d45faaf 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -14,12 +14,15 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) // BindOptions holds all inputs for config bind. @@ -128,8 +131,8 @@ func configBindRun(opts *BindOptions) error { if err != nil { return err } - core.SetCurrentWorkspace(core.Workspace(source)) - targetConfigPath := core.GetConfigPath() + workspace.SetCurrentWorkspace(workspace.Workspace(source)) + targetConfigPath := workspace.GetConfigPath() existing, err := reconcileExistingBinding(opts, source, targetConfigPath) if err != nil { @@ -186,12 +189,12 @@ func finalizeSource(opts *BindOptions) (string, error) { } var detected string - switch core.DetectWorkspaceFromEnv(os.Getenv) { - case core.WorkspaceOpenClaw: + switch workspace.DetectWorkspaceFromEnv(os.Getenv) { + case workspace.WorkspaceOpenClaw: detected = "openclaw" - case core.WorkspaceHermes: + case workspace.WorkspaceHermes: detected = "hermes" - case core.WorkspaceLarkChannel: + case workspace.WorkspaceLarkChannel: detected = "lark-channel" } @@ -264,7 +267,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi // enumerate candidates, pick one via the shared decision layer, and build a // ready-to-persist AppConfig. Adding a new bind source only requires // implementing SourceBinder — none of the logic below needs to change. -func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) { +func resolveAccount(opts *BindOptions, source string) (*configpkg.AppConfig, error) { binder, err := newBinder(source, opts) if err != nil { return nil, err @@ -307,12 +310,12 @@ func resolveIdentity(opts *BindOptions) error { // the bind flow treats a corrupt previous config (commitBinding will // overwrite it cleanly). func hasStrictBotLock(data []byte) bool { - var multi core.MultiAppConfig + var multi configpkg.MultiAppConfig if err := json.Unmarshal(data, &multi); err != nil { return false } for _, app := range multi.Apps { - if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot { + if app.StrictMode != nil && *app.StrictMode == identity.StrictModeBot { return true } } @@ -369,16 +372,16 @@ func preferredLang(requested, prior i18n.Lang) i18n.Lang { return prior } -func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) { +func applyPreferences(appConfig *configpkg.AppConfig, opts *BindOptions, prior i18n.Lang) { switch opts.Identity { case "bot-only": - sm := core.StrictModeBot + sm := identity.StrictModeBot appConfig.StrictMode = &sm - appConfig.DefaultAs = core.AsBot + appConfig.DefaultAs = identity.AsBot case "user-default": - sm := core.StrictModeOff + sm := identity.StrictModeOff appConfig.StrictMode = &sm - appConfig.DefaultAs = core.AsUser + appConfig.DefaultAs = identity.AsUser } appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior) } @@ -389,7 +392,7 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L // wrong profile's preference into a re-bind when the workspace holds multiple // named profiles and the active one disagrees with Apps[0]. func priorLang(previousConfigBytes []byte) i18n.Lang { - var multi core.MultiAppConfig + var multi configpkg.MultiAppConfig if json.Unmarshal(previousConfigBytes, &multi) != nil { return "" } @@ -404,10 +407,10 @@ func priorLang(previousConfigBytes []byte) i18n.Lang { // any), and a JSON success envelope. Cleanup runs only after the new config // is durably written — if anything fails earlier, the old workspace stays // usable. -func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error { - multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}} +func commitBinding(opts *BindOptions, appConfig *configpkg.AppConfig, previousConfigBytes []byte, source, configPath string) error { + multi := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{*appConfig}} - if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { + if err := vfs.MkdirAll(workspace.GetConfigDir(), 0700); err != nil { return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err) } data, err := json.MarshalIndent(multi, "", " ") @@ -476,8 +479,8 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB // the secret that ForStorage just wrote (old and new secret share the same // keychain key, derived from appId). Best-effort: errors are silently // ignored (same contract as config init's cleanup). -func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) { - var multi core.MultiAppConfig +func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *configpkg.AppConfig) { + var multi configpkg.MultiAppConfig if err := json.Unmarshal(data, &multi); err != nil { return } @@ -489,7 +492,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID { continue } - core.RemoveSecretStore(app.AppSecret, kc) + secret.RemoveSecretStore(app.AppSecret, kc) } } @@ -503,13 +506,13 @@ func tuiSelectSource(opts *BindOptions) (string, error) { var source string // Pre-select based on detected env signals - detected := core.DetectWorkspaceFromEnv(os.Getenv) + detected := workspace.DetectWorkspaceFromEnv(os.Getenv) switch detected { - case core.WorkspaceOpenClaw: + case workspace.WorkspaceOpenClaw: source = "openclaw" - case core.WorkspaceHermes: + case workspace.WorkspaceHermes: source = "hermes" - case core.WorkspaceLarkChannel: + case workspace.WorkspaceLarkChannel: source = "lark-channel" default: source = "openclaw" // default first option @@ -582,7 +585,7 @@ func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, er // Build existing binding summary existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath) if data, err := vfs.ReadFile(configPath); err == nil { - var multi core.MultiAppConfig + var multi configpkg.MultiAppConfig if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 { app := multi.Apps[0] existingSummary = fmt.Sprintf(msg.ConflictDesc, diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 04ea8442a2..dbe08f96ef 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -13,11 +13,15 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" + "github.com/larksuite/cli/internal/workspace" ) // wantErrDetail is the normalized comparison shape for a typed error's wire @@ -80,8 +84,8 @@ func assertEnvelope(t *testing.T, stdout []byte, want map[string]any) { // Must be called at the start of any test that may trigger configBindRun (which sets workspace). func saveWorkspace(t *testing.T) { t.Helper() - orig := core.CurrentWorkspace() - t.Cleanup(func() { core.SetCurrentWorkspace(orig) }) + orig := workspace.CurrentWorkspace() + t.Cleanup(func() { workspace.SetCurrentWorkspace(orig) }) } // ── Command flag parsing tests (aligned with config_test.go pattern) ── @@ -229,7 +233,7 @@ func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) { t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err) } - multi, err := core.LoadMultiAppConfig() + multi, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig: %v", err) } @@ -265,7 +269,7 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) { t.Fatalf("re-bind (no --lang): %v", err) } - multi, err := core.LoadMultiAppConfig() + multi, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig: %v", err) } @@ -279,9 +283,9 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) { // workspace (set up via `profile add` before a re-bind), the active profile's // Lang must win over a sibling profile that happens to sit earlier in the slice. func TestPriorLang_RespectsCurrentApp(t *testing.T) { - multi := core.MultiAppConfig{ + multi := configpkg.MultiAppConfig{ CurrentApp: "active", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ {Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP}, {Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS}, }, @@ -300,8 +304,8 @@ func TestPriorLang_RespectsCurrentApp(t *testing.T) { // so a bind-written config (which always has exactly one app and no // CurrentApp field) still inherits its Lang. func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) { - multi := core.MultiAppConfig{ - Apps: []core.AppConfig{ + multi := configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{ {AppId: "cli_only", Lang: i18n.LangJaJP}, }, } @@ -639,8 +643,8 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) { // Brand is not in the stdout envelope — read it back from the persisted // workspace config to verify accounts.app.tenant flowed through to the // stored AppConfig.Brand field. - core.SetCurrentWorkspace(core.WorkspaceLarkChannel) - multi, err := core.LoadMultiAppConfig() + workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel) + multi, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("load workspace config: %v", err) } @@ -686,8 +690,8 @@ func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) { if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil { t.Fatalf("expected success, got error: %v", err) } - core.SetCurrentWorkspace(core.WorkspaceLarkChannel) - multi, err := core.LoadMultiAppConfig() + workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel) + multi, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("load workspace config: %v", err) } @@ -801,16 +805,16 @@ func TestConfigShowRun_WorkspaceField(t *testing.T) { configDir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) - core.SetCurrentWorkspace(core.WorkspaceLocal) + workspace.SetCurrentWorkspace(workspace.WorkspaceLocal) - multi := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + multi := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ AppId: "cli_local_test", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("save: %v", err) } @@ -827,7 +831,7 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) { saveWorkspace(t) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - core.SetCurrentWorkspace(core.WorkspaceOpenClaw) + workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw) f, _, _, _ := cmdutil.TestFactory(t, nil) err := configShowRun(&ConfigShowOptions{Factory: f}) @@ -998,7 +1002,7 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) { if err != nil { t.Fatalf("read config.json: %v", err) } - var multi core.MultiAppConfig + var multi configpkg.MultiAppConfig if err := json.Unmarshal(data, &multi); err != nil { t.Fatalf("unmarshal config.json: %v", err) } @@ -1008,8 +1012,8 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) { if multi.Apps[0].AppId != "cli_hermes_abc" { t.Errorf("appId = %q, want %q", multi.Apps[0].AppId, "cli_hermes_abc") } - if multi.Apps[0].Brand != core.BrandLark { - t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, core.BrandLark) + if multi.Apps[0].Brand != brand.Lark { + t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, brand.Lark) } } @@ -1275,7 +1279,7 @@ func TestConfigBindRun_Identity_BotOnly_Applied(t *testing.T) { "message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "en")), }) assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"), - core.StrictModeBot, core.AsBot) + identity.StrictModeBot, identity.AsBot) } // TestConfigBindRun_FlagModeDefaultsToBotOnly verifies the flag-mode default @@ -1310,7 +1314,7 @@ func TestConfigBindRun_FlagModeDefaultsToBotOnly(t *testing.T) { "message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "")), }) assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"), - core.StrictModeBot, core.AsBot) + identity.StrictModeBot, identity.AsBot) } // TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce verifies the @@ -1406,7 +1410,7 @@ func TestConfigBindRun_IdentityEscalationWithForceAllowed(t *testing.T) { t.Fatalf("expected --force to allow the escalation, got: %v", err) } assertPresetApplied(t, filepath.Join(hermesDir, "config.json"), - core.StrictModeOff, core.AsUser) + identity.StrictModeOff, identity.AsUser) } // TestConfigBindRun_AllowsRebindSameBotOnly verifies re-binding the same @@ -1442,7 +1446,7 @@ func TestConfigBindRun_AllowsRebindSameBotOnly(t *testing.T) { t.Fatalf("expected rebind to same bot-only identity to succeed, got: %v", err) } assertPresetApplied(t, filepath.Join(hermesDir, "config.json"), - core.StrictModeBot, core.AsBot) + identity.StrictModeBot, identity.AsBot) } // TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig verifies that if the @@ -1479,18 +1483,18 @@ func TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig(t *testing.T) { t.Fatalf("expected user-default→user-default rebind to succeed, got: %v", err) } assertPresetApplied(t, filepath.Join(hermesDir, "config.json"), - core.StrictModeOff, core.AsUser) + identity.StrictModeOff, identity.AsUser) } // assertPresetApplied verifies the on-disk config.json applied the identity // preset's StrictMode + DefaultAs expansion. -func assertPresetApplied(t *testing.T, configPath string, wantStrict core.StrictMode, wantDefault core.Identity) { +func assertPresetApplied(t *testing.T, configPath string, wantStrict identity.StrictMode, wantDefault identity.Identity) { t.Helper() data, err := os.ReadFile(configPath) if err != nil { t.Fatalf("read %s: %v", configPath, err) } - var multi core.MultiAppConfig + var multi configpkg.MultiAppConfig if err := json.Unmarshal(data, &multi); err != nil { t.Fatalf("unmarshal %s: %v", configPath, err) } @@ -1787,10 +1791,10 @@ func TestCleanupKeychainFromData_KeepsSecretSharedWithNewApp(t *testing.T) { } oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"` + sharedID + `"}}]}`) - newApp := &core.AppConfig{ + newApp := &configpkg.AppConfig{ AppId: "cli_shared", - AppSecret: core.SecretInput{ - Ref: &core.SecretRef{Source: "keychain", ID: sharedID}, + AppSecret: secret.SecretInput{ + Ref: &secret.SecretRef{Source: "keychain", ID: sharedID}, }, } @@ -1817,10 +1821,10 @@ func TestCleanupKeychainFromData_RemovesStaleSecretWhenAppIDChanges(t *testing.T } oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"` + oldID + `"}}]}`) - newApp := &core.AppConfig{ + newApp := &configpkg.AppConfig{ AppId: "cli_new", - AppSecret: core.SecretInput{ - Ref: &core.SecretRef{Source: "keychain", ID: newID}, + AppSecret: secret.SecretInput{ + Ref: &secret.SecretRef{Source: "keychain", ID: newID}, }, } diff --git a/cmd/config/binder.go b/cmd/config/binder.go index a24f927be1..f4732ddbb5 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -9,9 +9,11 @@ import ( "path/filepath" "strings" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/binding" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/openclawbind" + secretpkg "github.com/larksuite/cli/internal/secret" "github.com/larksuite/cli/internal/vfs" ) @@ -36,7 +38,7 @@ type SourceBinder interface { ListCandidates() ([]Candidate, error) // Build resolves secrets, persists to keychain, and returns a ready AppConfig // for the chosen candidate AppID. Must be called after ListCandidates succeeds. - Build(appID string) (*core.AppConfig, error) + Build(appID string) (*configpkg.AppConfig, error) } // newBinder constructs the SourceBinder for the given source name. @@ -138,15 +140,15 @@ type openclawBinder struct { path string // Cached between ListCandidates and Build so we don't re-read / re-parse. - cfg *binding.OpenClawRoot - rawApps []binding.CandidateApp + cfg *openclawbind.OpenClawRoot + rawApps []openclawbind.CandidateApp } func (b *openclawBinder) Name() string { return "openclaw" } func (b *openclawBinder) ConfigPath() string { return b.path } func (b *openclawBinder) ListCandidates() ([]Candidate, error) { - cfg, err := binding.ReadOpenClawConfig(b.path) + cfg, err := openclawbind.ReadOpenClawConfig(b.path) if err != nil { return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). WithHint("verify OpenClaw is installed and configured"). @@ -157,7 +159,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) { WithHint("configure Feishu in OpenClaw first") } - raw := binding.ListCandidateApps(cfg.Channels.Feishu) + raw := openclawbind.ListCandidateApps(cfg.Channels.Feishu) b.cfg = cfg b.rawApps = raw @@ -168,12 +170,12 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) { return result, nil } -func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { +func (b *openclawBinder) Build(appID string) (*configpkg.AppConfig, error) { if b.cfg == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } - var selected *binding.CandidateApp + var selected *openclawbind.CandidateApp for i := range b.rawApps { if b.rawApps[i].AppID == appID { selected = &b.rawApps[i] @@ -188,24 +190,24 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path). WithHint("configure channels.feishu.appSecret in openclaw.json") } - secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) + secret, err := openclawbind.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) if err != nil { return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err). WithHint("check appSecret configuration in %s", b.path). WithCause(err) } - stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain) + stored, err := secretpkg.ForStorage(selected.AppID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). WithHint("use file: reference in config to bypass keychain"). WithCause(err) } - return &core.AppConfig{ + return &configpkg.AppConfig{ AppId: selected.AppID, AppSecret: stored, - Brand: core.ParseBrand(selected.Brand), + Brand: brand.ParseBrand(selected.Brand), }, nil } @@ -238,7 +240,7 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) { return []Candidate{{AppID: appID, Label: "default"}}, nil } -func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { +func (b *hermesBinder) Build(appID string) (*configpkg.AppConfig, error) { if b.envMap == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } @@ -251,17 +253,17 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { WithHint("run 'hermes setup' to configure Feishu credentials") } - stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain) + stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), b.opts.Factory.Keychain) if err != nil { return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). WithHint("use file: reference in config to bypass keychain"). WithCause(err) } - return &core.AppConfig{ + return &configpkg.AppConfig{ AppId: appID, AppSecret: stored, - Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]), + Brand: brand.ParseBrand(b.envMap["FEISHU_DOMAIN"]), }, nil } @@ -274,14 +276,14 @@ type larkChannelBinder struct { path string // Cached between ListCandidates and Build so we don't re-read the file. - cfg *binding.LarkChannelRoot + cfg *openclawbind.LarkChannelRoot } func (b *larkChannelBinder) Name() string { return "lark-channel" } func (b *larkChannelBinder) ConfigPath() string { return b.path } func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { - cfg, err := binding.ReadLarkChannelConfig(b.path) + cfg, err := openclawbind.ReadLarkChannelConfig(b.path) if err != nil { return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). WithHint("verify lark-channel-bridge is installed and configured"). @@ -295,7 +297,7 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil } -func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { +func (b *larkChannelBinder) Build(appID string) (*configpkg.AppConfig, error) { if b.cfg == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } @@ -309,24 +311,24 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { // Resolve through the same SecretInput pipeline openclaw uses, so // bridge configs can use ${VAR} / env / file / exec just like openclaw. - secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv) + secret, err := openclawbind.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv) if err != nil { return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err). WithHint("check appSecret configuration in %s", b.path). WithCause(err) } - stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain) + stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). WithHint("use file: reference in config to bypass keychain"). WithCause(err) } - return &core.AppConfig{ + return &configpkg.AppConfig{ AppId: appID, AppSecret: stored, - Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant), + Brand: brand.ParseBrand(b.cfg.Accounts.App.Tenant), }, nil } diff --git a/cmd/config/binder_test.go b/cmd/config/binder_test.go index ee7c7e6de2..ad14478190 100644 --- a/cmd/config/binder_test.go +++ b/cmd/config/binder_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" ) @@ -20,10 +20,10 @@ type fakeBinder struct { path string } -func (b *fakeBinder) Name() string { return b.name } -func (b *fakeBinder) ConfigPath() string { return b.path } -func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil } -func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil } +func (b *fakeBinder) Name() string { return b.name } +func (b *fakeBinder) ConfigPath() string { return b.path } +func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil } +func (b *fakeBinder) Build(appID string) (*configpkg.AppConfig, error) { return nil, nil } // tuiUnreachable is a tuiPrompt that fails the test if called. It's the // guardrail that proves the non-TUI decision paths really do stay out of the diff --git a/cmd/config/config.go b/cmd/config/config.go index ae316af819..9bfd13b696 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -4,8 +4,8 @@ package config import ( + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/spf13/cobra" ) @@ -38,6 +38,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { return cmd } -func parseBrand(value string) core.LarkBrand { - return core.ParseBrand(value) +func parseBrand(value string) brand.Brand { + return brand.ParseBrand(value) } diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 9f0f3da62c..7d1fafada3 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -12,14 +12,16 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" ) type noopConfigKeychain struct{} @@ -66,8 +68,8 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) { } func TestConfigShowCmd_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) var gotOpts *ConfigShowOptions @@ -108,16 +110,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) { func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "missing", - Apps: []core.AppConfig{{ + Apps: []configpkg.AppConfig{{ Name: "default", AppId: "app-default", - AppSecret: core.PlainSecret("secret-default"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-default"), + Brand: brand.Feishu, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -186,18 +188,18 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, _, _ := cmdutil.TestFactory(t, nil) - existing := &core.MultiAppConfig{Apps: []core.AppConfig{ - {AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP}, + existing := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{ + {AppId: "cli_x", AppSecret: secret.PlainSecret("s"), Brand: brand.Feishu, Lang: i18n.LangJaJP}, }} - if err := core.SaveMultiAppConfig(existing); err != nil { + if err := configpkg.SaveMultiAppConfig(existing); err != nil { t.Fatalf("seed config: %v", err) } - if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil { + if err := saveInitConfig("", existing, f, "cli_x", secret.PlainSecret("s2"), brand.Feishu, ""); err != nil { t.Fatalf("saveInitConfig (no --lang): %v", err) } - got, err := core.LoadMultiAppConfig() + got, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig: %v", err) } @@ -318,17 +320,17 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing configDir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) - multi := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + multi := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ AppId: "app-test", - AppSecret: core.SecretInput{ - Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"}, + AppSecret: secret.SecretInput{ + Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-test"}, }, - Brand: core.BrandFeishu, - Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}}, + Brand: brand.Feishu, + Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}}, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -357,7 +359,7 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing if err := os.Chmod(configDir, 0700); err != nil { t.Fatalf("restore Chmod(%s) error = %v", configDir, err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -377,18 +379,18 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - existing := &core.MultiAppConfig{ - Apps: []core.AppConfig{ + existing := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{ { Name: "prod", AppId: "cli_prod", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, }, }, } - err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en") + err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", secret.PlainSecret("new-secret"), brand.Lark, "en") if err == nil { t.Fatal("expected conflict error") } @@ -428,21 +430,21 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) { } func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) { - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "prod", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "prod", AppId: "app-old", - AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}}, - Brand: core.BrandFeishu, + AppSecret: secret.SecretInput{Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-old"}}, + Brand: brand.Feishu, Lang: "zh", - Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}}, + Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "User"}}, }, }, } - err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en") + err := updateExistingProfileWithoutSecret(multi, "", "app-new", brand.Lark, "en") if err == nil { t.Fatal("expected error when changing app ID without a new secret") } diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go index f1d5de4e79..239cf969a0 100644 --- a/cmd/config/default_as.go +++ b/cmd/config/default_as.go @@ -8,7 +8,8 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/spf13/cobra" ) @@ -20,14 +21,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - multi, err := core.LoadOrNotConfigured() + multi, err := configpkg.LoadOrNotConfigured() if err != nil { return err } app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return core.NoActiveProfileError() + return configpkg.NoActiveProfileError() } if len(args) == 0 { @@ -44,8 +45,8 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value) } - app.DefaultAs = core.Identity(value) - if err := core.SaveMultiAppConfig(multi); err != nil { + app.DefaultAs = identity.Identity(value) + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value) diff --git a/cmd/config/init.go b/cmd/config/init.go index 544c4c60be..7fb152efbb 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -13,13 +13,16 @@ import ( "github.com/spf13/cobra" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" + secretpkg "github.com/larksuite/cli/internal/secret" + "github.com/larksuite/cli/internal/workspace" ) // ConfigInitOptions holds all inputs for config init. @@ -121,7 +124,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error { if opts.ForceInit { return nil } - ws := core.DetectWorkspaceFromEnv(os.Getenv) + ws := workspace.DetectWorkspaceFromEnv(os.Getenv) if ws.IsLocal() { return nil } @@ -136,7 +139,7 @@ func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { } // cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. -func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) { +func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, skipAppID string) { if existing == nil { return } @@ -144,7 +147,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp if app.AppId == skipAppID { continue } - core.RemoveSecretStore(app.AppSecret, f.Keychain) + secretpkg.RemoveSecretStore(app.AppSecret, f.Keychain) for _, user := range app.Users { auth.RemoveStoredToken(app.AppId, user.UserOpenId) } @@ -152,19 +155,19 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp } // saveAsOnlyApp overwrites config.json with a single-app config. -func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { - config := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ - AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{}, +func saveAsOnlyApp(appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error { + config := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ + AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []configpkg.AppUser{}, }}, } - return core.SaveMultiAppConfig(config) + return configpkg.SaveMultiAppConfig(config) } // saveInitConfig saves a new/updated app config, respecting --profile mode. // With profileName: appends or updates the named profile (preserves other profiles). // Without profileName: cleans up old config and saves as the only app. -func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +func saveInitConfig(profileName string, existing *configpkg.MultiAppConfig, f *cmdutil.Factory, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error { if profileName != "" { return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang) } @@ -195,20 +198,20 @@ func wrapSaveConfigError(err error) error { // saveAsProfile appends or updates a named profile in the config. // If a profile with the same name exists, it updates it; otherwise appends. // When updating, cleans up old keychain secrets if AppId changed. -func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +func saveAsProfile(existing *configpkg.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error { multi := existing if multi == nil { - multi = &core.MultiAppConfig{} + multi = &configpkg.MultiAppConfig{} } if idx := findProfileIndexByName(multi, profileName); idx >= 0 { // Clean up old keychain secret and user tokens if AppId changed if multi.Apps[idx].AppId != appId { - core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc) + secretpkg.RemoveSecretStore(multi.Apps[idx].AppSecret, kc) for _, user := range multi.Apps[idx].Users { auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId) } - multi.Apps[idx].Users = []core.AppUser{} + multi.Apps[idx].Users = []configpkg.AppUser{} } multi.Apps[idx].AppId = appId multi.Apps[idx].AppSecret = secret @@ -221,19 +224,19 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr WithParam("--name") } // Append new profile - multi.Apps = append(multi.Apps, core.AppConfig{ + multi.Apps = append(multi.Apps, configpkg.AppConfig{ Name: profileName, AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), - Users: []core.AppUser{}, + Users: []configpkg.AppUser{}, }) } - return core.SaveMultiAppConfig(multi) + return configpkg.SaveMultiAppConfig(multi) } -func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int { +func findProfileIndexByName(multi *configpkg.MultiAppConfig, profileName string) int { if multi == nil { return -1 } @@ -245,7 +248,7 @@ func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int return -1 } -func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int { +func findAppIndexByAppID(multi *configpkg.MultiAppConfig, appID string) int { if multi == nil { return -1 } @@ -272,13 +275,13 @@ func wrapUpdateExistingProfileErr(err error) error { return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err) } -func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error { +func updateExistingProfileWithoutSecret(existing *configpkg.MultiAppConfig, profileName, appID string, brand brandpkg.Brand, lang string) error { if existing == nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration"). WithParam("--app-secret") } - var app *core.AppConfig + var app *configpkg.AppConfig if profileName != "" { if idx := findProfileIndexByName(existing, profileName); idx >= 0 { app = &existing.Apps[idx] @@ -302,7 +305,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa app.AppId = appID app.Brand = brand app.Lang = preferredLang(i18n.Lang(lang), app.Lang) - return core.SaveMultiAppConfig(existing) + return configpkg.SaveMultiAppConfig(existing) } func configInitRun(opts *ConfigInitOptions) error { @@ -323,14 +326,14 @@ func configInitRun(opts *ConfigInitOptions) error { } } - existing, err := core.LoadMultiAppConfig() + existing, err := configpkg.LoadMultiAppConfig() if err != nil { existing = nil // treat as empty } // Validate --profile name if set if opts.ProfileName != "" { - if err := core.ValidateProfileName(opts.ProfileName); err != nil { + if err := configpkg.ValidateProfileName(opts.ProfileName); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err) } } @@ -338,14 +341,14 @@ func configInitRun(opts *ConfigInitOptions) error { // Mode 1: Non-interactive if opts.AppID != "" && opts.appSecret != "" { brand := parseBrand(opts.Brand) - secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) + secret, err := secretpkg.ForStorage(opts.AppID, secretpkg.PlainSecret(opts.appSecret), f.Keychain) if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { return wrapSaveConfigError(err) } - output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath())) printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil { @@ -377,8 +380,8 @@ func configInitRun(opts *ConfigInitOptions) error { if result == nil { return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result") } - existing, _ := core.LoadMultiAppConfig() - secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) + existing, _ := configpkg.LoadMultiAppConfig() + secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain) if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } @@ -404,11 +407,11 @@ func configInitRun(opts *ConfigInitOptions) error { WithParam("--app-id") } - existing, _ := core.LoadMultiAppConfig() + existing, _ := configpkg.LoadMultiAppConfig() if result.AppSecret != "" { // New secret provided (either from "create" or "existing" with input) - secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) + secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain) if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } @@ -443,7 +446,7 @@ func configInitRun(opts *ConfigInitOptions) error { } // Mode 5: Legacy interactive (readline fallback) - firstApp := (*core.AppConfig)(nil) + firstApp := (*configpkg.AppConfig)(nil) if existing != nil { firstApp = existing.CurrentAppConfig("") } @@ -494,9 +497,9 @@ func configInitRun(opts *ConfigInitOptions) error { if resolvedAppId == "" && firstApp != nil { resolvedAppId = firstApp.AppId } - var resolvedSecret core.SecretInput + var resolvedSecret secretpkg.SecretInput if appSecretInput != "" { - resolvedSecret = core.PlainSecret(appSecretInput) + resolvedSecret = secretpkg.PlainSecret(appSecretInput) } else if firstApp != nil { resolvedSecret = firstApp.AppSecret } @@ -513,14 +516,14 @@ func configInitRun(opts *ConfigInitOptions) error { WithParam("--app-id") } - storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) + storedSecret, err := secretpkg.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { return wrapSaveConfigError(err) } - output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath())) printLangPreferenceConfirmation(opts) if appSecretInput != "" { if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil { diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index ecc3ef0201..444ca7d27a 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -10,13 +10,14 @@ import ( "net" "github.com/charmbracelet/huh" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/build" qrcode "github.com/skip2/go-qrcode" "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/transport" ) @@ -24,7 +25,7 @@ import ( // configInitResult holds the result of the interactive config init flow. type configInitResult struct { Mode string // "create" or "existing" - Brand core.LarkBrand + Brand brand.Brand AppID string AppSecret string } @@ -62,8 +63,8 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init // runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand. func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { // Load existing config for defaults - existing, _ := core.LoadMultiAppConfig() - var firstApp *core.AppConfig + existing, _ := configpkg.LoadMultiAppConfig() + var firstApp *configpkg.AppConfig if existing != nil { firstApp = existing.CurrentAppConfig("") } @@ -150,8 +151,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er // runCreateAppFlow runs the "create new app" flow via OpenClaw device flow. // If brandOverride is non-empty, skip the interactive brand selection. -func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) { - var larkBrand core.LarkBrand +func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride brand.Brand, msg *initMsg) (*configInitResult, error) { + var larkBrand brand.Brand if brandOverride != "" { larkBrand = brandOverride } else { diff --git a/cmd/config/init_probe.go b/cmd/config/init_probe.go index b6873ac21a..f86bf8092a 100644 --- a/cmd/config/init_probe.go +++ b/cmd/config/init_probe.go @@ -11,10 +11,10 @@ import ( "net/http" "time" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" ) @@ -47,7 +47,7 @@ const probeTimeout = 3 * time.Second // 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of // that call (success, server error, timeout, parse failure) is always // ignored — return nil regardless. -func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error { +func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand brandpkg.Brand) error { if factory == nil { return nil } @@ -73,7 +73,7 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret } // TAT succeeded — fire the probe call. Any outcome is ignored. - url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe" + url := brandpkg.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe" body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { diff --git a/cmd/config/init_probe_test.go b/cmd/config/init_probe_test.go index f4156a73b9..99b0db7d88 100644 --- a/cmd/config/init_probe_test.go +++ b/cmd/config/init_probe_test.go @@ -13,10 +13,10 @@ import ( "testing" "time" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" ) // fakeRT routes requests to per-path handlers and records what it saw. @@ -132,7 +132,7 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) { } f, errBuf := fakeFactory(t, rt) - err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu) if rt.probeCalls != 0 { t.Error("probe endpoint must not be called when TAT fails") @@ -148,7 +148,7 @@ func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) { }, } f, errBuf := fakeFactory(t, rt) - assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) + assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf) } // Any other deterministic client-side OAuth error (e.g. invalid_scope) falls @@ -161,7 +161,7 @@ func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) { }, } f, errBuf := fakeFactory(t, rt) - err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu) if err == nil || !errs.IsTyped(err) { t.Fatalf("expected a propagated typed error, got %T: %v", err, err) } @@ -180,7 +180,7 @@ func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) { }, } f, errBuf := fakeFactory(t, rt) - assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf) } } @@ -191,7 +191,7 @@ func TestRunProbe_TATTransportError_Silent(t *testing.T) { }, } f, errBuf := fakeFactory(t, rt) - assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf) } func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) { @@ -201,7 +201,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) { }, } f, errBuf := fakeFactory(t, rt) - err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu) if rt.probeCalls != 1 { t.Errorf("probe should be called once, got %d", rt.probeCalls) } @@ -211,7 +211,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) { func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) { rt := &fakeRT{} f, errBuf := fakeFactory(t, rt) - err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu) if rt.tatCalls != 1 || rt.probeCalls != 1 { t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls) } @@ -221,7 +221,7 @@ func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) { func TestRunProbe_ProbeRequestShape(t *testing.T) { rt := &fakeRT{} f, _ := fakeFactory(t, rt) - if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil { + if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -245,7 +245,7 @@ func TestRunProbe_ProbeRequestShape(t *testing.T) { func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) { rt := &fakeRT{} f, _ := fakeFactory(t, rt) - if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil { + if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Lark); err != nil { t.Fatalf("unexpected error: %v", err) } if rt.probeReq == nil { @@ -262,7 +262,7 @@ func TestRunProbe_HTTPClientError_Silent(t *testing.T) { f.HttpClient = func() (*http.Client, error) { return nil, errors.New("client init failed") } - assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf) } func TestRunProbe_TimeoutHonored(t *testing.T) { @@ -275,7 +275,7 @@ func TestRunProbe_TimeoutHonored(t *testing.T) { f, errBuf := fakeFactory(t, rt) start := time.Now() - err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu) elapsed := time.Since(start) if elapsed > 4*time.Second { diff --git a/cmd/config/init_test.go b/cmd/config/init_test.go index 7347b8b528..b7f84a8ae8 100644 --- a/cmd/config/init_test.go +++ b/cmd/config/init_test.go @@ -8,9 +8,11 @@ import ( "fmt" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" ) // updateExistingProfileWithoutSecret guards four blank-input scenarios. Each @@ -19,47 +21,47 @@ import ( // not for missing user input. func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) { - err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en") + err := updateExistingProfileWithoutSecret(nil, "", "cli_test", brand.Feishu, "en") assertValidationParam(t, err, "--app-secret") } func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) { - existing := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + existing := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ Name: "default", AppId: "app-default", - AppSecret: core.PlainSecret("secret-default"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-default"), + Brand: brand.Feishu, }}, } - err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en") + err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", brand.Feishu, "en") assertValidationParam(t, err, "--app-secret") } func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) { - existing := &core.MultiAppConfig{ + existing := &configpkg.MultiAppConfig{ CurrentApp: "missing", - Apps: []core.AppConfig{{ + Apps: []configpkg.AppConfig{{ Name: "default", AppId: "app-default", - AppSecret: core.PlainSecret("secret-default"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-default"), + Brand: brand.Feishu, }}, } - err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en") + err := updateExistingProfileWithoutSecret(existing, "", "cli_test", brand.Feishu, "en") assertValidationParam(t, err, "--app-secret") } func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) { - existing := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + existing := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ Name: "default", AppId: "app-default", - AppSecret: core.PlainSecret("secret-default"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-default"), + Brand: brand.Feishu, }}, } - err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en") + err := updateExistingProfileWithoutSecret(existing, "", "cli_different", brand.Feishu, "en") assertValidationParam(t, err, "--app-secret") } diff --git a/cmd/config/remove.go b/cmd/config/remove.go index 74dd0e8476..50d333c54a 100644 --- a/cmd/config/remove.go +++ b/cmd/config/remove.go @@ -9,8 +9,9 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" "github.com/spf13/cobra" ) @@ -41,21 +42,21 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro func configRemoveRun(opts *ConfigRemoveOptions) error { f := opts.Factory - config, err := core.LoadMultiAppConfig() + config, err := configpkg.LoadMultiAppConfig() if err != nil || config == nil || len(config.Apps) == 0 { return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet") } // Save empty config first. If this fails, keep secrets and tokens intact so the // existing config can still be retried instead of ending up half-removed. - empty := &core.MultiAppConfig{Apps: []core.AppConfig{}} - if err := core.SaveMultiAppConfig(empty); err != nil { + empty := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{}} + if err := configpkg.SaveMultiAppConfig(empty); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } // Clean up keychain entries for all apps after config is cleared. for _, app := range config.Apps { - core.RemoveSecretStore(app.AppSecret, f.Keychain) + secret.RemoveSecretStore(app.AppSecret, f.Keychain) for _, user := range app.Users { _ = auth.RemoveStoredToken(app.AppId, user.UserOpenId) } diff --git a/cmd/config/risk_control.go b/cmd/config/risk_control.go index f594fcc9c8..b0c38b53e3 100644 --- a/cmd/config/risk_control.go +++ b/cmd/config/risk_control.go @@ -10,7 +10,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) // NewCmdConfigRiskControl creates the workspace risk-control policy command. @@ -29,7 +29,7 @@ opt it back in explicitly, or default to remove the explicit preference.`, return nil }, RunE: func(cmd *cobra.Command, args []string) error { - config, err := core.LoadOrNotConfigured() + config, err := configpkg.LoadOrNotConfigured() if err != nil { return err } @@ -52,7 +52,7 @@ opt it back in explicitly, or default to remove the explicit preference.`, "invalid risk-control value %q, valid values: on | off | default", args[0]) } - if err := core.SaveMultiAppConfig(config); err != nil { + if err := configpkg.SaveMultiAppConfig(config); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save risk-control policy: %v", err).WithCause(err) } @@ -64,7 +64,7 @@ opt it back in explicitly, or default to remove the explicit preference.`, return cmd } -func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) { +func printRiskControl(f *cmdutil.Factory, config *configpkg.MultiAppConfig) { source := "default" if config.RiskControl != nil { source = "workspace" diff --git a/cmd/config/risk_control_test.go b/cmd/config/risk_control_test.go index 7b29f830fa..3dc42d4dd9 100644 --- a/cmd/config/risk_control_test.go +++ b/cmd/config/risk_control_test.go @@ -8,17 +8,19 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/secret" ) func TestRiskControlWorkspacePolicy(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - config := &core.MultiAppConfig{Apps: []core.AppConfig{{ - AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu, + config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{ + AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu, }}} - if err := core.SaveMultiAppConfig(config); err != nil { + if err := configpkg.SaveMultiAppConfig(config); err != nil { t.Fatal(err) } @@ -28,7 +30,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("set off: %v", err) } - loaded, err := core.LoadMultiAppConfig() + loaded, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatal(err) } @@ -53,7 +55,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("set on: %v", err) } - loaded, err = core.LoadMultiAppConfig() + loaded, err = configpkg.LoadMultiAppConfig() if err != nil { t.Fatal(err) } @@ -66,7 +68,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("reset default: %v", err) } - loaded, err = core.LoadMultiAppConfig() + loaded, err = configpkg.LoadMultiAppConfig() if err != nil { t.Fatal(err) } @@ -86,8 +88,8 @@ func TestRiskControlWorkspacePolicy(t *testing.T) { func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{ - AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu, + if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{ + AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu, }}}); err != nil { t.Fatal(err) } @@ -107,10 +109,10 @@ func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) { func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) { f := newConfigFactoryWithExternalProvider(t) - config := &core.MultiAppConfig{Apps: []core.AppConfig{{ - AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu, + config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{ + AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu, }}} - if err := core.SaveMultiAppConfig(config); err != nil { + if err := configpkg.SaveMultiAppConfig(config); err != nil { t.Fatal(err) } @@ -120,7 +122,7 @@ func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) t.Fatalf("set off with external credentials: %v", err) } - loaded, err := core.LoadMultiAppConfig() + loaded, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatal(err) } diff --git a/cmd/config/show.go b/cmd/config/show.go index 5526f0254a..df4c8bb75b 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -11,8 +11,9 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/workspace" "github.com/spf13/cobra" ) @@ -43,15 +44,15 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) * func configShowRun(opts *ConfigShowOptions) error { f := opts.Factory - config, err := core.LoadMultiAppConfig() + config, err := configpkg.LoadMultiAppConfig() if err != nil { if errors.Is(err, os.ErrNotExist) { - return core.NotConfiguredError() + return configpkg.NotConfiguredError() } return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err) } if config == nil || len(config.Apps) == 0 { - return core.NotConfiguredError() + return configpkg.NotConfiguredError() } app := config.CurrentAppConfig(f.Invocation.Profile) if app == nil { @@ -66,7 +67,7 @@ func configShowRun(opts *ConfigShowOptions) error { users = strings.Join(userStrs, ", ") } output.PrintJson(f.IOStreams.Out, map[string]interface{}{ - "workspace": core.CurrentWorkspace().Display(), + "workspace": workspace.CurrentWorkspace().Display(), "profile": app.ProfileName(), "appId": app.AppId, "appSecret": "****", @@ -74,6 +75,6 @@ func configShowRun(opts *ConfigShowOptions) error { "lang": app.Lang, "users": users, }) - fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath()) + fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", workspace.GetConfigPath()) return nil } diff --git a/cmd/config/strict_mode.go b/cmd/config/strict_mode.go index 46610585ab..4b99871121 100644 --- a/cmd/config/strict_mode.go +++ b/cmd/config/strict_mode.go @@ -9,7 +9,8 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/spf13/cobra" ) @@ -37,7 +38,7 @@ explicit user confirmation — never run on your own initiative.`, lark-cli config strict-mode --reset # clear profile override`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - multi, err := core.LoadOrNotConfigured() + multi, err := configpkg.LoadOrNotConfigured() if err != nil { return err } @@ -45,20 +46,20 @@ explicit user confirmation — never run on your own initiative.`, if reset { app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return core.NoActiveProfileError() + return configpkg.NoActiveProfileError() } return resetStrictMode(f, multi, app, global, args) } if len(args) == 0 { app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return core.NoActiveProfileError() + return configpkg.NoActiveProfileError() } return showStrictMode(cmd.Context(), f, multi, app) } app := multi.CurrentAppConfig(f.Invocation.Profile) if !global && app == nil { - return core.NoActiveProfileError() + return configpkg.NoActiveProfileError() } return setStrictMode(f, multi, app, args[0], global) }, @@ -71,7 +72,7 @@ explicit user confirmation — never run on your own initiative.`, return cmd } -func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error { +func resetStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, global bool, args []string) error { if global { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset") } @@ -79,14 +80,14 @@ func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.A return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset") } app.StrictMode = nil - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)") return nil } -func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error { +func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) error { // Runtime effective mode from credential provider chain is the source of truth. runtime := f.ResolveStrictMode(ctx) configMode, configSource := resolveStrictModeStatus(multi, app) @@ -99,10 +100,10 @@ func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAp return nil } -func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error { - mode := core.StrictMode(value) +func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, value string, global bool) error { + mode := identity.StrictMode(value) switch mode { - case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff: + case identity.StrictModeBot, identity.StrictModeUser, identity.StrictModeOff: default: return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value) } @@ -118,7 +119,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App // false-positived (--global change while current profile has an explicit // override) and false-negatived (--global broadening that doesn't affect // the current profile but does affect other inheriting profiles). - var oldMode core.StrictMode + var oldMode identity.StrictMode if global { oldMode = multi.StrictMode } else { @@ -138,16 +139,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App } } else { if app == nil { - return core.NoActiveProfileError() + return configpkg.NoActiveProfileError() } app.StrictMode = &mode } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } - if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) { + if oldMode == identity.StrictModeBot && (mode == identity.StrictModeUser || mode == identity.StrictModeOff) { fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage) } @@ -162,19 +163,19 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App // strictModeRelaxLang picks the bind-message bundle whose language matches the // active profile's Lang setting. Falls back to bindMsgZh when no profile is // available (global mutation with no current app). -func strictModeRelaxLang(app *core.AppConfig) *bindMsg { +func strictModeRelaxLang(app *configpkg.AppConfig) *bindMsg { if app != nil { return getBindMsg(app.Lang) } return getBindMsg("") } -func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) { +func resolveStrictModeStatus(multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) (identity.StrictMode, string) { if app != nil && app.StrictMode != nil { return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName()) } if multi.StrictMode.IsActive() { return multi.StrictMode, "global" } - return core.StrictModeOff, "global (default)" + return identity.StrictModeOff, "global (default)" } diff --git a/cmd/config/strict_mode_test.go b/cmd/config/strict_mode_test.go index 7b930415ed..8664b2bb3d 100644 --- a/cmd/config/strict_mode_test.go +++ b/cmd/config/strict_mode_test.go @@ -7,29 +7,32 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" + "github.com/larksuite/cli/internal/secret" ) func setupStrictModeTestConfig(t *testing.T) { t.Helper() dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - multi := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + multi := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ AppId: "test-app", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatal(err) } } func TestStrictMode_Show_Default(t *testing.T) { setupStrictModeTestConfig(t) - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{}) if err := cmd.Execute(); err != nil { @@ -42,37 +45,37 @@ func TestStrictMode_Show_Default(t *testing.T) { func TestStrictMode_SetBot_Profile(t *testing.T) { setupStrictModeTestConfig(t) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{"bot"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } - multi, _ := core.LoadMultiAppConfig() + multi, _ := configpkg.LoadMultiAppConfig() app := multi.CurrentAppConfig("") - if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot { + if app.StrictMode == nil || *app.StrictMode != identity.StrictModeBot { t.Error("expected StrictMode=bot on profile") } } func TestStrictMode_SetUser_Profile(t *testing.T) { setupStrictModeTestConfig(t) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{"user"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } - multi, _ := core.LoadMultiAppConfig() + multi, _ := configpkg.LoadMultiAppConfig() app := multi.CurrentAppConfig("") - if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser { + if app.StrictMode == nil || *app.StrictMode != identity.StrictModeUser { t.Error("expected StrictMode=user on profile") } } func TestStrictMode_SetOff_Profile(t *testing.T) { setupStrictModeTestConfig(t) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{"bot"}) cmd.Execute() @@ -81,23 +84,23 @@ func TestStrictMode_SetOff_Profile(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatal(err) } - multi, _ := core.LoadMultiAppConfig() + multi, _ := configpkg.LoadMultiAppConfig() app := multi.CurrentAppConfig("") - if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff { + if app.StrictMode == nil || *app.StrictMode != identity.StrictModeOff { t.Error("expected StrictMode=off on profile") } } func TestStrictMode_SetBot_Global(t *testing.T) { setupStrictModeTestConfig(t) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{"bot", "--global"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } - multi, _ := core.LoadMultiAppConfig() - if multi.StrictMode != core.StrictModeBot { + multi, _ := configpkg.LoadMultiAppConfig() + if multi.StrictMode != identity.StrictModeBot { t.Error("expected global StrictMode=bot") } } @@ -105,38 +108,38 @@ func TestStrictMode_SetBot_Global(t *testing.T) { func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "missing-profile", - Apps: []core.AppConfig{{ + Apps: []configpkg.AppConfig{{ Name: "default", AppId: "test-app", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatal(err) } - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{"bot", "--global"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v", err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } - if saved.StrictMode != core.StrictModeBot { - t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot) + if saved.StrictMode != identity.StrictModeBot { + t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, identity.StrictModeBot) } } func TestStrictMode_Reset(t *testing.T) { setupStrictModeTestConfig(t) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{"bot"}) cmd.Execute() @@ -145,7 +148,7 @@ func TestStrictMode_Reset(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatal(err) } - multi, _ := core.LoadMultiAppConfig() + multi, _ := configpkg.LoadMultiAppConfig() app := multi.CurrentAppConfig("") if app.StrictMode != nil { t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode) @@ -154,7 +157,7 @@ func TestStrictMode_Reset(t *testing.T) { func TestStrictMode_InvalidValue(t *testing.T) { setupStrictModeTestConfig(t) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs([]string{"on"}) err := cmd.Execute() diff --git a/cmd/config/strict_mode_warning_test.go b/cmd/config/strict_mode_warning_test.go index 62e7324ae3..b87b4e5c32 100644 --- a/cmd/config/strict_mode_warning_test.go +++ b/cmd/config/strict_mode_warning_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) // runStrictMode is a small helper that runs `config strict-mode ` and @@ -16,7 +16,7 @@ import ( // new user-identity warning land. func runStrictMode(t *testing.T, args ...string) string { t.Helper() - f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"}) cmd := NewCmdConfigStrictMode(f) cmd.SetArgs(args) if err := cmd.Execute(); err != nil { diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index bef93d03a8..c9b46f2224 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -14,14 +14,16 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/update" + "github.com/larksuite/cli/internal/workspace" ) // DoctorOptions holds inputs for the doctor command. @@ -85,7 +87,7 @@ func doctorRun(opts *DoctorOptions) error { } // ── 1. Config file ── - _, err := core.LoadMultiAppConfig() + _, err := configpkg.LoadMultiAppConfig() if err != nil { // For "config not present" cases, prefer the workspace-aware // NotConfiguredError message + hint (e.g. "openclaw context @@ -96,7 +98,7 @@ func doctorRun(opts *DoctorOptions) error { msg, hint := err.Error(), "" if errors.Is(err, os.ErrNotExist) { var cfgErr *errs.ConfigError - if errors.As(core.NotConfiguredError(), &cfgErr) { + if errors.As(configpkg.NotConfiguredError(), &cfgErr) { msg, hint = cfgErr.Message, cfgErr.Hint } } @@ -118,7 +120,7 @@ func doctorRun(opts *DoctorOptions) error { } checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand))) - ep := core.ResolveEndpoints(cfg.Brand) + ep := brand.ResolveEndpoints(cfg.Brand) // ── 3. Identity readiness ── diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline) @@ -149,7 +151,7 @@ func identityCheck(name string, id identitydiag.Identity) checkResult { } // networkChecks probes Open API and MCP endpoints concurrently. -func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult { +func networkChecks(ctx context.Context, opts *DoctorOptions, ep brand.Endpoints) []checkResult { if opts.Offline { return []checkResult{ skip("endpoint_open", "skipped (--offline)"), @@ -239,7 +241,7 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error { result := map[string]interface{}{ "ok": allOK, - "workspace": core.CurrentWorkspace().Display(), + "workspace": workspace.CurrentWorkspace().Display(), "checks": checks, } output.PrintJson(f.IOStreams.Out, result) diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go index 76cfbd77e4..ecd258b85c 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -13,15 +13,17 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/secret" ) func TestNewCmdDoctor_FlagParsing(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := NewCmdDoctor(f) @@ -88,7 +90,7 @@ func TestFinishDoctor(t *testing.T) { } func TestNetworkChecks_Offline(t *testing.T) { - ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"} + ep := brand.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"} opts := &DoctorOptions{Ctx: context.Background(), Offline: true} checks := networkChecks(opts.Ctx, opts, ep) if len(checks) != 2 { @@ -103,22 +105,22 @@ func TestNetworkChecks_Offline(t *testing.T) { func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "default", AppId: "test-app", - AppSecret: core.PlainSecret("secret"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, }, }, }); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu, + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu, }) err := doctorRun(&DoctorOptions{ Factory: f, @@ -180,16 +182,16 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext // per-identity checks already carry the source-appropriate escalation. func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}}, + Apps: []configpkg.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu}}, }); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } // Provider serves neither identity: bot unsupported, user supported but not // signed in → both unavailable → identity_ready fails. - cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)} + cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsUser)} cred := credential.NewCredentialProvider( []extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}}, nil, nil, @@ -197,7 +199,7 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T ) out := &bytes.Buffer{} f := &cmdutil.Factory{ - Config: func() (*core.CliConfig, error) { return cfg, nil }, + Config: func() (*configpkg.CliConfig, error) { return cfg, nil }, Credential: cred, IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}, } diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index b25dec07cf..339bbde39c 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -14,7 +14,7 @@ import ( "github.com/larksuite/cli/internal/apicatalog" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + identitypkg "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" shortcutcommon "github.com/larksuite/cli/shortcuts/common" @@ -58,9 +58,9 @@ func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string { identity := string(f.ResolvedIdentity) if identity == "" { - identity = string(core.AsUser) + identity = string(identitypkg.AsUser) } - if identity != string(core.AsUser) && identity != string(core.AsBot) { + if identity != string(identitypkg.AsUser) && identity != string(identitypkg.AsBot) { return nil } @@ -130,7 +130,7 @@ func commandCatalogPath(cmd *cobra.Command) []string { func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool { authTypes := sc.AuthTypes if len(authTypes) == 0 { - authTypes = []string{string(core.AsUser)} + authTypes = []string{string(identitypkg.AsUser)} } for _, authType := range authTypes { if authType == identity { diff --git a/cmd/event/bus.go b/cmd/event/bus.go index 61d2d3c0e1..e5db061e56 100644 --- a/cmd/event/bus.go +++ b/cmd/event/bus.go @@ -14,10 +14,10 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/workspace" ) // NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go. @@ -35,7 +35,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command { } // Sanitize AppID: an unsanitized value could escape events/ via ".." or separators. - eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID)) + eventsDir := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID)) logger, err := bus.SetupBusLogger(eventsDir) if err != nil { diff --git a/cmd/event/bus_test.go b/cmd/event/bus_test.go index b974cb38a1..c7d5dcbed7 100644 --- a/cmd/event/bus_test.go +++ b/cmd/event/bus_test.go @@ -8,9 +8,10 @@ import ( "path/filepath" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) // The hidden `event _bus` daemon command must exit with a typed file_io error @@ -24,8 +25,8 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) { t.Fatal(err) } - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "cli_bus_test", AppSecret: "secret", Brand: brand.Feishu, }) cmd := NewCmdBus(f) cmd.SetArgs([]string{}) diff --git a/cmd/event/console_url.go b/cmd/event/console_url.go index efe95597c7..c891a24c5f 100644 --- a/cmd/event/console_url.go +++ b/cmd/event/console_url.go @@ -10,8 +10,9 @@ import ( "encoding/json" "fmt" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" eventlib "github.com/larksuite/cli/internal/event" + identitypkg "github.com/larksuite/cli/internal/identity" ) // Landing-page contract for the scan-to-enable deep link, verified against the @@ -67,23 +68,23 @@ func encodeAddons(a ManifestAddons) (string, error) { } // consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks. -func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) { +func consoleAddonsURL(brand brandpkg.Brand, appID string, a ManifestAddons) (string, error) { encoded, err := encodeAddons(a) if err != nil { return "", err } - host := core.ResolveEndpoints(brand).Open + host := brandpkg.ResolveEndpoints(brand).Open return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil } // consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails. -func consoleLandingURL(brand core.LarkBrand, appID string) string { - host := core.ResolveEndpoints(brand).Open +func consoleLandingURL(brand brandpkg.Brand, appID string) string { + host := brandpkg.ResolveEndpoints(brand).Open return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID) } // addonsHintURL returns the scan URL, degrading to the bare landing page on encode error. -func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string { +func addonsHintURL(brand brandpkg.Brand, appID string, a ManifestAddons) string { url, err := consoleAddonsURL(brand, appID, a) if err != nil { return consoleLandingURL(brand, appID) @@ -94,7 +95,7 @@ func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string // missingScopeAddons routes missing scopes into the identity-appropriate section. // The unused side is an empty (non-nil) slice so JSON encodes [] not null — // the addons spec treats a missing tenant/user as an empty array. -func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons { +func missingScopeAddons(identity identitypkg.Identity, missing []string) ManifestAddons { s := &AddonsScopes{Tenant: []string{}, User: []string{}} if identity.IsBot() { s.Tenant = missing @@ -106,7 +107,7 @@ func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons // missingSubscriptionAddons routes missing events/callbacks into the right section. // Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec. -func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons { +func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity identitypkg.Identity, missing []string) ManifestAddons { if subType == eventlib.SubTypeCallback { return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}} } diff --git a/cmd/event/console_url_test.go b/cmd/event/console_url_test.go index a9f3ce1eec..fad29280f9 100644 --- a/cmd/event/console_url_test.go +++ b/cmd/event/console_url_test.go @@ -12,8 +12,9 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/identity" ) func decodeAddons(t *testing.T, encoded string) ManifestAddons { @@ -55,11 +56,11 @@ func TestEncodeAddons_RoundTrip(t *testing.T) { } func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) { - url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}}) + url, err := consoleAddonsURL(brand.Feishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}}) if err != nil { t.Fatalf("url: %v", err) } - host := core.ResolveEndpoints(core.BrandFeishu).Open + host := brand.ResolveEndpoints(brand.Feishu).Open prefix := host + "/page/launcher?clientID=cli_x&addons=" if !strings.HasPrefix(url, prefix) { t.Errorf("url = %q, want prefix %q", url, prefix) @@ -71,22 +72,22 @@ func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) { } func TestMissingScopeAddons_ByIdentity(t *testing.T) { - bot := missingScopeAddons(core.AsBot, []string{"im:message"}) + bot := missingScopeAddons(identity.AsBot, []string{"im:message"}) if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 { t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes) } - user := missingScopeAddons(core.AsUser, []string{"im:message"}) + user := missingScopeAddons(identity.AsUser, []string{"im:message"}) if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 { t.Errorf("user scopes = %+v, want user-only", user.Scopes) } } func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) { - ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}) + ev := missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"}) if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 { t.Errorf("event addons = %+v, want events.items.tenant", ev.Events) } - cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"}) + cb := missingSubscriptionAddons(eventlib.SubTypeCallback, identity.AsBot, []string{"card.action.trigger"}) if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil { t.Errorf("callback addons = %+v, want callbacks.items only", cb) } @@ -96,9 +97,9 @@ func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) { // Unused identity sides must encode as [] (not null) so the launcher page's // shape validation treats them as "缺省 -> 空数组" per the addons spec. cases := []ManifestAddons{ - missingScopeAddons(core.AsBot, []string{"im:message"}), - missingScopeAddons(core.AsUser, []string{"im:message"}), - missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}), + missingScopeAddons(identity.AsBot, []string{"im:message"}), + missingScopeAddons(identity.AsUser, []string{"im:message"}), + missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"}), } for i, a := range cases { raw, err := json.Marshal(a) diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 21ded3f9cd..0fe580baaf 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -16,15 +16,16 @@ import ( "github.com/spf13/cobra" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/appmeta" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" eventlib "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/consume" "github.com/larksuite/cli/internal/event/transport" + identitypkg "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" ) @@ -118,7 +119,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu outputDir = safePath } - domain := core.ResolveEndpoints(cfg.Brand).Open + domain := brandpkg.ResolveEndpoints(cfg.Brand).Open // Surface auth errors before forking the bus daemon. if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil { @@ -131,7 +132,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu } runtime := &consumeRuntime{client: apiClient, accessIdentity: identity} // botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level. - botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot} + botRuntime := &consumeRuntime{client: apiClient, accessIdentity: identitypkg.AsBot} // Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op. preflightErrOut := f.IOStreams.ErrOut @@ -224,8 +225,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu } // resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist. -func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) { - flagAs := core.Identity(cmd.Flag("as").Value.String()) +func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (identitypkg.Identity, error) { + flagAs := identitypkg.Identity(cmd.Flag("as").Value.String()) identity := f.ResolveAs(cmd.Context(), cmd, flagAs) if len(keyDef.AuthTypes) > 0 { if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil { @@ -238,9 +239,9 @@ func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.Ke type preflightCtx struct { factory *cmdutil.Factory appID string - brand core.LarkBrand + brand brandpkg.Brand eventKey string - identity core.Identity + identity identitypkg.Identity keyDef *eventlib.KeyDefinition appVer *appmeta.AppVersion // subscribedCallbacks is the application/get 底账 for callback-type EventKeys; @@ -264,7 +265,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error { return nil } storedScopes = strings.Join(pf.appVer.TenantScopes, " ") - case pf.identity == core.AsUser: + case pf.identity == identitypkg.AsUser: result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID)) if err != nil || result == nil || result.Scopes == "" { return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error @@ -291,7 +292,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error { // the tenant token carries them. User: the scan link only updates the app // manifest — the user's own token still lacks the scopes until it is // re-authorized — so direct the user to re-login instead. -func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string { +func scopeRemediationHint(brand brandpkg.Brand, appID string, identity identitypkg.Identity, missing []string) string { if identity.IsBot() { return fmt.Sprintf("grant these scopes by scanning: %s", addonsHintURL(brand, appID, missingScopeAddons(identity, missing))) @@ -368,7 +369,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) ( if ctx == nil { ctx = context.Background() } - result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID)) + result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identitypkg.AsBot, appID)) if err != nil { if _, ok := errs.ProblemOf(err); ok { return "", err diff --git a/cmd/event/format_helpers_test.go b/cmd/event/format_helpers_test.go index 5e4117b8a6..17b22dfeb1 100644 --- a/cmd/event/format_helpers_test.go +++ b/cmd/event/format_helpers_test.go @@ -11,7 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/output" ) @@ -287,7 +287,7 @@ func errorAs(err error, target interface{}) bool { } func TestNewCmdFactories_WireFlags(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"}) t.Run("consume", func(t *testing.T) { cmd := NewCmdConsume(f) diff --git a/cmd/event/list_test.go b/cmd/event/list_test.go index 294a2d5470..678d709cc1 100644 --- a/cmd/event/list_test.go +++ b/cmd/event/list_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" eventlib "github.com/larksuite/cli/internal/event" _ "github.com/larksuite/cli/events" @@ -29,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) { } func TestRunList_TextOutput(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runList(f, false); err != nil { t.Fatalf("runList: %v", err) @@ -53,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) { } func TestRunList_JSONOutput(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runList(f, true); err != nil { t.Fatalf("runList json: %v", err) diff --git a/cmd/event/preflight_test.go b/cmd/event/preflight_test.go index bbe3a2fdcf..c26b617724 100644 --- a/cmd/event/preflight_test.go +++ b/cmd/event/preflight_test.go @@ -8,13 +8,14 @@ import ( "strings" "testing" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/appmeta" - "github.com/larksuite/cli/internal/core" eventlib "github.com/larksuite/cli/internal/event" + identitypkg "github.com/larksuite/cli/internal/identity" ) -func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx { +func newPreflightCtx(appID string, brand brandpkg.Brand, identity identitypkg.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx { key := "" if keyDef != nil { key = keyDef.Key @@ -108,7 +109,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) { Key: "im.message.text", Scopes: []string{"im:message", "im:message.group_at_msg"}, } - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)) + err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil)) if err != nil { t.Fatalf("bot + nil appVer should skip, got: %v", err) } @@ -124,7 +125,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) { "im:message.group_at_msg", "contact:user:readonly", }} - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) + err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer)) if err != nil { t.Fatalf("all scopes granted, unexpected error: %v", err) } @@ -136,7 +137,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) { Scopes: []string{"im:message", "im:message.group_at_msg"}, } appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}} - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) + err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer)) if err == nil { t.Fatal("expected error for missing scope") } @@ -169,7 +170,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) { func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) { def := &eventlib.KeyDefinition{Key: "x"} - if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil { + if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil)); err != nil { t.Fatalf("no required scopes means nothing to verify, got: %v", err) } } @@ -177,9 +178,9 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) { func TestPreflightEventTypes_CallbackMissing(t *testing.T) { pf := &preflightCtx{ appID: "cli_x", - brand: core.BrandFeishu, + brand: brandpkg.Feishu, eventKey: "test.cb", - identity: core.AsBot, + identity: identitypkg.AsBot, subscribedCallbacks: []string{"profile.view.get"}, keyDef: &eventlib.KeyDefinition{ Key: "test.cb", @@ -206,9 +207,9 @@ func TestPreflightEventTypes_CallbackMissing(t *testing.T) { func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) { pf := &preflightCtx{ appID: "cli_x", - brand: core.BrandFeishu, + brand: brandpkg.Feishu, eventKey: "test.cb", - identity: core.AsBot, + identity: identitypkg.AsBot, subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过 keyDef: &eventlib.KeyDefinition{ Key: "test.cb", @@ -227,9 +228,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) { // not skipped as a weak dependency. pf := &preflightCtx{ appID: "cli_x", - brand: core.BrandFeishu, + brand: brandpkg.Feishu, eventKey: "test.cb", - identity: core.AsBot, + identity: identitypkg.AsBot, subscribedCallbacks: []string{}, // fetched, none subscribed keyDef: &eventlib.KeyDefinition{ Key: "test.cb", @@ -249,9 +250,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) { func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) { pf := &preflightCtx{ appID: "cli_x", - brand: core.BrandFeishu, + brand: brandpkg.Feishu, eventKey: "test.cb", - identity: core.AsBot, + identity: identitypkg.AsBot, subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"}, keyDef: &eventlib.KeyDefinition{ Key: "test.cb", @@ -266,12 +267,12 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) { func TestScopeRemediationHint_ByIdentity(t *testing.T) { // bot: scan-to-enable link (adds scopes to app manifest) - bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"}) + bot := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsBot, []string{"im:message"}) if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") { t.Errorf("bot hint should give the scan link, got: %s", bot) } // user: re-login (scan link cannot grant scopes to the user's own token) - user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"}) + user := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsUser, []string{"im:message"}) if !strings.Contains(user, "auth login --scope") { t.Errorf("user hint should direct to auth login, got: %s", user) } diff --git a/cmd/event/runtime.go b/cmd/event/runtime.go index 3f03f4073c..4902e59b04 100644 --- a/cmd/event/runtime.go +++ b/cmd/event/runtime.go @@ -9,13 +9,13 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" ) // consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity. type consumeRuntime struct { client *client.APIClient - accessIdentity core.Identity + accessIdentity identity.Identity } func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) { diff --git a/cmd/event/runtime_test.go b/cmd/event/runtime_test.go index 67f2bea1c2..46bb30e984 100644 --- a/cmd/event/runtime_test.go +++ b/cmd/event/runtime_test.go @@ -14,10 +14,12 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/identity" ) // staticTokenResolver always returns a fixed token without any HTTP calls. @@ -45,9 +47,9 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime { SDK: sdk, ErrOut: io.Discard, Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil), - Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu}, }, - accessIdentity: core.AsBot, + accessIdentity: identity.AsBot, } } diff --git a/cmd/event/schema_test.go b/cmd/event/schema_test.go index 9d0acf82ac..c2562171dd 100644 --- a/cmd/event/schema_test.go +++ b/cmd/event/schema_test.go @@ -12,7 +12,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" eventlib "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/schemas" @@ -43,7 +43,7 @@ type approvalSchemaJSONProperty struct { } func TestRunSchema_ProcessedKey_Text(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, "im.message.receive_v1", false); err != nil { t.Fatalf("runSchema: %v", err) @@ -63,7 +63,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) { } func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, "im.message.message_read_v1", false); err != nil { t.Fatalf("runSchema: %v", err) @@ -83,7 +83,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) { } func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) err := runSchema(f, "im.message.recieve_v1", false) if err == nil { @@ -99,7 +99,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) { } func TestRunSchema_JSONOutput(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, "im.message.receive_v1", true); err != nil { t.Fatalf("runSchema json: %v", err) @@ -120,7 +120,7 @@ func TestRunSchema_JSONOutput(t *testing.T) { } func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, "im.message.receive_v1", true); err != nil { t.Fatalf("runSchema json: %v", err) @@ -154,7 +154,7 @@ func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) { } func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil { t.Fatalf("runSchema json: %v", err) @@ -193,7 +193,7 @@ func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) { for _, tc := range tests { t.Run(tc.key, func(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, tc.key, true); err != nil { t.Fatalf("runSchema json: %v", err) @@ -241,7 +241,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) { "vc.meeting.participant_meeting_joined_v1", } { t.Run(key, func(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, key, true); err != nil { t.Fatalf("runSchema json: %v", err) @@ -288,7 +288,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}}, }) - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, syntheticKey, false); err != nil { t.Fatalf("runSchema: %v", err) } @@ -334,7 +334,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}}, }) - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"}) if err := runSchema(f, syntheticKey, true); err != nil { t.Fatalf("runSchema json: %v", err) } diff --git a/cmd/global_flags.go b/cmd/global_flags.go index b77e8f189d..c5acd29725 100644 --- a/cmd/global_flags.go +++ b/cmd/global_flags.go @@ -4,7 +4,7 @@ package cmd import ( - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/spf13/pflag" ) @@ -32,7 +32,7 @@ func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) { // until at least two profiles exist. Intended for the Execute entry point — // buildInternal must not call this directly to stay state-free. func isSingleAppMode() bool { - raw, err := core.LoadMultiAppConfig() + raw, err := configpkg.LoadMultiAppConfig() if err != nil || raw == nil { return true } diff --git a/cmd/global_flags_test.go b/cmd/global_flags_test.go index 67ee198394..b286762e66 100644 --- a/cmd/global_flags_test.go +++ b/cmd/global_flags_test.go @@ -8,8 +8,10 @@ import ( "os" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/secret" "github.com/spf13/pflag" ) @@ -58,8 +60,8 @@ func TestIsSingleAppMode_NoConfig(t *testing.T) { func TestIsSingleAppMode_SingleApp(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - saveAppsForTest(t, []core.AppConfig{ - {Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu}, + saveAppsForTest(t, []configpkg.AppConfig{ + {Name: "default", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu}, }) if !isSingleAppMode() { t.Fatal("isSingleAppMode() = false, want true for single-app config") @@ -68,9 +70,9 @@ func TestIsSingleAppMode_SingleApp(t *testing.T) { func TestIsSingleAppMode_MultiApp(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - saveAppsForTest(t, []core.AppConfig{ - {Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu}, - {Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu}, + saveAppsForTest(t, []configpkg.AppConfig{ + {Name: "a", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu}, + {Name: "b", AppId: "cli_b", AppSecret: secret.PlainSecret("y"), Brand: brand.Feishu}, }) if isSingleAppMode() { t.Fatal("isSingleAppMode() = true, want false for multi-app config") @@ -101,10 +103,10 @@ func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) { } } -func saveAppsForTest(t *testing.T, apps []core.AppConfig) { +func saveAppsForTest(t *testing.T, apps []configpkg.AppConfig) { t.Helper() - multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps} - if err := core.SaveMultiAppConfig(multi); err != nil { + multi := &configpkg.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps} + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } } diff --git a/cmd/platform_bootstrap.go b/cmd/platform_bootstrap.go index 9b1b57ac35..e8f3209be5 100644 --- a/cmd/platform_bootstrap.go +++ b/cmd/platform_bootstrap.go @@ -14,10 +14,10 @@ import ( "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/cmdpolicy" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/hook" internalplatform "github.com/larksuite/cli/internal/platform" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) // userPolicyFileName is the conventional filename for the user-layer Rule. @@ -261,7 +261,7 @@ func splitCSV(s string) []string { // userPolicyPath returns the path of /policy.yml. // // The base directory honours LARKSUITE_CLI_CONFIG_DIR (via -// core.GetBaseConfigDir) so that test isolation, container deployments +// workspace.GetBaseConfigDir) so that test isolation, container deployments // and per-Agent config overrides all see a consistent policy location. // Using vfs.UserHomeDir directly here would silently bypass the env // override and route every test through the real ~/.lark-cli. @@ -271,7 +271,7 @@ func splitCSV(s string) []string { // the home dir can't be resolved, and the resolver already treats a // missing file as "no policy". func userPolicyPath() (string, error) { - return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil + return filepath.Join(workspace.GetBaseConfigDir(), userPolicyFileName), nil } // warnPolicyError writes a one-line stderr warning when the user policy diff --git a/cmd/profile/add.go b/cmd/profile/add.go index d384c5ba1f..6a256bda9b 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -12,11 +12,13 @@ import ( "github.com/spf13/cobra" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" + secretpkg "github.com/larksuite/cli/internal/secret" ) // NewCmdProfileAdd creates the profile add subcommand. @@ -53,7 +55,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command { } func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error { - if err := core.ValidateProfileName(name); err != nil { + if err := configpkg.ValidateProfileName(name); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err). WithCause(err). WithParam("--name") @@ -90,12 +92,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, } // Load or create config - multi, err := core.LoadMultiAppConfig() + multi, err := configpkg.LoadMultiAppConfig() if err != nil { if !errors.Is(err, os.ErrNotExist) { return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err) } - multi = &core.MultiAppConfig{} + multi = &configpkg.MultiAppConfig{} } // Check name uniqueness @@ -115,12 +117,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, } // Store secret securely - secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain) + secret, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), f.Keychain) if err != nil { return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err) } - parsedBrand := core.ParseBrand(brand) + parsedBrand := brandpkg.ParseBrand(brand) // Capture current profile before appending (avoid setting PreviousApp to self) var previousName string @@ -131,13 +133,13 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, } // Append profile - multi.Apps = append(multi.Apps, core.AppConfig{ + multi.Apps = append(multi.Apps, configpkg.AppConfig{ Name: name, AppId: appID, AppSecret: secret, Brand: parsedBrand, Lang: i18n.Lang(lang), - Users: []core.AppUser{}, + Users: []configpkg.AppUser{}, }) if useAfter { @@ -147,7 +149,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, multi.CurrentApp = name } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } diff --git a/cmd/profile/list.go b/cmd/profile/list.go index 5b0234c2c9..617f3433dc 100644 --- a/cmd/profile/list.go +++ b/cmd/profile/list.go @@ -9,21 +9,22 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" ) // profileListItem is the JSON output for a single profile entry. type profileListItem struct { - Name string `json:"name"` - AppID string `json:"appId"` - Brand core.LarkBrand `json:"brand"` - Active bool `json:"active"` - User string `json:"user,omitempty"` - TokenStatus string `json:"tokenStatus,omitempty"` + Name string `json:"name"` + AppID string `json:"appId"` + Brand brand.Brand `json:"brand"` + Active bool `json:"active"` + User string `json:"user,omitempty"` + TokenStatus string `json:"tokenStatus,omitempty"` } // NewCmdProfileList creates the profile list subcommand. @@ -40,7 +41,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command { } func profileListRun(f *cmdutil.Factory) error { - multi, err := core.LoadMultiAppConfig() + multi, err := configpkg.LoadMultiAppConfig() if err != nil { if errors.Is(err, os.ErrNotExist) { output.PrintJson(f.IOStreams.Out, []profileListItem{}) diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 472a803bf6..e4f578b8e3 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -11,11 +11,13 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" "github.com/larksuite/cli/internal/vfs" ) @@ -75,7 +77,7 @@ func TestProfileAddRun_Lang(t *testing.T) { if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil { t.Fatalf("--lang %q: profileAddRun() error = %v", in, err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -92,7 +94,7 @@ func TestProfileAddRun_Lang(t *testing.T) { if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil { t.Fatalf("profileAddRun() error = %v", err) } - saved, _ := core.LoadMultiAppConfig() + saved, _ := configpkg.LoadMultiAppConfig() if app := saved.FindApp("p"); app == nil || app.Lang != "" { t.Errorf("stored Lang = %v, want \"\" (unset)", app) } @@ -115,13 +117,13 @@ func TestProfileAddRun_Lang(t *testing.T) { func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ - {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, + Apps: []configpkg.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -132,7 +134,7 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) { t.Fatalf("profileAddRun() error = %v", err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -149,15 +151,15 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) { func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "target", PreviousApp: "default", - Apps: []core.AppConfig{ - {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, - {Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark}, + Apps: []configpkg.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu}, + {Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -166,7 +168,7 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te t.Fatalf("profileRemoveRun() error = %v", err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -183,17 +185,17 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "old", PreviousApp: "old", - Apps: []core.AppConfig{{ + Apps: []configpkg.AppConfig{{ Name: "old", AppId: "app-old", - AppSecret: core.PlainSecret("secret-old"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-old"), + Brand: brand.Feishu, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -202,7 +204,7 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) { t.Fatalf("profileRenameRun() error = %v", err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -219,17 +221,17 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) { func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "old", PreviousApp: "old", - Apps: []core.AppConfig{{ + Apps: []configpkg.AppConfig{{ Name: "old", AppId: "app-old", - AppSecret: core.PlainSecret("secret-old"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-old"), + Brand: brand.Feishu, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -238,7 +240,7 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) { t.Fatalf("profileRenameRun() error = %v", err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -255,15 +257,15 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) { func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", PreviousApp: "target", - Apps: []core.AppConfig{ - {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, - {Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark}, + Apps: []configpkg.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu}, + {Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -272,7 +274,7 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) { t.Fatalf("profileUseRun() error = %v", err) } - saved, err := core.LoadMultiAppConfig() + saved, err := configpkg.LoadMultiAppConfig() if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } @@ -286,14 +288,14 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) { func TestProfileListRun_OutputsProfiles(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ - {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, - {Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark}, + Apps: []configpkg.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu}, + {Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -339,14 +341,14 @@ func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) { func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "target", - Apps: []core.AppConfig{ - {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, - {Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark}, + Apps: []configpkg.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu}, + {Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -364,16 +366,16 @@ func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) { func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "old", - Apps: []core.AppConfig{{ + Apps: []configpkg.AppConfig{{ Name: "old", AppId: "app-old", - AppSecret: core.PlainSecret("secret-old"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-old"), + Brand: brand.Feishu, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -391,14 +393,14 @@ func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) { func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ - {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, - {Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark}, + Apps: []configpkg.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu}, + {Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -461,14 +463,14 @@ func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wa func saveTwoProfiles(t *testing.T) { t.Helper() - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ - {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, - {Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark}, + Apps: []configpkg.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu}, + {Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } } @@ -609,13 +611,13 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) { t.Run("cannot remove the only profile", func(t *testing.T) { setupProfileConfigDir(t) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "solo", - Apps: []core.AppConfig{ - {Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu}, + Apps: []configpkg.AppConfig{ + {Name: "solo", AppId: "app-solo", AppSecret: secret.PlainSecret("secret-solo"), Brand: brand.Feishu}, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } f, _, _, _ := cmdutil.TestFactory(t, nil) diff --git a/cmd/profile/remove.go b/cmd/profile/remove.go index ff249c3d5f..033860df13 100644 --- a/cmd/profile/remove.go +++ b/cmd/profile/remove.go @@ -12,8 +12,9 @@ import ( "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" ) // NewCmdProfileRemove creates the profile remove subcommand. @@ -34,7 +35,7 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command { } func profileRemoveRun(f *cmdutil.Factory, name string) error { - multi, err := core.LoadOrNotConfigured() + multi, err := configpkg.LoadOrNotConfigured() if err != nil { return err } @@ -66,12 +67,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error { multi.PreviousApp = "" } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } // Best-effort credential cleanup after config commit - core.RemoveSecretStore(appSecret, f.Keychain) + secret.RemoveSecretStore(appSecret, f.Keychain) for _, user := range users { larkauth.RemoveStoredToken(appId, user.UserOpenId) } diff --git a/cmd/profile/rename.go b/cmd/profile/rename.go index 9506870f3b..7de7d1b22e 100644 --- a/cmd/profile/rename.go +++ b/cmd/profile/rename.go @@ -11,7 +11,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" ) @@ -30,11 +30,11 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command { } func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error { - if err := core.ValidateProfileName(newName); err != nil { + if err := configpkg.ValidateProfileName(newName); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err) } - multi, err := core.LoadOrNotConfigured() + multi, err := configpkg.LoadOrNotConfigured() if err != nil { return err } @@ -67,7 +67,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error { multi.PreviousApp = newName } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } diff --git a/cmd/profile/use.go b/cmd/profile/use.go index 7080d52775..608c63225f 100644 --- a/cmd/profile/use.go +++ b/cmd/profile/use.go @@ -11,7 +11,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" ) @@ -33,7 +33,7 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command { } func profileUseRun(f *cmdutil.Factory, name string) error { - multi, err := core.LoadOrNotConfigured() + multi, err := configpkg.LoadOrNotConfigured() if err != nil { return err } @@ -67,7 +67,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error { } multi.CurrentApp = targetName - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } diff --git a/cmd/prune.go b/cmd/prune.go index 49979423f0..6e612ff1de 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -12,11 +12,11 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" ) // pruneForStrictMode removes commands incompatible with the active strict mode. -func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) { +func pruneForStrictMode(root *cobra.Command, mode identity.StrictMode) { pruneIncompatible(root, mode) pruneEmpty(root) } @@ -25,7 +25,7 @@ func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) { // identities incompatible with the forced identity. Commands without annotation are kept. // Hidden stubs preserve direct execution so users get a strict-mode error instead // of Cobra's generic "unknown flag" fallback from the parent command. -func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) { +func pruneIncompatible(parent *cobra.Command, mode identity.StrictMode) { forced := string(mode.ForcedIdentity()) var toRemove []*cobra.Command var toAdd []*cobra.Command @@ -44,7 +44,7 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) { } } -func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command { +func strictModeStubFrom(child *cobra.Command, mode identity.StrictMode) *cobra.Command { // The denial annotations let the hook layer's populateInvocationDenial // recognise this command as denied, so the Wrap chain is physically // isolated (wrapRunE takes the DeniedByPolicy branch and calls the diff --git a/cmd/prune_test.go b/cmd/prune_test.go index aee11177b2..501a13a6ae 100644 --- a/cmd/prune_test.go +++ b/cmd/prune_test.go @@ -12,7 +12,7 @@ import ( "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -75,7 +75,7 @@ func findCmd(root *cobra.Command, names ...string) *cobra.Command { func TestPruneForStrictMode_Bot(t *testing.T) { root := newTestTree() - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) if cmd := findCmd(root, "im", "+search"); cmd == nil || !cmd.Hidden { t.Error("+search (user-only) should be replaced by a hidden stub in bot mode") @@ -99,7 +99,7 @@ func TestPruneForStrictMode_Bot(t *testing.T) { func TestPruneForStrictMode_User(t *testing.T) { root := newTestTree() - pruneForStrictMode(root, core.StrictModeUser) + pruneForStrictMode(root, identity.StrictModeUser) if findCmd(root, "im", "+search") == nil { t.Error("+search (user-only) should be kept in user mode") @@ -117,7 +117,7 @@ func TestPruneForStrictMode_User(t *testing.T) { func TestPruneEmpty(t *testing.T) { root := newTestTree() - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden { t.Error("resource 'messages' should be kept hidden when only hidden stubs remain") @@ -144,7 +144,7 @@ func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T root := newTestTree() root.SilenceErrors = true root.SilenceUsage = true - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) root.SetArgs([]string{"im", "+search", "--query", "hello"}) err := root.Execute() @@ -160,7 +160,7 @@ func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testi root := newTestTree() root.SilenceErrors = true root.SilenceUsage = true - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) root.SetArgs([]string{"im", "messages", "search", "--query", "hello"}) err := root.Execute() @@ -176,7 +176,7 @@ func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) { root := newTestTree() root.SilenceErrors = true root.SilenceUsage = true - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"}) err := root.Execute() @@ -192,7 +192,7 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T root := newTestTree() root.SilenceErrors = true root.SilenceUsage = true - pruneForStrictMode(root, core.StrictModeUser) + pruneForStrictMode(root, identity.StrictModeUser) root.SetArgs([]string{"im", "+subscribe", "--topic", "x"}) err := root.Execute() @@ -215,7 +215,7 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T // stops at the stub and proceeds to its RunE. func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) { root := newTestTree() - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) stub := findCmd(root, "auth", "login") if stub == nil { t.Fatal("auth/login stub should exist after StrictModeBot") @@ -235,7 +235,7 @@ func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) { // stub's RunE. func TestStrictModeStub_BypassesArgsValidator(t *testing.T) { root := newTestTree() - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) stub := findCmd(root, "auth", "login") if stub == nil { t.Fatal("auth/login stub should exist after StrictModeBot") @@ -256,7 +256,7 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) { // still inspect the structured denial taxonomy via errors.As. func TestStrictModeStub_StructuredEnvelope(t *testing.T) { root := newTestTree() - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) stub := findCmd(root, "im", "+search") if stub == nil { t.Fatalf("expected im/+search stub") @@ -318,7 +318,7 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) { // and silently return nil, swallowing the strict-mode error. func TestStrictModeStub_HasDenialAnnotation(t *testing.T) { root := newTestTree() - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) // im/+search is user-only -> replaced by a stub in StrictModeBot. stub := findCmd(root, "im", "+search") @@ -356,7 +356,7 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) { cmdutil.SetRisk(userOnly, "read") svc.AddCommand(userOnly) - pruneForStrictMode(root, core.StrictModeBot) + pruneForStrictMode(root, identity.StrictModeBot) stub := findCmd(root, "im", "+search") if stub == nil { diff --git a/cmd/root.go b/cmd/root.go index 7e8244fd35..f0387813e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -237,7 +237,7 @@ func configureFlagCompletions(args []string) { // render via the typed envelope writer, which lifts extension fields // (missing_scopes, console_url, challenge_url, ...) to the top level. // Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are -// constructed typed at their origin (internal/auth, internal/core), so the +// constructed typed at their origin (internal/auth, internal/config), so the // dispatcher no longer promotes any legacy shape here. // 2. PartialFailure / BareError signals: the result envelope is already on // stdout; honor the exit code and write nothing to stderr. diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 11a6b24c84..71f1f7934b 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -11,17 +11,20 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/service" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/envvars" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/secret" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/shortcuts" @@ -155,37 +158,37 @@ func strictModeFixtureCatalog() apicatalog.Catalog { }) } -func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { +func newStrictModeDefaultFactory(t *testing.T, profile string, mode identity.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() - t.Setenv(envvars.CliAppID, "") - t.Setenv(envvars.CliAppSecret, "") - t.Setenv(envvars.CliUserAccessToken, "") - t.Setenv(envvars.CliTenantAccessToken, "") - t.Setenv(envvars.CliDefaultAs, "") + t.Setenv(envnames.CliAppID, "") + t.Setenv(envnames.CliAppSecret, "") + t.Setenv(envnames.CliUserAccessToken, "") + t.Setenv(envnames.CliTenantAccessToken, "") + t.Setenv(envnames.CliDefaultAs, "") dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) targetMode := mode - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "default", AppId: "app-default", - AppSecret: core.PlainSecret("secret-default"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-default"), + Brand: brand.Feishu, }, { Name: "target", AppId: "app-target", - AppSecret: core.PlainSecret("secret-target"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-target"), + Brand: brand.Feishu, StrictMode: &targetMode, }, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } @@ -206,7 +209,7 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) { // --- service command --- func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) { - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"}) @@ -238,7 +241,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testin } func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) { - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ @@ -315,7 +318,7 @@ func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMes } func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) { - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ @@ -335,7 +338,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) { // +chat-create supports both user and bot identities, so strict mode user // should allow it and force user identity. - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ @@ -352,7 +355,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t * } func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) { - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ @@ -370,7 +373,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn } func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) { - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot) catalog := strictModeFixtureCatalog() rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog) @@ -389,7 +392,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv } func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) { - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser) catalog := strictModeFixtureCatalog() rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog) @@ -408,7 +411,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE } func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) { - f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) + f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot) rootCmd := buildStrictModeIntegrationRootCmd(t, f) code := executeRootIntegration(t, f, rootCmd, []string{ @@ -428,8 +431,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop // --- shortcut command --- func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) { - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu, + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "e2e-sc-err", AppSecret: "secret", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ URL: "/open-apis/im/v1/messages", diff --git a/cmd/root_test.go b/cmd/root_test.go index e7f3f5e738..fe00d679d4 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/auth" cmdconfig "github.com/larksuite/cli/cmd/config" @@ -20,8 +21,9 @@ import ( "github.com/larksuite/cli/errs" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/deprecation" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" ) @@ -305,7 +307,7 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) { // TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression // baseline for auth/config errors: it pins the typed envelope and exit code the // dispatcher produces for the two source-of-truth shapes, which are constructed -// typed at their origin in internal/auth and internal/core. +// typed at their origin in internal/auth and internal/configpkg. func TestHandleRootError_AuthConfigWireGolden(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) @@ -345,7 +347,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) { errOut := &bytes.Buffer{} f.IOStreams.ErrOut = errOut - exit := handleRootError(f, core.NotConfiguredError()) + exit := handleRootError(f, configpkg.NotConfiguredError()) if exit != int(output.ExitAuth) { t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth)) } @@ -512,10 +514,10 @@ func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) { func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) - f.ResolvedIdentity = core.AsUser + f.ResolvedIdentity = identity.AsUser var target registry.CommandEntry for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") { @@ -560,10 +562,10 @@ func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *tes func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) - f.ResolvedIdentity = core.AsUser + f.ResolvedIdentity = identity.AsUser root := &cobra.Command{Use: "lark-cli"} serviceCmd := &cobra.Command{Use: "docs"} @@ -585,10 +587,10 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) - f.ResolvedIdentity = core.AsUser + f.ResolvedIdentity = identity.AsUser root := &cobra.Command{Use: "lark-cli"} serviceCmd := &cobra.Command{Use: "drive"} @@ -611,10 +613,10 @@ func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) - f.ResolvedIdentity = core.AsUser + f.ResolvedIdentity = identity.AsUser root := &cobra.Command{Use: "lark-cli"} serviceCmd := &cobra.Command{Use: "docs"} diff --git a/cmd/root_upgrade_test.go b/cmd/root_upgrade_test.go index bc28b858ff..a085706bc6 100644 --- a/cmd/root_upgrade_test.go +++ b/cmd/root_upgrade_test.go @@ -14,7 +14,7 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/workspace" "github.com/spf13/cobra" ) @@ -68,9 +68,9 @@ func TestOfferRootUpgrade(t *testing.T) { // workspace detection; pin the process-global workspace to Local so // statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale // subdir inherited from a prior test in the package. - origWS := core.CurrentWorkspace() - t.Cleanup(func() { core.SetCurrentWorkspace(origWS) }) - core.SetCurrentWorkspace(core.WorkspaceLocal) + origWS := workspace.CurrentWorkspace() + t.Cleanup(func() { workspace.SetCurrentWorkspace(origWS) }) + workspace.SetCurrentWorkspace(workspace.WorkspaceLocal) cases := []struct { name string diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 3b27967e08..8859618d97 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -12,7 +12,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/schema" @@ -91,7 +91,7 @@ func schemaRun(opts *SchemaOptions) error { // schema owns rendering (Envelope/Envelopes); this adapter only chooses the // output shape — a single resolved method renders as one envelope object, // anything broader as an array — and maps resolve failures to hints. -func runSchema(out io.Writer, parts []string, mode core.StrictMode) error { +func runSchema(out io.Writer, parts []string, mode identity.StrictMode) error { catalog := registry.SchemaCatalog() if len(catalog.Services()) == 0 { // No embedded metadata and the runtime fallback is empty too: offline diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index 7e1762c8e4..7f09337b38 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -9,9 +9,10 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) func TestSchemaCmd_FlagParsing(t *testing.T) { @@ -198,8 +199,8 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) { } func TestSchemaCmd_UnknownService(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := NewCmdSchema(f, nil) @@ -227,8 +228,8 @@ func TestSchemaCmd_UnknownService(t *testing.T) { // JSON-mode unknown-method path: *errs.ValidationError with // subtype invalid_argument and a hint listing the available methods. func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := NewCmdSchema(f, nil) diff --git a/cmd/service/service.go b/cmd/service/service.go index 813b9c5f7a..a559acf8b7 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -16,9 +16,10 @@ import ( "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/errclass" + identitypkg "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" @@ -134,7 +135,7 @@ type ServiceMethodOptions struct { // Flags Params string Data string - As core.Identity + As identitypkg.Identity Output string PageAll bool PageLimit int @@ -267,7 +268,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm RunE: func(cmd *cobra.Command, args []string) error { opts.Cmd = cmd opts.Ctx = cmd.Context() - opts.As = core.Identity(asStr) + opts.As = identitypkg.Identity(asStr) if runF != nil { return runF(opts) } @@ -370,7 +371,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { return err } - // Check if this API method supports the resolved identity. + // Check if this API method supports the resolved identitypkg. if opts.Method.RestrictsIdentity() { if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil { return err @@ -453,7 +454,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { } // checkServiceScopes pre-checks user scopes before making the API call. -func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error { +func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity identitypkg.Identity, config *configpkg.CliConfig, method meta.Method) error { if ctx.Err() != nil { return ctx.Err() } @@ -667,7 +668,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd return request, nil, nil } -func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error { +func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *ServiceMethodOptions) error { return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts)) } @@ -682,7 +683,7 @@ func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) } } -func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error { +func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, identitypkg.Identity) error) error { if pagOpts.Identity == "" { pagOpts.Identity = request.As } diff --git a/cmd/service/service_paginate_test.go b/cmd/service/service_paginate_test.go index 62f77b7c42..10e379aa64 100644 --- a/cmd/service/service_paginate_test.go +++ b/cmd/service/service_paginate_test.go @@ -13,11 +13,13 @@ import ( "net/http" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) @@ -44,10 +46,10 @@ func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buff output.PendingNotice = nil t.Cleanup(func() { output.PendingNotice = previousNotice }) - config := &core.CliConfig{ + config := &configpkg.CliConfig{ AppID: "test-app", AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, } f, out, errOut, reg := cmdutil.TestFactory(t, config) ac, err := f.NewAPIClientWithConfig(config) @@ -62,7 +64,7 @@ func servicePaginateRequest() client.RawApiRequest { return client.RawApiRequest{ Method: "GET", URL: "/open-apis/test/v1/items", - As: core.AsBot, + As: identity.AsBot, } } diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 79df1e6c5a..730812718e 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -15,19 +15,21 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcs "github.com/larksuite/cli/extension/contentsafety" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/meta" "github.com/spf13/cobra" ) // ── helpers ── -var testConfig = &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +var testConfig = &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, } func driveSpec() meta.Service { @@ -131,8 +133,8 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) { } func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2, }) cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil) @@ -193,7 +195,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) { if captured == nil { t.Fatal("runF was not called") } - if captured.As != core.AsBot { + if captured.As != identity.AsBot { t.Errorf("expected As=bot, got %s", captured.As) } if captured.SchemaPath != "drive.files.list" { @@ -463,8 +465,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) { } func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-page", AppSecret: "test-secret-page", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -531,8 +533,8 @@ func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) { extcs.Register(provider) t.Cleanup(func() { extcs.Register(nil) }) - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -585,8 +587,8 @@ func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) { extcs.Register(provider) t.Cleanup(func() { extcs.Register(nil) }) - f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu, + f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -633,8 +635,8 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) { extcs.Register(provider) t.Cleanup(func() { extcs.Register(nil) }) - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -689,8 +691,8 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) { } func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -720,8 +722,8 @@ func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *t } func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -750,8 +752,8 @@ func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing } func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -796,8 +798,8 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) } func TestServiceMethod_UnknownFormat_Warning(t *testing.T) { - f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu, + f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -880,8 +882,8 @@ func TestServiceMethod_JqAndOutputConflict(t *testing.T) { } func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -951,8 +953,8 @@ func TestServiceMethod_JqInvalidExpression(t *testing.T) { } func TestServiceMethod_PageAll_WithJq(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ @@ -984,8 +986,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) { } func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu, + f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: brand.Feishu, }) reg.Register(&httpmock.Stub{ diff --git a/cmd/startup_brand.go b/cmd/startup_brand.go index de73ff9b22..a7a0d9e5c5 100644 --- a/cmd/startup_brand.go +++ b/cmd/startup_brand.go @@ -6,8 +6,9 @@ package cmd import ( "os" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/envnames" + configpkg "github.com/larksuite/cli/internal/config" ) // ResolveStartupBrand resolves the brand before the command tree is built, so @@ -15,14 +16,14 @@ import ( // first catalog access. It mirrors the credential chain's brand precedence — // environment, then the active profile's raw config entry — without touching // the keychain (no secrets are needed to know the brand). -func ResolveStartupBrand(profile string) core.LarkBrand { - if raw := os.Getenv(envvars.CliBrand); raw != "" { - return core.ParseBrand(raw) +func ResolveStartupBrand(profile string) brand.Brand { + if raw := os.Getenv(envnames.CliBrand); raw != "" { + return brand.ParseBrand(raw) } - if cfg, err := core.LoadMultiAppConfig(); err == nil { + if cfg, err := configpkg.LoadMultiAppConfig(); err == nil { if app := cfg.CurrentAppConfig(profile); app != nil { - return core.ParseBrand(string(app.Brand)) + return brand.ParseBrand(string(app.Brand)) } } - return core.BrandFeishu + return brand.Feishu } diff --git a/cmd/startup_brand_test.go b/cmd/startup_brand_test.go index 4458db0395..ce2dc69e40 100644 --- a/cmd/startup_brand_test.go +++ b/cmd/startup_brand_test.go @@ -14,8 +14,8 @@ import ( "testing" "github.com/google/uuid" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/registry" ) @@ -48,7 +48,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) { os.Unsetenv("LARKSUITE_CLI_BRAND") // No config at all → default brand. - if got := ResolveStartupBrand(""); got != core.BrandFeishu { + if got := ResolveStartupBrand(""); got != brand.Feishu { t.Errorf("empty state brand = %q, want feishu", got) } @@ -59,16 +59,16 @@ func TestResolveStartupBrand_Precedence(t *testing.T) { if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil { t.Fatal(err) } - if got := ResolveStartupBrand(""); got != core.BrandFeishu { + if got := ResolveStartupBrand(""); got != brand.Feishu { t.Errorf("default profile brand = %q, want feishu", got) } - if got := ResolveStartupBrand("lark-prof"); got != core.BrandLark { + if got := ResolveStartupBrand("lark-prof"); got != brand.Lark { t.Errorf("lark profile brand = %q, want lark (normalized)", got) } // Environment wins over the config file. t.Setenv("LARKSUITE_CLI_BRAND", "lark") - if got := ResolveStartupBrand(""); got != core.BrandLark { + if got := ResolveStartupBrand(""); got != brand.Lark { t.Errorf("env brand = %q, want lark", got) } } diff --git a/cmd/update/update.go b/cmd/update/update.go index 750941f9e8..417c8f4670 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -11,10 +11,11 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" @@ -175,17 +176,17 @@ func updateRun(opts *UpdateOptions) error { // resolveSkillsBrand returns the skills-source brand: resolved config first, // then the active profile's raw config entry (the brand is not a secret; a // locked keychain must not flip the source), then the default with a notice. -func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) core.LarkBrand { +func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) brand.Brand { if cfg, err := f.Config(); err == nil && cfg != nil { - return core.ParseBrand(string(cfg.Brand)) + return brand.ParseBrand(string(cfg.Brand)) } - if raw, err := core.LoadMultiAppConfig(); err == nil { + if raw, err := configpkg.LoadMultiAppConfig(); err == nil { if app := raw.CurrentAppConfig(f.Invocation.Profile); app != nil { - return core.ParseBrand(string(app.Brand)) + return brand.ParseBrand(string(app.Brand)) } } fmt.Fprintf(errOut, "note: could not resolve the configured brand; syncing skills from the default source\n") - return core.BrandFeishu + return brand.Feishu } // --- Output helpers --- diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 3df74b040b..0f9a0d2268 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -16,9 +16,10 @@ import ( "testing" "time" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" @@ -29,7 +30,7 @@ const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS" // newTestFactory creates a test factory with minimal config. func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() - f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{}) return f, stdout, stderr } @@ -1864,10 +1865,10 @@ func containsString(values []string, target string) bool { func TestResolveSkillsBrand_LayeredFallback(t *testing.T) { // Layer 1: resolved config wins. var errBuf bytes.Buffer - f := &cmdutil.Factory{Config: func() (*core.CliConfig, error) { - return &core.CliConfig{Brand: core.LarkBrand(" LARK ")}, nil + f := &cmdutil.Factory{Config: func() (*configpkg.CliConfig, error) { + return &configpkg.CliConfig{Brand: brand.Brand(" LARK ")}, nil }} - if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark { + if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark { t.Errorf("resolved-config brand = %q, want lark", got) } @@ -1879,9 +1880,9 @@ func TestResolveSkillsBrand_LayeredFallback(t *testing.T) { if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil { t.Fatal(err) } - f = &cmdutil.Factory{Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") }} + f = &cmdutil.Factory{Config: func() (*configpkg.CliConfig, error) { return nil, errors.New("keychain locked") }} errBuf.Reset() - if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark { + if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark { t.Errorf("raw-config brand = %q, want lark", got) } if errBuf.Len() != 0 { @@ -1891,7 +1892,7 @@ func TestResolveSkillsBrand_LayeredFallback(t *testing.T) { // Layer 3: nothing readable → default brand with a notice. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) errBuf.Reset() - if got := resolveSkillsBrand(f, &errBuf); got != core.BrandFeishu { + if got := resolveSkillsBrand(f, &errBuf); got != brand.Feishu { t.Errorf("fallback brand = %q, want feishu", got) } if !strings.Contains(errBuf.String(), "could not resolve the configured brand") { @@ -1911,10 +1912,10 @@ func TestResolveSkillsBrand_RespectsActiveProfile(t *testing.T) { } f := &cmdutil.Factory{ Invocation: cmdutil.InvocationContext{Profile: "lark-prof"}, - Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") }, + Config: func() (*configpkg.CliConfig, error) { return nil, errors.New("keychain locked") }, } var errBuf bytes.Buffer - if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark { + if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark { t.Errorf("brand = %q, want lark (the active profile's brand)", got) } if errBuf.Len() != 0 { diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index d5b4b387f9..d24cd334e7 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -8,8 +8,10 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/output" ) @@ -25,7 +27,7 @@ import ( type whoamiResult struct { Profile string `json:"profile"` AppID string `json:"appId"` - Brand core.LarkBrand `json:"brand"` + Brand brand.Brand `json:"brand"` DefaultAs string `json:"defaultAs"` Identity string `json:"identity"` IdentitySource string `json:"identitySource"` @@ -80,7 +82,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error { return err } ctx := cmd.Context() - flagAs := core.Identity(opts.As) + flagAs := identity.Identity(opts.As) as := f.ResolveAs(ctx, cmd, flagAs) // Validate as a real API call does (strict mode, then identity) so whoami // can't preview an identity the next call would refuse. @@ -107,8 +109,8 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error { // auto-detected result means auto-detect; otherwise a strict-mode forced // identity means strict-mode; otherwise it came from configured default-as. // Values are snake_case to match the other enum fields (e.g. tokenStatus). -func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string { - if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) { +func resolveSource(changedAs bool, flagAs identity.Identity, autoDetected bool, strictForced identity.Identity) string { + if changedAs && (flagAs == identity.AsUser || flagAs == identity.AsBot) { return "flag" } if autoDetected { @@ -122,10 +124,10 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri // buildResult maps the resolved identity and local diagnostics into the output. // ResolveAs only ever returns user or bot, so the default branch handles user. -func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult { +func buildResult(cfg *configpkg.CliConfig, as identity.Identity, source string, diag identitydiag.Result) *whoamiResult { defaultAs := cfg.DefaultAs if defaultAs == "" { - defaultAs = core.AsAuto + defaultAs = identity.AsAuto } res := &whoamiResult{ Profile: cfg.ProfileName, @@ -138,7 +140,7 @@ func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag iden // Use the diagnosed hint as-is: it is tailored to the credential source, so // it never says "auth login" when that is blocked under an external provider. switch as { - case core.AsBot: + case identity.AsBot: res.Available = diag.Bot.Available res.TokenStatus = diag.Bot.Status if !diag.Bot.Available { diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go index 0888623974..284898a14e 100644 --- a/cmd/whoami/whoami_test.go +++ b/cmd/whoami/whoami_test.go @@ -13,11 +13,13 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/identitydiag" ) @@ -25,16 +27,16 @@ func TestResolveSource(t *testing.T) { tests := []struct { name string changedAs bool - flagAs core.Identity + flagAs identity.Identity autoDetected bool - strictForced core.Identity + strictForced identity.Identity want string }{ - {"explicit flag user", true, core.AsUser, false, "", "flag"}, - {"explicit flag bot", true, core.AsBot, false, "", "flag"}, - {"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"}, + {"explicit flag user", true, identity.AsUser, false, "", "flag"}, + {"explicit flag bot", true, identity.AsBot, false, "", "flag"}, + {"flag auto falls through to auto-detect", true, identity.AsAuto, true, "", "auto_detect"}, {"auto detected", false, "", true, "", "auto_detect"}, - {"strict mode", false, "", false, core.AsBot, "strict_mode"}, + {"strict mode", false, "", false, identity.AsBot, "strict_mode"}, {"default_as", false, "", false, "", "default_as"}, } for _, tt := range tests { @@ -48,11 +50,11 @@ func TestResolveSource(t *testing.T) { } func TestBuildResult_UserValid(t *testing.T) { - cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto} + cfg := &configpkg.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: brand.Lark, DefaultAs: identity.AsAuto} diag := identitydiag.Result{ User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"}, } - r := buildResult(cfg, core.AsUser, "auto_detect", diag) + r := buildResult(cfg, identity.AsUser, "auto_detect", diag) if r.Identity != "user" || r.IdentitySource != "auto_detect" { t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource) @@ -67,17 +69,17 @@ func TestBuildResult_UserValid(t *testing.T) { if r.Hint != "" { t.Fatalf("hint = %q, want empty", r.Hint) } - if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark { + if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != brand.Lark { t.Fatalf("app context = %#v", r) } } func TestBuildResult_UserMissingToken(t *testing.T) { - cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark} + cfg := &configpkg.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: brand.Lark} diag := identitydiag.Result{ User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in } - r := buildResult(cfg, core.AsUser, "auto_detect", diag) + r := buildResult(cfg, identity.AsUser, "auto_detect", diag) if r.Available { t.Fatalf("available = true, want false") @@ -96,11 +98,11 @@ func TestBuildResult_UserMissingToken(t *testing.T) { } func TestBuildResult_BotReady(t *testing.T) { - cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot} + cfg := &configpkg.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu, DefaultAs: identity.AsBot} diag := identitydiag.Result{ Bot: identitydiag.Identity{Available: true, Status: "ready"}, } - r := buildResult(cfg, core.AsBot, "default_as", diag) + r := buildResult(cfg, identity.AsBot, "default_as", diag) if r.Identity != "bot" || r.IdentitySource != "default_as" { t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource) @@ -117,11 +119,11 @@ func TestBuildResult_BotReady(t *testing.T) { } func TestBuildResult_BotNotConfigured(t *testing.T) { - cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu} diag := identitydiag.Result{ Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"}, } - r := buildResult(cfg, core.AsBot, "auto_detect", diag) + r := buildResult(cfg, identity.AsBot, "auto_detect", diag) if r.Available { t.Fatalf("available = true, want false") @@ -135,8 +137,8 @@ func TestBuildResult_BotNotConfigured(t *testing.T) { } func TestWhoami_BotJSON(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := NewCmdWhoami(f) @@ -169,8 +171,8 @@ func TestWhoami_BotJSON(t *testing.T) { func TestWhoami_RejectsInvalidAs(t *testing.T) { for _, bad := range []string{"admin", "USER", "bogus123", ""} { t.Run("as="+bad, func(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) cmd := NewCmdWhoami(f) cmd.SetArgs([]string{"--as", bad}) @@ -195,11 +197,11 @@ func TestWhoami_RejectsInvalidAs(t *testing.T) { } func TestWhoami_ConfigErrorPropagates(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, }) wantErr := fmt.Errorf("boom") - f.Config = func() (*core.CliConfig, error) { return nil, wantErr } + f.Config = func() (*configpkg.CliConfig, error) { return nil, wantErr } cmd := NewCmdWhoami(f) cmd.SetArgs([]string{"--json"}) @@ -218,8 +220,8 @@ func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) { // Bot-only account → strict mode bot. A real `--as user` call would be // rejected by CheckStrictMode; whoami must reject it identically rather than // previewing a user identity the next call would refuse. - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2, // bot only }) cmd := NewCmdWhoami(f) @@ -247,7 +249,7 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext return nil, nil // no UAT served locally; whoami runs with verify=false } -func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) { +func externalWhoamiFactory(cfg *configpkg.CliConfig) (*cmdutil.Factory, *bytes.Buffer) { cred := credential.NewCredentialProvider( []extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}}, nil, nil, @@ -255,7 +257,7 @@ func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer ) out := &bytes.Buffer{} f := &cmdutil.Factory{ - Config: func() (*core.CliConfig, error) { return cfg, nil }, + Config: func() (*configpkg.CliConfig, error) { return cfg, nil }, Credential: cred, IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}, } @@ -266,8 +268,8 @@ func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer // an extension provider, a signed-in user must read as available, and an // unavailable identity must not be told to "auth login" (which is blocked). func TestWhoami_ExternalProvider_UserReady(t *testing.T) { - cfg := &core.CliConfig{ - ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, + cfg := &configpkg.CliConfig{ + ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice", } f, out := externalWhoamiFactory(cfg) @@ -293,8 +295,8 @@ func TestWhoami_ExternalProvider_UserReady(t *testing.T) { } func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) { - cfg := &core.CliConfig{ - ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, + cfg := &configpkg.CliConfig{ + ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in } f, out := externalWhoamiFactory(cfg) diff --git a/envnames/envnames.go b/envnames/envnames.go new file mode 100644 index 0000000000..6734affc31 --- /dev/null +++ b/envnames/envnames.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package envnames defines environment variable names shared by the CLI and +// its public extension packages. +package envnames + +const ( + CliAppID = "LARKSUITE_CLI_APP_ID" + CliAppSecret = "LARKSUITE_CLI_APP_SECRET" + CliBrand = "LARKSUITE_CLI_BRAND" + CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN" + CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN" + CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS" + CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" + CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" + CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" +) diff --git a/events/im/message_receive.go b/events/im/message_receive.go index ae73021232..4d9ae4160a 100644 --- a/events/im/message_receive.go +++ b/events/im/message_receive.go @@ -8,7 +8,7 @@ import ( "encoding/json" "github.com/larksuite/cli/internal/event" - convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" + "github.com/larksuite/cli/internal/imcontent" ) // ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema. @@ -74,11 +74,11 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra msg := envelope.Event.Message var content string if msg.MessageType == "interactive" { - content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions) + content = imcontent.ConvertInteractiveEventContent(msg.Content, msg.Mentions) } else { - content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{ + content = imcontent.ConvertBodyContent(msg.MessageType, &imcontent.ConvertContext{ RawContent: msg.Content, - MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions), + MentionMap: imcontent.BuildMentionKeyMap(msg.Mentions), }) } diff --git a/extension/credential/env/env.go b/extension/credential/env/env.go index 5458125689..5910b80093 100644 --- a/extension/credential/env/env.go +++ b/extension/credential/env/env.go @@ -8,9 +8,8 @@ import ( "fmt" "os" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/extension/credential" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/envvars" ) // Provider resolves credentials from environment variables. @@ -19,33 +18,33 @@ type Provider struct{} func (p *Provider) Name() string { return "env" } func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) { - appID := os.Getenv(envvars.CliAppID) - appSecret := os.Getenv(envvars.CliAppSecret) - hasUAT := os.Getenv(envvars.CliUserAccessToken) != "" - hasTAT := os.Getenv(envvars.CliTenantAccessToken) != "" + appID := os.Getenv(envnames.CliAppID) + appSecret := os.Getenv(envnames.CliAppSecret) + hasUAT := os.Getenv(envnames.CliUserAccessToken) != "" + hasTAT := os.Getenv(envnames.CliTenantAccessToken) != "" if appID == "" && appSecret == "" { switch { case hasUAT: - return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"} + return nil, &credential.BlockError{Provider: "env", Reason: envnames.CliUserAccessToken + " is set but " + envnames.CliAppID + " is missing"} case hasTAT: - return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"} + return nil, &credential.BlockError{Provider: "env", Reason: envnames.CliTenantAccessToken + " is set but " + envnames.CliAppID + " is missing"} default: return nil, nil } } if appID == "" { - return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"} + return nil, &credential.BlockError{Provider: "env", Reason: envnames.CliAppSecret + " is set but " + envnames.CliAppID + " is missing"} } if appSecret == "" && !hasUAT && !hasTAT { return nil, &credential.BlockError{ Provider: "env", - Reason: envvars.CliAppID + " is set but no app secret or access token is available", + Reason: envnames.CliAppID + " is set but no app secret or access token is available", } } - brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand))) + brand := credential.ParseBrand(os.Getenv(envnames.CliBrand)) acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand} - switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id { + switch id := credential.Identity(os.Getenv(envnames.CliDefaultAs)); id { case "", credential.IdentityAuto: acct.DefaultAs = id case credential.IdentityUser, credential.IdentityBot: @@ -53,12 +52,12 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err default: return nil, &credential.BlockError{ Provider: "env", - Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id), + Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envnames.CliDefaultAs, id), } } // Explicit strict mode policy takes priority - switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode { + switch strictMode := os.Getenv(envnames.CliStrictMode); strictMode { case "bot": acct.SupportedIdentities = credential.SupportsBot case "user": @@ -76,7 +75,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err default: return nil, &credential.BlockError{ Provider: "env", - Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode), + Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envnames.CliStrictMode, strictMode), } } @@ -96,9 +95,9 @@ func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) ( var envKey string switch req.Type { case credential.TokenTypeUAT: - envKey = envvars.CliUserAccessToken + envKey = envnames.CliUserAccessToken case credential.TokenTypeTAT: - envKey = envvars.CliTenantAccessToken + envKey = envnames.CliTenantAccessToken default: return nil, nil } diff --git a/extension/credential/env/env_test.go b/extension/credential/env/env_test.go index 096bd9fe29..15d069685c 100644 --- a/extension/credential/env/env_test.go +++ b/extension/credential/env/env_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/extension/credential" - "github.com/larksuite/cli/internal/envvars" ) func TestProvider_Name(t *testing.T) { @@ -20,9 +20,9 @@ func TestProvider_Name(t *testing.T) { } func TestResolveAccount_BothSet(t *testing.T) { - t.Setenv(envvars.CliAppID, "cli_test") - t.Setenv(envvars.CliAppSecret, "secret_test") - t.Setenv(envvars.CliBrand, " LARK ") + t.Setenv(envnames.CliAppID, "cli_test") + t.Setenv(envnames.CliAppSecret, "secret_test") + t.Setenv(envnames.CliBrand, " LARK ") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { @@ -41,7 +41,7 @@ func TestResolveAccount_NeitherSet(t *testing.T) { } func TestResolveAccount_OnlyIDSet(t *testing.T) { - t.Setenv(envvars.CliAppID, "cli_test") + t.Setenv(envnames.CliAppID, "cli_test") _, err := (&Provider{}).ResolveAccount(context.Background()) var blockErr *credential.BlockError if !errors.As(err, &blockErr) { @@ -50,8 +50,8 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) { } func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) { - t.Setenv(envvars.CliAppID, "cli_test") - t.Setenv(envvars.CliUserAccessToken, "uat_test") + t.Setenv(envnames.CliAppID, "cli_test") + t.Setenv(envnames.CliUserAccessToken, "uat_test") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { @@ -69,7 +69,7 @@ func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) { } func TestResolveAccount_OnlySecretSet(t *testing.T) { - t.Setenv(envvars.CliAppSecret, "secret_test") + t.Setenv(envnames.CliAppSecret, "secret_test") _, err := (&Provider{}).ResolveAccount(context.Background()) var blockErr *credential.BlockError if !errors.As(err, &blockErr) { @@ -78,21 +78,21 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) { } func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) { - t.Setenv(envvars.CliUserAccessToken, "uat_test") + t.Setenv(envnames.CliUserAccessToken, "uat_test") _, err := (&Provider{}).ResolveAccount(context.Background()) var blockErr *credential.BlockError if !errors.As(err, &blockErr) { t.Fatalf("expected BlockError, got %v", err) } - if !strings.Contains(err.Error(), envvars.CliAppID) { - t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID) + if !strings.Contains(err.Error(), envnames.CliAppID) { + t.Fatalf("error = %v, want mention of %s", err, envnames.CliAppID) } } func TestResolveAccount_DefaultBrand(t *testing.T) { - t.Setenv(envvars.CliAppID, "cli_test") - t.Setenv(envvars.CliAppSecret, "secret_test") + t.Setenv(envnames.CliAppID, "cli_test") + t.Setenv(envnames.CliAppSecret, "secret_test") acct, _ := (&Provider{}).ResolveAccount(context.Background()) if acct.Brand != "feishu" { t.Errorf("expected 'feishu', got %q", acct.Brand) @@ -100,9 +100,9 @@ func TestResolveAccount_DefaultBrand(t *testing.T) { } func TestResolveAccount_DefaultAsFromEnv(t *testing.T) { - t.Setenv(envvars.CliAppID, "cli_test") - t.Setenv(envvars.CliAppSecret, "secret_test") - t.Setenv(envvars.CliDefaultAs, "user") + t.Setenv(envnames.CliAppID, "cli_test") + t.Setenv(envnames.CliAppSecret, "secret_test") + t.Setenv(envnames.CliDefaultAs, "user") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { @@ -114,23 +114,23 @@ func TestResolveAccount_DefaultAsFromEnv(t *testing.T) { } func TestResolveToken_UATSet(t *testing.T) { - t.Setenv(envvars.CliUserAccessToken, "u-env") + t.Setenv(envnames.CliUserAccessToken, "u-env") tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT}) if err != nil { t.Fatal(err) } - if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken { + if tok.Value != "u-env" || tok.Source != "env:"+envnames.CliUserAccessToken { t.Errorf("unexpected: %+v", tok) } } func TestResolveToken_TATSet(t *testing.T) { - t.Setenv(envvars.CliTenantAccessToken, "t-env") + t.Setenv(envnames.CliTenantAccessToken, "t-env") tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT}) if err != nil { t.Fatal(err) } - if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken { + if tok.Value != "t-env" || tok.Source != "env:"+envnames.CliTenantAccessToken { t.Errorf("unexpected: %+v", tok) } } @@ -143,9 +143,9 @@ func TestResolveToken_NotSet(t *testing.T) { } func TestResolveAccount_StrictModeBot(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliStrictMode, "bot") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliStrictMode, "bot") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { t.Fatal(err) @@ -156,9 +156,9 @@ func TestResolveAccount_StrictModeBot(t *testing.T) { } func TestResolveAccount_StrictModeUser(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliStrictMode, "user") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliStrictMode, "user") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { t.Fatal(err) @@ -169,9 +169,9 @@ func TestResolveAccount_StrictModeUser(t *testing.T) { } func TestResolveAccount_StrictModeOff(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliStrictMode, "off") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliStrictMode, "off") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { t.Fatal(err) @@ -182,9 +182,9 @@ func TestResolveAccount_StrictModeOff(t *testing.T) { } func TestResolveAccount_InferFromUATOnly(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliUserAccessToken, "u-tok") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliUserAccessToken, "u-tok") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { t.Fatal(err) @@ -198,9 +198,9 @@ func TestResolveAccount_InferFromUATOnly(t *testing.T) { } func TestResolveAccount_InferFromTATOnly(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliTenantAccessToken, "t-tok") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliTenantAccessToken, "t-tok") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { t.Fatal(err) @@ -214,10 +214,10 @@ func TestResolveAccount_InferFromTATOnly(t *testing.T) { } func TestResolveAccount_InferBothTokens(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliUserAccessToken, "u-tok") - t.Setenv(envvars.CliTenantAccessToken, "t-tok") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliUserAccessToken, "u-tok") + t.Setenv(envnames.CliTenantAccessToken, "t-tok") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { t.Fatal(err) @@ -231,11 +231,11 @@ func TestResolveAccount_InferBothTokens(t *testing.T) { } func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliUserAccessToken, "u-tok") - t.Setenv(envvars.CliTenantAccessToken, "t-tok") - t.Setenv(envvars.CliStrictMode, "bot") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliUserAccessToken, "u-tok") + t.Setenv(envnames.CliTenantAccessToken, "t-tok") + t.Setenv(envnames.CliStrictMode, "bot") acct, err := (&Provider{}).ResolveAccount(context.Background()) if err != nil { t.Fatal(err) @@ -246,9 +246,9 @@ func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) { } func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliStrictMode, "invalid") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliStrictMode, "invalid") _, err := (&Provider{}).ResolveAccount(context.Background()) if err == nil { @@ -258,15 +258,15 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) { if !errors.As(err, &blockErr) { t.Fatalf("expected BlockError, got %T", err) } - if !strings.Contains(err.Error(), envvars.CliStrictMode) { - t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode) + if !strings.Contains(err.Error(), envnames.CliStrictMode) { + t.Fatalf("error = %v, want mention of %s", err, envnames.CliStrictMode) } } func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) { - t.Setenv(envvars.CliAppID, "app") - t.Setenv(envvars.CliAppSecret, "secret") - t.Setenv(envvars.CliDefaultAs, "invalid") + t.Setenv(envnames.CliAppID, "app") + t.Setenv(envnames.CliAppSecret, "secret") + t.Setenv(envnames.CliDefaultAs, "invalid") _, err := (&Provider{}).ResolveAccount(context.Background()) if err == nil { @@ -276,7 +276,7 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) { if !errors.As(err, &blockErr) { t.Fatalf("expected BlockError, got %T", err) } - if !strings.Contains(err.Error(), envvars.CliDefaultAs) { - t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs) + if !strings.Contains(err.Error(), envnames.CliDefaultAs) { + t.Fatalf("error = %v, want mention of %s", err, envnames.CliDefaultAs) } } diff --git a/extension/credential/sidecar/provider.go b/extension/credential/sidecar/provider.go index d01f56616b..87182e5205 100644 --- a/extension/credential/sidecar/provider.go +++ b/extension/credential/sidecar/provider.go @@ -15,9 +15,8 @@ import ( "fmt" "os" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/extension/credential" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -32,7 +31,7 @@ func (p *Provider) Priority() int { return 0 } // placeholder secret, and SupportedIdentities derived from STRICT_MODE. // Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set). func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) { - proxyAddr := os.Getenv(envvars.CliAuthProxy) + proxyAddr := os.Getenv(envnames.CliAuthProxy) if proxyAddr == "" { return nil, nil // not in sidecar mode, skip } @@ -40,26 +39,26 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { return nil, &credential.BlockError{ Provider: "sidecar", - Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err), + Reason: fmt.Sprintf("invalid %s %q: %v", envnames.CliAuthProxy, proxyAddr, err), } } - appID := os.Getenv(envvars.CliAppID) + appID := os.Getenv(envnames.CliAppID) if appID == "" { return nil, &credential.BlockError{ Provider: "sidecar", - Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing", + Reason: envnames.CliAuthProxy + " is set but " + envnames.CliAppID + " is missing", } } - if os.Getenv(envvars.CliProxyKey) == "" { + if os.Getenv(envnames.CliProxyKey) == "" { return nil, &credential.BlockError{ Provider: "sidecar", - Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing", + Reason: envnames.CliAuthProxy + " is set but " + envnames.CliProxyKey + " is missing", } } - brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand))) + brand := credential.ParseBrand(os.Getenv(envnames.CliBrand)) acct := &credential.Account{ AppID: appID, @@ -68,7 +67,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err } // Parse DefaultAs - switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id { + switch id := credential.Identity(os.Getenv(envnames.CliDefaultAs)); id { case "", credential.IdentityAuto: acct.DefaultAs = id case credential.IdentityUser, credential.IdentityBot: @@ -76,12 +75,12 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err default: return nil, &credential.BlockError{ Provider: "sidecar", - Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id), + Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envnames.CliDefaultAs, id), } } // Parse SupportedIdentities from STRICT_MODE, default to SupportsAll. - switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode { + switch strictMode := os.Getenv(envnames.CliStrictMode); strictMode { case "bot": acct.SupportedIdentities = credential.SupportsBot case "user": @@ -91,7 +90,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err default: return nil, &credential.BlockError{ Provider: "sidecar", - Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode), + Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envnames.CliStrictMode, strictMode), } } @@ -103,7 +102,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err // (user vs bot), strips it, and the sidecar injects the real token. // Returns nil, nil when sidecar mode is not active. func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) { - if os.Getenv(envvars.CliAuthProxy) == "" { + if os.Getenv(envnames.CliAuthProxy) == "" { return nil, nil } diff --git a/extension/credential/sidecar/provider_test.go b/extension/credential/sidecar/provider_test.go index e265b5652f..7b1c6100a3 100644 --- a/extension/credential/sidecar/provider_test.go +++ b/extension/credential/sidecar/provider_test.go @@ -10,8 +10,8 @@ import ( "os" "testing" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/extension/credential" - "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -40,7 +40,7 @@ func unsetEnv(t *testing.T, key string) { } func TestResolveAccount_NotActive(t *testing.T) { - unsetEnv(t, envvars.CliAuthProxy) + unsetEnv(t, envnames.CliAuthProxy) p := &Provider{} acct, err := p.ResolveAccount(context.Background()) @@ -53,12 +53,12 @@ func TestResolveAccount_NotActive(t *testing.T) { } func TestResolveAccount_Active(t *testing.T) { - setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") - setEnv(t, envvars.CliProxyKey, "test-key") - setEnv(t, envvars.CliAppID, "cli_test123") - setEnv(t, envvars.CliBrand, " LARK ") - unsetEnv(t, envvars.CliDefaultAs) - unsetEnv(t, envvars.CliStrictMode) + setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384") + setEnv(t, envnames.CliProxyKey, "test-key") + setEnv(t, envnames.CliAppID, "cli_test123") + setEnv(t, envnames.CliBrand, " LARK ") + unsetEnv(t, envnames.CliDefaultAs) + unsetEnv(t, envnames.CliStrictMode) p := &Provider{} acct, err := p.ResolveAccount(context.Background()) @@ -83,9 +83,9 @@ func TestResolveAccount_Active(t *testing.T) { } func TestResolveAccount_MissingProxyKey(t *testing.T) { - setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") - unsetEnv(t, envvars.CliProxyKey) - setEnv(t, envvars.CliAppID, "cli_test") + setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384") + unsetEnv(t, envnames.CliProxyKey) + setEnv(t, envnames.CliAppID, "cli_test") p := &Provider{} _, err := p.ResolveAccount(context.Background()) @@ -98,9 +98,9 @@ func TestResolveAccount_MissingProxyKey(t *testing.T) { } func TestResolveAccount_MissingAppID(t *testing.T) { - setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") - setEnv(t, envvars.CliProxyKey, "test-key") - unsetEnv(t, envvars.CliAppID) + setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384") + setEnv(t, envnames.CliProxyKey, "test-key") + unsetEnv(t, envnames.CliAppID) p := &Provider{} _, err := p.ResolveAccount(context.Background()) @@ -113,9 +113,9 @@ func TestResolveAccount_MissingAppID(t *testing.T) { } func TestResolveAccount_StrictMode(t *testing.T) { - setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") - setEnv(t, envvars.CliProxyKey, "test-key") - setEnv(t, envvars.CliAppID, "cli_test") + setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384") + setEnv(t, envnames.CliProxyKey, "test-key") + setEnv(t, envnames.CliAppID, "cli_test") tests := []struct { mode string @@ -131,9 +131,9 @@ func TestResolveAccount_StrictMode(t *testing.T) { for _, tt := range tests { t.Run("strict_"+tt.mode, func(t *testing.T) { if tt.mode == "" { - unsetEnv(t, envvars.CliStrictMode) + unsetEnv(t, envnames.CliStrictMode) } else { - setEnv(t, envvars.CliStrictMode, tt.mode) + setEnv(t, envnames.CliStrictMode, tt.mode) } acct, err := p.ResolveAccount(context.Background()) if err != nil { @@ -147,7 +147,7 @@ func TestResolveAccount_StrictMode(t *testing.T) { } func TestResolveToken_NotActive(t *testing.T) { - unsetEnv(t, envvars.CliAuthProxy) + unsetEnv(t, envnames.CliAuthProxy) p := &Provider{} tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT}) @@ -160,8 +160,8 @@ func TestResolveToken_NotActive(t *testing.T) { } func TestResolveToken_Sentinels(t *testing.T) { - setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") - setEnv(t, envvars.CliProxyKey, "test-key") + setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384") + setEnv(t, envnames.CliProxyKey, "test-key") p := &Provider{} diff --git a/extension/credential/types.go b/extension/credential/types.go index 209013fda5..95fb384e24 100644 --- a/extension/credential/types.go +++ b/extension/credential/types.go @@ -3,16 +3,29 @@ package credential -import "context" +import ( + "context" + + "github.com/larksuite/cli/brand" +) // Brand represents the Lark platform brand. -type Brand string +type Brand = brand.Brand const ( - BrandLark Brand = "lark" - BrandFeishu Brand = "feishu" + BrandLark = brand.Lark + BrandFeishu = brand.Feishu ) +// ParseBrand maps a brand string to a Brand, defaulting to BrandFeishu. +// It forwards to the repository-root brand package so every credential source +// and the rest of the CLI resolve the brand through one implementation; +// re-spelling the rule here would let the two copies drift when the brand set +// changes. It stays exported so SDK callers need not import brand directly. +func ParseBrand(value string) Brand { + return brand.ParseBrand(value) +} + // NoAppSecret marks that a credential source does not provide a real app secret. // Token-only sources should return this value instead of inventing placeholder text. const NoAppSecret = "" diff --git a/extension/transport/sidecar/interceptor.go b/extension/transport/sidecar/interceptor.go index 6d928bd8a7..339dcf1d1e 100644 --- a/extension/transport/sidecar/interceptor.go +++ b/extension/transport/sidecar/interceptor.go @@ -20,8 +20,8 @@ import ( "os" "strings" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/extension/transport" - "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -36,15 +36,15 @@ func (p *Provider) Name() string { return "sidecar" } // the non-sidecar transport path (where the credential layer will typically // block them for lack of a valid account). func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor { - proxyAddr := os.Getenv(envvars.CliAuthProxy) + proxyAddr := os.Getenv(envnames.CliAuthProxy) if proxyAddr == "" { return nil } if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { - fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err) + fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envnames.CliAuthProxy, err) return nil } - key := os.Getenv(envvars.CliProxyKey) + key := os.Getenv(envnames.CliProxyKey) return &Interceptor{ key: []byte(key), sidecarHost: sidecar.ProxyHost(proxyAddr), @@ -166,12 +166,12 @@ func detectSentinel(req *http.Request) (identity, authHeader string) { } func init() { - proxyAddr := os.Getenv(envvars.CliAuthProxy) + proxyAddr := os.Getenv(envnames.CliAuthProxy) if proxyAddr == "" { return } if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { - fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err) + fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envnames.CliAuthProxy, err) return } transport.Register(&Provider{}) diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 44d5c3af95..5828a39d70 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -14,7 +14,8 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/internal/authlog" ) // Terminal registration outcomes, exposed for typed classification by callers. @@ -26,7 +27,7 @@ var ( // Protocol defaults, mirroring the official SDK registration flow. const ( - registrationBootstrapBrand = core.BrandFeishu + registrationBootstrapBrand = brandpkg.Feishu defaultPollIntervalSeconds = 5 defaultExpireInSeconds = 600 beginRequestTimeout = 30 * time.Second @@ -81,14 +82,14 @@ type AppRegUserInfo struct { } // appRegistrationEndpoint returns the brand's accounts registration endpoint. -func appRegistrationEndpoint(brand core.LarkBrand) string { - return core.ResolveEndpoints(brand).Accounts + PathAppRegistration +func appRegistrationEndpoint(brand brandpkg.Brand) string { + return brandpkg.ResolveEndpoints(brand).Accounts + PathAppRegistration } // RequestAppRegistration initiates the device flow. The registration protocol // always bootstraps on Feishu; brand selects the user-facing verification host. // The request is bounded by ctx and a begin timeout. -func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) { +func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand brandpkg.Brand, errOut io.Writer) (*AppRegistrationResponse, error) { if errOut == nil { errOut = io.Discard } @@ -96,7 +97,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout) defer cancel() - ep := core.ResolveEndpoints(brand) + ep := brandpkg.ResolveEndpoints(brand) endpoint := appRegistrationEndpoint(registrationBootstrapBrand) form := url.Values{} @@ -116,7 +117,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand return nil, err } defer resp.Body.Close() - logHTTPResponse(resp) + logHTTPResponse(newAuthLogger(), resp) body, err := io.ReadAll(resp.Body) if err != nil { @@ -180,7 +181,7 @@ func BuildVerificationURL(baseURL, cliVersion string) string { } // pollOnce performs one ctx-bound poll request and decodes the payload. -func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string) (map[string]interface{}, error) { +func pollOnce(ctx context.Context, httpClient *http.Client, brand brandpkg.Brand, deviceCode string, logger *authlog.Logger) (map[string]interface{}, error) { form := url.Values{} form.Set("action", "poll") form.Set("device_code", deviceCode) @@ -196,7 +197,7 @@ func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand return nil, fmt.Errorf("poll network error: %w", err) } defer resp.Body.Close() - logHTTPResponse(resp) + logHTTPResponse(logger, resp) body, err := io.ReadAll(resp.Body) if err != nil { @@ -214,7 +215,7 @@ func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand // non-error responses without complete credentials keep polling, and one // deadline from the begin expiry bounds all waits and in-flight requests. // The returned brand is the one the credentials were issued on. -func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, core.LarkBrand, error) { +func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, brandpkg.Brand, error) { if errOut == nil { errOut = io.Discard } @@ -230,6 +231,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp effectiveBrand := currentBrand switched := false waitBeforePoll := false + authLogger := newAuthLogger() for { if waitBeforePoll { @@ -244,7 +246,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp return nil, effectiveBrand, registrationContextError(ctx) } - data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode) + data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode, authLogger) if err != nil { fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: %v\n", err) interval = minInt(interval+1, maxPollIntervalSeconds) @@ -257,7 +259,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp if !switched { if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok { if tb := getStr(userInfoRaw, "tenant_brand"); tb != "" { - if actual := core.ParseBrand(tb); actual != currentBrand { + if actual := brandpkg.ParseBrand(tb); actual != currentBrand { currentBrand = actual effectiveBrand = actual switched = true @@ -285,7 +287,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp // The issuing domain is authoritative; a contradictory final // tenant report is a protocol violation, not a brand override. if result.UserInfo != nil && result.UserInfo.TenantBrand != "" && - core.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand { + brandpkg.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand { return nil, effectiveBrand, fmt.Errorf("app registration returned credentials with a contradictory tenant brand %q", result.UserInfo.TenantBrand) } return result, effectiveBrand, nil diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go index 64993e9ccb..1012906497 100644 --- a/internal/auth/app_registration_test.go +++ b/internal/auth/app_registration_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" "github.com/smartystreets/goconvey/convey" ) @@ -51,11 +51,11 @@ func Test_BuildVerificationURL(t *testing.T) { func TestAppRegistrationEndpoint(t *testing.T) { cases := []struct { - brand core.LarkBrand + brand brandpkg.Brand want string }{ - {core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration}, - {core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration}, + {brandpkg.Feishu, "https://accounts.feishu.cn" + PathAppRegistration}, + {brandpkg.Lark, "https://accounts.larksuite.com" + PathAppRegistration}, } for _, c := range cases { if got := appRegistrationEndpoint(c.brand); got != c.want { @@ -66,11 +66,11 @@ func TestAppRegistrationEndpoint(t *testing.T) { func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) { cases := []struct { - brand core.LarkBrand + brand brandpkg.Brand verificationHost string }{ - {core.BrandFeishu, "open.feishu.cn"}, - {core.BrandLark, "open.larksuite.com"}, + {brandpkg.Feishu, "open.feishu.cn"}, + {brandpkg.Lark, "open.larksuite.com"}, } for _, c := range cases { t.Run(string(c.brand), func(t *testing.T) { @@ -115,7 +115,7 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) { t.Errorf("unexpected host polled: %s", r.URL.Host) return jsonResponse(`{}`), nil })} - resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard) + resp, err := RequestAppRegistration(context.Background(), client, brandpkg.Lark, io.Discard) if err != nil { t.Fatalf("RequestAppRegistration error = %v", err) } @@ -127,8 +127,8 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) { if err != nil { t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err) } - if finalBrand != core.BrandLark { - t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, core.BrandLark) + if finalBrand != brandpkg.Lark { + t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, brandpkg.Lark) } if result.ClientID != "cli_x" || result.ClientSecret != "test-secret" { t.Errorf("credentials = (%q, %q), want (cli_x, test-secret)", result.ClientID, result.ClientSecret) @@ -162,8 +162,8 @@ func TestRegisterAppWithDiscovery_BootstrapBrandSinglePoll(t *testing.T) { if err != nil { t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err) } - if finalBrand != core.BrandFeishu { - t.Errorf("finalBrand = %q, want %q", finalBrand, core.BrandFeishu) + if finalBrand != brandpkg.Feishu { + t.Errorf("finalBrand = %q, want %q", finalBrand, brandpkg.Feishu) } if polls != 1 { t.Errorf("polls = %d, want 1", polls) @@ -214,7 +214,7 @@ func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) { if polls != 3 { t.Errorf("polls = %d, want 3", polls) } - if result.ClientSecret != "test-secret" || finalBrand != core.BrandFeishu { + if result.ClientSecret != "test-secret" || finalBrand != brandpkg.Feishu { t.Errorf("result = (%q, %q), want (test-secret, feishu)", result.ClientSecret, finalBrand) } } @@ -240,7 +240,7 @@ func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) { if elapsed := time.Since(start); elapsed > 2*time.Second { t.Errorf("discovery waited an interval somewhere: took %v", elapsed) } - if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" { + if finalBrand != brandpkg.Lark || result.ClientSecret != "test-secret" { t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand) } want := []string{"accounts.feishu.cn", "accounts.larksuite.com"} @@ -286,7 +286,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err := RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","expire_in":60,"interval":3}`), brandpkg.Feishu, io.Discard) if err != nil { t.Fatalf("begin error = %v", err) } @@ -295,7 +295,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err = RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","expires_in":45}`), brandpkg.Feishu, io.Discard) if err != nil { t.Fatalf("legacy begin error = %v", err) } @@ -304,7 +304,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err = RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","interval":0}`), brandpkg.Feishu, io.Discard) if err != nil { t.Fatalf("defaults begin error = %v", err) } @@ -313,7 +313,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } if _, err := RequestAppRegistration(context.Background(), - serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil { + serve(`{"interval":5}`), brandpkg.Feishu, io.Discard); err == nil { t.Error("missing device_code: expected error, got nil") } } @@ -335,7 +335,7 @@ func TestRegisterAppWithDiscovery_PendingWithTenantSignalSwitches(t *testing.T) if err != nil { t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err) } - if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" { + if finalBrand != brandpkg.Lark || result.ClientSecret != "test-secret" { t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand) } want := []string{"accounts.feishu.cn", "accounts.larksuite.com"} @@ -394,7 +394,7 @@ func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) { Header: make(http.Header), }, nil })} - _, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard) + _, err := RequestAppRegistration(context.Background(), client, brandpkg.Feishu, io.Discard) if !errors.Is(err, context.Canceled) { t.Errorf("err = %v, want a context.Canceled cause", err) } diff --git a/internal/auth/auth_response_log.go b/internal/auth/auth_response_log.go index b57a9edaef..f1f5c0af19 100644 --- a/internal/auth/auth_response_log.go +++ b/internal/auth/auth_response_log.go @@ -6,14 +6,25 @@ package auth import ( "net/http" - "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/authlog" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) +type authLogger interface { + LogResponse(path string, status int, logID string) +} + +// newAuthLogger returns the process-wide authentication logger. It is shared on +// purpose: one file handle and one log-prune per process, and keychain errors +// land in the same file as the responses they explain. +func newAuthLogger() *authlog.Logger { + return authlog.Shared() +} + // logHTTPResponse logs the HTTP response details for an authentication request. // It extracts the request path, status code, and x-tt-logid from the given HTTP response. -func logHTTPResponse(resp *http.Response) { - if resp == nil { +func logHTTPResponse(logger authLogger, resp *http.Response) { + if logger == nil || resp == nil { return } @@ -22,20 +33,23 @@ func logHTTPResponse(resp *http.Response) { path = resp.Request.URL.Path } - keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid")) + logger.LogResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid")) } // logSDKResponse logs the SDK response details for an authentication request. // It extracts the status code and x-tt-logid from the given API response object. -func logSDKResponse(path string, apiResp *larkcore.ApiResp) { +func logSDKResponse(logger authLogger, path string, apiResp *larkcore.ApiResp) { + if logger == nil { + return + } if path == "" { path = "missing" } if apiResp == nil { - keychain.LogAuthResponse(path, 0, "") + logger.LogResponse(path, 0, "") return } - keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid")) + logger.LogResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid")) } diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 7acdad859c..88dc37afa9 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" ) // DeviceAuthResponse is the response from the device authorization endpoint. @@ -52,8 +52,8 @@ type OAuthEndpoints struct { } // ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand. -func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints { - ep := core.ResolveEndpoints(brand) +func ResolveOAuthEndpoints(brand brandpkg.Brand) OAuthEndpoints { + ep := brandpkg.ResolveEndpoints(brand) return OAuthEndpoints{ DeviceAuthorization: ep.Accounts + PathDeviceAuthorization, Revoke: ep.Accounts + PathOAuthRevoke, @@ -62,7 +62,11 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints { } // RequestDeviceAuthorization requests a device authorization code. -func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) { +func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) { + return requestDeviceAuthorization(httpClient, appId, appSecret, brand, scope, errOut, newAuthLogger()) +} + +func requestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, scope string, errOut io.Writer, logger authLogger) (*DeviceAuthResponse, error) { if errOut == nil { errOut = io.Discard } @@ -95,7 +99,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string return nil, err } defer resp.Body.Close() - logHTTPResponse(resp) + logHTTPResponse(logger, resp) body, err := io.ReadAll(resp.Body) if err != nil { @@ -139,7 +143,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string } // PollDeviceToken polls the token endpoint until authorization completes or times out. -func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult { +func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult { if errOut == nil { errOut = io.Discard } @@ -155,6 +159,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) currentInterval := interval attempts := 0 + authLogger := newAuthLogger() for time.Now().Before(deadline) && attempts < maxPollAttempts { attempts++ @@ -186,7 +191,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec currentInterval = minInt(currentInterval+1, maxPollInterval) continue } - logHTTPResponse(resp) + logHTTPResponse(authLogger, resp) body, err := io.ReadAll(resp.Body) resp.Body.Close() diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 397098cc18..ffb7c21894 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -14,9 +14,9 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/internal/authlog" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/keychain" ) type roundTripFunc func(*http.Request) (*http.Response, error) @@ -25,9 +25,17 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } +func testAuthLogger(buf *bytes.Buffer, now func() time.Time, args func() []string) *authlog.Logger { + return authlog.New(authlog.Options{ + Logger: log.New(buf, "", 0), + Now: now, + Args: args, + }) +} + // TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand. func TestResolveOAuthEndpoints_Feishu(t *testing.T) { - ep := ResolveOAuthEndpoints(core.BrandFeishu) + ep := ResolveOAuthEndpoints(brand.Feishu) if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" { t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) } @@ -41,7 +49,7 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) { // TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand. func TestResolveOAuthEndpoints_Lark(t *testing.T) { - ep := ResolveOAuthEndpoints(core.BrandLark) + ep := ResolveOAuthEndpoints(brand.Lark) if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" { t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) } @@ -76,14 +84,13 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) { }) var buf bytes.Buffer - restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + authLogger := testAuthLogger(&buf, func() time.Time { return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) }, func() []string { return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"} }) - t.Cleanup(restore) - _, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil) + _, err := requestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", brand.Feishu, "", nil, authLogger) if err != nil { t.Fatalf("RequestDeviceAuthorization() error: %v", err) } @@ -108,7 +115,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) { // TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated. func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) { - got := keychain.FormatAuthCmdline([]string{ + got := authlog.FormatAuthCmdline([]string{ "lark-cli", "auth", "login", @@ -126,11 +133,10 @@ func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) { // TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully. func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) { var buf bytes.Buffer - restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil) - t.Cleanup(restore) + authLogger := testAuthLogger(&buf, nil, nil) var resp *http.Response - logHTTPResponse(resp) + logHTTPResponse(authLogger, resp) if got := buf.String(); got != "" { t.Fatalf("expected no log output, got %q", got) @@ -140,14 +146,13 @@ func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) { // TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking. func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) { var buf bytes.Buffer - restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + authLogger := testAuthLogger(&buf, func() time.Time { return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) }, func() []string { return []string{"lark-cli", "auth", "status", "--verify"} }) - t.Cleanup(restore) - logSDKResponse(PathUserInfoV1, nil) + logSDKResponse(authLogger, PathUserInfoV1, nil) got := buf.String() if !strings.Contains(got, "path="+PathUserInfoV1) { @@ -160,14 +165,13 @@ func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) { func TestLogAuthError_RecordsStructuredEntry(t *testing.T) { var buf bytes.Buffer - restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + authLogger := testAuthLogger(&buf, func() time.Time { return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) }, func() []string { return []string{"lark-cli", "auth", "login", "--device-code", "secret"} }) - t.Cleanup(restore) - keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse)) + authLogger.LogError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse)) got := buf.String() if !strings.Contains(got, "auth-error") { @@ -205,7 +209,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) t.Cleanup(cancel) - result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil) + result := PollDeviceToken(ctx, client, "cli_a", "secret_b", brand.Feishu, "device-code", 0, 10, nil) if result == nil { t.Fatal("PollDeviceToken() returned nil result") } diff --git a/internal/auth/revoke.go b/internal/auth/revoke.go index f1f046daf0..a7c5e6dee4 100644 --- a/internal/auth/revoke.go +++ b/internal/auth/revoke.go @@ -11,12 +11,12 @@ import ( "net/url" "strings" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" ) // RevokeToken revokes a previously issued OAuth token. -func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error { +func RevokeToken(httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, token, tokenTypeHint string) error { endpoints := ResolveOAuthEndpoints(brand) form := url.Values{} @@ -38,7 +38,7 @@ func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.La return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err) } defer resp.Body.Close() - logHTTPResponse(resp) + logHTTPResponse(newAuthLogger(), resp) body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/auth/revoke_test.go b/internal/auth/revoke_test.go index 28184bfa8e..d78ae255df 100644 --- a/internal/auth/revoke_test.go +++ b/internal/auth/revoke_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" ) @@ -54,7 +54,7 @@ func TestRevokeToken_PostsExpectedForm(t *testing.T) { } reg.Register(stub) - err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", brand.Feishu, "user-access-token", "access_token") if err != nil { t.Fatalf("RevokeToken() error = %v", err) } @@ -71,7 +71,7 @@ func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) { }), } - err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + err := RevokeToken(httpClient, "cli_a", "secret_b", brand.Feishu, "user-access-token", "access_token") if err == nil { t.Fatal("expected error") } @@ -98,7 +98,7 @@ func TestRevokeToken_ReportsHTTPError(t *testing.T) { Body: map[string]interface{}{"error": "invalid_token"}, }) - err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", brand.Feishu, "user-access-token", "access_token") if err == nil { t.Fatal("expected error") } @@ -127,7 +127,7 @@ func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) { }, }) - err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", brand.Feishu, "user-access-token", "access_token") if err == nil { t.Fatal("expected error") } @@ -156,7 +156,7 @@ func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) { }, }) - err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", brand.Feishu, "user-access-token", "access_token") if err == nil { t.Fatal("expected error") } @@ -184,7 +184,7 @@ func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) { }), } - err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + err := RevokeToken(httpClient, "cli_a", "secret_b", brand.Feishu, "user-access-token", "access_token") if err == nil { t.Fatal("expected error") } diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go index d1a0683927..8d65a55c46 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -18,10 +18,12 @@ import ( "time" "github.com/gofrs/flock" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) @@ -36,7 +38,7 @@ type UATCallOptions struct { UserOpenId string AppId string AppSecret string - Domain core.LarkBrand + Domain brand.Brand ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut) } @@ -52,7 +54,7 @@ type UATStatus struct { } // NewUATCallOptions creates UATCallOptions from a CLI config. -func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions { +func NewUATCallOptions(cfg *configpkg.CliConfig, errOut io.Writer) UATCallOptions { if errOut == nil { errOut = os.Stderr } @@ -128,7 +130,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store // 2. Cross-process lock using flock // We use the same underlying storage directory resolution as keychain_other.go // to ensure locks are isolated properly alongside other sensitive data. - configDir := core.GetConfigDir() + configDir := workspace.GetConfigDir() lockDir := filepath.Join(configDir, "locks") if err := vfs.MkdirAll(lockDir, 0700); err != nil { @@ -187,6 +189,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored } endpoints := ResolveOAuthEndpoints(opts.Domain) + authLogger := newAuthLogger() callEndpoint := func() (map[string]interface{}, error) { form := url.Values{} @@ -206,7 +209,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored return nil, err } defer resp.Body.Close() - logHTTPResponse(resp) + logHTTPResponse(authLogger, resp) body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/auth/uat_client_options_test.go b/internal/auth/uat_client_options_test.go index d1f8257735..e05bd50411 100644 --- a/internal/auth/uat_client_options_test.go +++ b/internal/auth/uat_client_options_test.go @@ -7,15 +7,16 @@ import ( "bytes" "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" + configpkg "github.com/larksuite/cli/internal/config" ) // TestNewUATCallOptions validates the extraction of options from CLI config. func TestNewUATCallOptions(t *testing.T) { - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "app123", AppSecret: "secret", - Brand: core.BrandLark, + Brand: brand.Lark, UserOpenId: "ou_test", } errOut := &bytes.Buffer{} @@ -28,7 +29,7 @@ func TestNewUATCallOptions(t *testing.T) { if opts.AppSecret != "secret" { t.Errorf("AppSecret = %q, want secret", opts.AppSecret) } - if opts.Domain != core.BrandLark { + if opts.Domain != brand.Lark { t.Errorf("Domain = %q, want lark", opts.Domain) } if opts.UserOpenId != "ou_test" { diff --git a/internal/auth/verify.go b/internal/auth/verify.go index 8786a7d3c4..f6840e97d0 100644 --- a/internal/auth/verify.go +++ b/internal/auth/verify.go @@ -16,6 +16,10 @@ import ( // VerifyUserToken calls /authen/v1/user_info to confirm the token is accepted server-side. // Returns nil on success or an error describing why the server rejected the token. func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error { + return verifyUserToken(ctx, sdk, accessToken, newAuthLogger()) +} + +func verifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string, logger authLogger) error { apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: PathUserInfoV1, @@ -24,7 +28,7 @@ func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) if err != nil { return err } - logSDKResponse(PathUserInfoV1, apiResp) + logSDKResponse(logger, PathUserInfoV1, apiResp) var resp struct { Code int `json:"code"` diff --git a/internal/auth/verify_test.go b/internal/auth/verify_test.go index d513a66a3f..21c11736fe 100644 --- a/internal/auth/verify_test.go +++ b/internal/auth/verify_test.go @@ -6,13 +6,11 @@ package auth import ( "bytes" "context" - "log" "net/http" "strings" "testing" "time" - "github.com/larksuite/cli/internal/keychain" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -86,14 +84,13 @@ func TestVerifyUserToken(t *testing.T) { ) var buf bytes.Buffer - restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time { + authLogger := testAuthLogger(&buf, func() time.Time { return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC) }, func() []string { return []string{"lark-cli", "auth", "status"} }) - t.Cleanup(restore) - err := VerifyUserToken(context.Background(), sdk, "test-token") + err := verifyUserToken(context.Background(), sdk, "test-token", authLogger) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") diff --git a/internal/authlog/authlog.go b/internal/authlog/authlog.go new file mode 100644 index 0000000000..1ffc1a5cdf --- /dev/null +++ b/internal/authlog/authlog.go @@ -0,0 +1,337 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package authlog records authentication response and error diagnostics. +package authlog + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +// Options configures one authentication logger instance. +type Options struct { + RuntimeDir func() string + Logger *log.Logger + Now func() time.Time + Args func() []string +} + +// Logger records authentication diagnostics in a workspace-specific log. +type Logger struct { + runtimeDir func() string + now func() time.Time + args func() []string + + // mu guards everything below. The file handle is retained so a logger that + // gets superseded can release it instead of waiting for process exit. + mu sync.Mutex + resolved bool + logger *log.Logger + file *os.File +} + +// New creates an authentication logger with explicit dependencies. +func New(options Options) *Logger { + if options.RuntimeDir == nil { + options.RuntimeDir = defaultRuntimeDir + } + if options.Now == nil { + options.Now = time.Now + } + if options.Args == nil { + options.Args = func() []string { return os.Args } + } + return &Logger{ + runtimeDir: options.RuntimeDir, + logger: options.Logger, + now: options.Now, + args: options.Args, + } +} + +// shared holds the process-wide logger. A Logger caches its file handle and +// prunes week-old logs on first use, so constructing one per call would leak a +// descriptor and re-run the prune for every line written. +// +// SetShared is the single writer and runs once while the command factory is +// built, which is also the only place that resolves the workspace-aware runtime +// directory. After the internal/core split, importing internal/workspace here +// would no longer close a cycle — it is a leaf over internal/vfs — but the +// indirection stays on purpose: a factory-installed logger follows the detected +// workspace, while the Shared() fallback below stays on the pre-workspace +// directory. Resolving the directory in this package would collapse that +// distinction. +var ( + sharedMu sync.Mutex + sharedLog *Logger + sharedInstalled bool +) + +// SetShared installs the process-wide authentication logger. Only the first +// explicit install takes effect: the factory can be constructed more than once +// in one process, and letting a later construction swap the logger would open a +// second file and silently move subsequent lines to another workspace +// directory. A lazily created fallback is not an explicit install, so the first +// real one replaces it and closes the file it had opened. +func SetShared(logger *Logger) { + if logger == nil { + return + } + + sharedMu.Lock() + defer sharedMu.Unlock() + if sharedInstalled { + return + } + + superseded := sharedLog + sharedLog = logger + sharedInstalled = true + if superseded != nil { + _ = superseded.close() + } +} + +// Shared returns the process-wide authentication logger. When the command +// factory has not installed one — library callers, tests — it falls back to a +// logger rooted at the pre-workspace runtime directory, matching the behaviour +// of call paths that ran before workspace detection. +func Shared() *Logger { + sharedMu.Lock() + defer sharedMu.Unlock() + if sharedLog == nil { + sharedLog = New(Options{}) + } + return sharedLog +} + +func defaultRuntimeDir() string { + if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { + // Deliberately unvalidated, mirroring workspace.GetBaseConfigDir. Routing this + // through validate.SafeEnvDirPath would also resolve symlinks, changing + // the directory the CLI reports and uses on any host where the path + // crosses one — applying it moved four packages' expectations from /var + // to /private/var on macOS. Whether config paths should be + // symlink-resolved is a decision about the on-disk contract, and it has + // to be made in one place for every reader of this variable. + return dir + } + home, err := vfs.UserHomeDir() + if err != nil || home == "" { + // Silent fallback to a relative ".lark-cli": this package has no + // IOStreams in scope, so we cannot surface a warning here without + // violating the IOStreams injection boundary (enforced by lint). + // Users who hit this path should set LARKSUITE_CLI_CONFIG_DIR + // explicitly; the relative path will otherwise surface as an + // explicit I/O error at first use. + home = "" + } + return filepath.Join(home, ".lark-cli") +} + +func (l *Logger) logDir() string { + // LARKSUITE_CLI_LOG_DIR is the highest-priority override. + // When set, it bypasses workspace subtree routing entirely. + if dir := os.Getenv("LARKSUITE_CLI_LOG_DIR"); dir != "" { + safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_LOG_DIR") + if err == nil { + return safeDir + } + // The caller asked for a specific directory and it was rejected. Staying + // quiet would send the logs elsewhere while they keep watching the path + // they configured. This fires only on a rejected override, and logDir + // resolves once per process. + // + //nolint:forbidigo // leaf package with no IOStreams in scope; keychain + // surfaces its own directory warning the same way. + fmt.Fprintf( + os.Stderr, + "[lark-cli] [WARN] LARKSUITE_CLI_LOG_DIR is unusable (%v); writing auth logs under the default directory instead\n", + err, + ) + } + + return filepath.Join(l.runtimeDir(), "logs") +} + +// writer resolves the destination on first use and returns nil when the log +// file could not be opened, or when this logger has been superseded. +func (l *Logger) writer() *log.Logger { + l.mu.Lock() + defer l.mu.Unlock() + if l.resolved { + return l.logger + } + l.resolved = true + if l.logger != nil { + return l.logger + } + + dir := l.logDir() + now := l.now() + if err := vfs.MkdirAll(dir, 0700); err != nil { + return nil + } + + logName := fmt.Sprintf("auth-%s.log", now.Format("2006-01-02")) + logPath := filepath.Join(dir, logName) + f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil + } + + l.logger = log.New(f, "", 0) + l.file = f + cleanupOldLogs(dir, now) + return l.logger +} + +// close releases the log file, if one was opened, and stops the logger from +// opening another. Writes after this point are dropped rather than landing in a +// file nobody reads. +func (l *Logger) close() error { + if l == nil { + return nil + } + + l.mu.Lock() + defer l.mu.Unlock() + l.resolved = true + l.logger = nil + file := l.file + l.file = nil + if file == nil { + return nil + } + return file.Close() +} + +// maxCmdlineWords keeps the privacy bound the log has always had: the binary +// plus two words. It is a bound, not a measurement of how deep commands go — +// generated service commands reach one level further (`lark-cli drive +// file.comments create_v2`) and lose their last word here. Raising the cap to +// fit them would also let the first positional argument back in, and that is +// where resource identifiers live. +const maxCmdlineWords = 3 + +// FormatAuthCmdline renders the command path for the log under two limits, both +// of which are load-bearing. +// +// It stops at the first flag, because a denylist of sensitive flag names has to +// be extended whenever one is added. It also keeps at most maxCmdlineWords +// words, because positional arguments carry resource identifiers: `api GET +// ` puts a document token in the fourth word. Dropping either limit +// widens what reaches a file that lives for a week — the flag limit alone let +// that token through, and the word limit alone let `--token=... auth login` +// through. +// +// args[0] is reduced to its base name so an absolute install path does not end +// up in the file either. +func FormatAuthCmdline(args []string) string { + if len(args) == 0 { + return "" + } + + words := []string{filepath.Base(args[0])} + dropped := false + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "-") || len(words) == maxCmdlineWords { + dropped = true + break + } + words = append(words, arg) + } + + line := strings.Join(words, " ") + if dropped { + line += " ..." + } + return line +} + +// LogResponse records one authentication HTTP response. +func (l *Logger) LogResponse(path string, status int, logID string) { + if l == nil { + return + } + writer := l.writer() + if writer == nil { + return + } + + writer.Printf( + "[lark-cli] auth-response: time=%s path=%s status=%d x-tt-logid=%s cmdline=%s", + l.now().Format(time.RFC3339Nano), + path, + status, + logID, + FormatAuthCmdline(l.args()), + ) +} + +// LogError records one authentication-related local error. +func (l *Logger) LogError(component, op string, err error) { + if l == nil || err == nil { + return + } + + writer := l.writer() + if writer == nil { + return + } + + writer.Printf( + "[lark-cli] auth-error: time=%s component=%s op=%s error=%q cmdline=%s", + l.now().Format(time.RFC3339Nano), + component, + op, + err.Error(), + FormatAuthCmdline(l.args()), + ) +} + +func cleanupOldLogs(dir string, now time.Time) { + defer func() { + if r := recover(); r != nil { + //nolint:forbidigo // same leaf-package constraint as defaultRuntimeDir: no + // IOStreams in scope, and a panic in background cleanup must still be + // visible. Carried over unchanged from internal/keychain. + fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] background log cleanup panicked: %v\n", r) + } + }() + + entries, err := vfs.ReadDir(dir) + if err != nil { + return + } + + now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + cutoff := now.AddDate(0, 0, -7) + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), "auth-") || !strings.HasSuffix(entry.Name(), ".log") { + continue + } + + dateStr := strings.TrimPrefix(entry.Name(), "auth-") + dateStr = strings.TrimSuffix(dateStr, ".log") + + logDate, err := time.Parse("2006-01-02", dateStr) + if err != nil { + continue + } + + logDate = time.Date(logDate.Year(), logDate.Month(), logDate.Day(), 0, 0, 0, 0, now.Location()) + if logDate.Before(cutoff) { + _ = vfs.Remove(filepath.Join(dir, entry.Name())) + } + } +} diff --git a/internal/authlog/authlog_test.go b/internal/authlog/authlog_test.go new file mode 100644 index 0000000000..1f24304134 --- /dev/null +++ b/internal/authlog/authlog_test.go @@ -0,0 +1,352 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package authlog + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/vfs" +) + +// TestAuthLogDir_UsesValidatedLogDirEnv verifies that a valid absolute +// LARKSUITE_CLI_LOG_DIR is normalized and used as the auth log directory. +func TestAuthLogDir_UsesValidatedLogDirEnv(t *testing.T) { + base := t.TempDir() + base, _ = filepath.EvalSymlinks(base) + t.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(base, "logs", "..", "auth")) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "") + + logger := New(Options{RuntimeDir: func() string { return t.TempDir() }}) + got := logger.logDir() + want := filepath.Join(base, "auth") + if got != want { + t.Fatalf("authLogDir() = %q, want %q", got, want) + } +} + +// TestAuthLogDir_InvalidLogDirFallsBackToConfigDir verifies that an invalid +// LARKSUITE_CLI_LOG_DIR falls back to LARKSUITE_CLI_CONFIG_DIR/logs. +func TestAuthLogDir_InvalidLogDirFallsBackToConfigDir(t *testing.T) { + t.Setenv("LARKSUITE_CLI_LOG_DIR", "relative-logs") + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + logger := New(Options{RuntimeDir: func() string { return configDir }}) + got := logger.logDir() + want := filepath.Join(configDir, "logs") + if got != want { + t.Fatalf("authLogDir() = %q, want %q", got, want) + } +} + +// TestShared_ReturnsOneLoggerPerProcess pins the property the callers depend on: +// every call site must observe the same logger, otherwise each one opens its own +// file handle and re-runs the log prune. +func TestShared_ReturnsOneLoggerPerProcess(t *testing.T) { + restoreShared(t) + + first := Shared() + if first == nil { + t.Fatal("Shared() returned nil") + } + if second := Shared(); second != first { + t.Fatalf("Shared() returned a different logger on the second call: %p then %p", first, second) + } +} + +// TestSetShared_InstalledLoggerWins covers the startup wiring: the command +// factory installs a workspace-aware logger and later callers must get it +// instead of the pre-workspace fallback. +func TestSetShared_InstalledLoggerWins(t *testing.T) { + restoreShared(t) + + installed := New(Options{RuntimeDir: func() string { return "workspace-dir" }}) + SetShared(installed) + if got := Shared(); got != installed { + t.Fatalf("Shared() = %p, want the installed logger %p", got, installed) + } + + // A nil install must not wipe the configured logger. + SetShared(nil) + if got := Shared(); got != installed { + t.Fatalf("SetShared(nil) replaced the logger: got %p, want %p", got, installed) + } +} + +// TestLogger_WritesUnderTheConfiguredRuntimeDir guards the directory the logger +// picks. A caller that supplies a workspace-aware RuntimeDir must get its lines +// there; falling back to the process default silently splits one investigation +// across two files. +func TestLogger_WritesUnderTheConfiguredRuntimeDir(t *testing.T) { + t.Setenv("LARKSUITE_CLI_LOG_DIR", "") + runtimeDir := t.TempDir() + now := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + + logger := New(Options{ + RuntimeDir: func() string { return runtimeDir }, + Now: func() time.Time { return now }, + Args: func() []string { return []string{"lark-cli", "auth", "login"} }, + }) + logger.LogResponse("/open-apis/authen/v1/user_info", 200, "logid-1") + logger.LogError("keychain", "Get", errors.New("keychain locked")) + + path := filepath.Join(runtimeDir, "logs", "auth-2026-07-25.log") + body, err := vfs.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + for _, want := range []string{ + "auth-response:", "status=200", "x-tt-logid=logid-1", + "auth-error:", "component=keychain", "op=Get", "keychain locked", + "cmdline=lark-cli auth login", + } { + if !strings.Contains(string(body), want) { + t.Errorf("log is missing %q:\n%s", want, body) + } + } +} + +// TestLogger_NilReceiverIsInert covers the guard both entry points carry, so a +// caller holding no logger cannot turn a diagnostic into a crash. +func TestLogger_NilReceiverIsInert(t *testing.T) { + var logger *Logger + logger.LogResponse("/path", 500, "logid") + logger.LogError("keychain", "Get", errors.New("boom")) +} + +// TestCleanupOldLogs_PrunesOnlyExpiredAuthLogs pins the retention window and the +// filename patterns it is allowed to touch. +func TestCleanupOldLogs_PrunesOnlyExpiredAuthLogs(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + survives := map[string]bool{ + "auth-2026-07-25.log": true, // today + "auth-2026-07-18.log": true, // exactly at the seven-day cutoff + "auth-2026-07-17.log": false, // past the cutoff + "auth-not-a-date.log": true, // unparsable, left alone + "other-2020-01-01.log": true, // not an auth log + } + for name := range survives { + if err := vfs.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil { + t.Fatalf("seed %s: %v", name, err) + } + } + + cleanupOldLogs(dir, now) + + for name, want := range survives { + _, err := vfs.Stat(filepath.Join(dir, name)) + if got := err == nil; got != want { + t.Errorf("%s present = %v, want %v", name, got, want) + } + } +} + +// TestSetShared_FirstExplicitInstallWins covers a factory being constructed more +// than once in one process. A later install must not swap the logger: that would +// open a second file and move subsequent lines to another workspace directory. +func TestSetShared_FirstExplicitInstallWins(t *testing.T) { + restoreShared(t) + + first := New(Options{RuntimeDir: func() string { return "first" }}) + second := New(Options{RuntimeDir: func() string { return "second" }}) + SetShared(first) + SetShared(second) + + if got := Shared(); got != first { + t.Fatalf("Shared() = %p, want the first installed logger %p", got, first) + } +} + +// TestSetShared_ReleasesTheSupersededFallback pins the one replacement that is +// allowed: a lazily created fallback gives way to the first explicit install, +// and its file handle is released rather than held until the process exits. +func TestSetShared_ReleasesTheSupersededFallback(t *testing.T) { + restoreShared(t) + t.Setenv("LARKSUITE_CLI_LOG_DIR", "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + fallback := Shared() + fallback.LogError("keychain", "Get", errors.New("before the factory exists")) + fallback.mu.Lock() + opened := fallback.file != nil + fallback.mu.Unlock() + if !opened { + t.Fatal("fallback did not open a log file, so the release path is untested") + } + + installed := New(Options{RuntimeDir: func() string { return t.TempDir() }}) + SetShared(installed) + + if got := Shared(); got != installed { + t.Fatalf("Shared() = %p, want the installed logger %p", got, installed) + } + fallback.mu.Lock() + defer fallback.mu.Unlock() + if fallback.file != nil { + t.Error("superseded fallback still holds its file handle") + } + if fallback.logger != nil { + t.Error("superseded fallback can still write") + } +} + +// TestFormatAuthCmdline_DropsEverythingFromTheFirstFlag is the regression guard +// for the rule this replaced. Keeping the first three arguments was safe only +// while no sensitive flag appeared early; a global flag in front of the +// subcommand put its value straight into the file. +func TestFormatAuthCmdline_DropsEverythingFromTheFirstFlag(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + { + name: "flag before the subcommand", + args: []string{"lark-cli", "--token=super-secret", "auth", "login"}, + want: "lark-cli ...", + }, + { + name: "separated flag value", + args: []string{"lark-cli", "auth", "login", "--device-code", "device-secret"}, + want: "lark-cli auth login ...", + }, + { + name: "absolute install path is reduced to the binary name", + args: []string{"/opt/internal-tools/build-42/lark-cli", "auth", "status"}, + want: "lark-cli auth status", + }, + { + name: "no flags at all", + args: []string{"lark-cli", "auth", "status"}, + want: "lark-cli auth status", + }, + { + // `api ` puts a document token in the fourth word. + // Stopping at the first flag is not enough on its own; the word cap + // is what keeps a positional identifier out of the file. + name: "positional resource identifier past the cap", + args: []string{"lark-cli", "api", "GET", "/open-apis/docx/v1/documents/doccnDOCTOKEN"}, + want: "lark-cli api GET ...", + }, + { + name: "cap applies before any flag appears", + args: []string{"lark-cli", "drive", "upload", "/home/me/private/quarterly.xlsx", "--to", "folder"}, + want: "lark-cli drive upload ...", + }, + { + // Generated service commands are one level deeper than the cap, so + // their last word is lost. Recorded here so the trade-off is visible: + // widening the cap to keep it would also admit the first positional + // argument. + name: "generated service command loses its verb to the cap", + args: []string{"lark-cli", "drive", "file.comments", "create_v2"}, + want: "lark-cli drive file.comments ...", + }, + { + name: "empty", + args: nil, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := FormatAuthCmdline(tc.args) + if got != tc.want { + t.Fatalf("FormatAuthCmdline(%q) = %q, want %q", tc.args, got, tc.want) + } + for _, arg := range tc.args { + for _, marker := range []string{"secret", "TOKEN", "private"} { + if strings.Contains(arg, marker) && strings.Contains(got, marker) { + t.Fatalf("FormatAuthCmdline leaked %q into %q", arg, got) + } + } + } + }) + } +} + +// TestAuthLogDir_RejectedOverrideWarns covers the one case where staying silent +// misleads: the caller set the directory explicitly and it was refused. +func TestAuthLogDir_RejectedOverrideWarns(t *testing.T) { + t.Setenv("LARKSUITE_CLI_LOG_DIR", "relative-logs") + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + logger := New(Options{RuntimeDir: func() string { return configDir }}) + warning := captureStderr(t, func() { _ = logger.logDir() }) + + for _, want := range []string{"LARKSUITE_CLI_LOG_DIR", "default directory"} { + if !strings.Contains(warning, want) { + t.Errorf("warning %q does not mention %q", warning, want) + } + } +} + +// TestAuthLogDir_AcceptedOverrideStaysQuiet keeps the warning scoped to the +// failure: a usable override must not print anything. +func TestAuthLogDir_AcceptedOverrideStaysQuiet(t *testing.T) { + base := t.TempDir() + base, _ = filepath.EvalSymlinks(base) + t.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(base, "auth")) + + logger := New(Options{RuntimeDir: func() string { return base }}) + if warning := captureStderr(t, func() { _ = logger.logDir() }); warning != "" { + t.Fatalf("a usable override printed %q", warning) + } +} + +// captureStderr redirects os.Stderr for the duration of fn and returns what was +// written to it. +// +// This one uses os rather than vfs on purpose. os.Pipe and os.Stderr are process +// contracts, not filesystem access, so vfs neither wraps them nor should: the +// code under test writes to the real stderr and there is nothing for a +// substituted vfs.DefaultFS to intercept. Assertions that touch files stay on +// vfs so they follow whatever the implementation uses. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + previous := os.Stderr + os.Stderr = writer + defer func() { os.Stderr = previous }() + + fn() + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + out, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read captured stderr: %v", err) + } + return string(out) +} + +// restoreShared isolates each test from the process-wide logger. +func restoreShared(t *testing.T) { + t.Helper() + + sharedMu.Lock() + previousLog, previousInstalled := sharedLog, sharedInstalled + sharedLog, sharedInstalled = nil, false + sharedMu.Unlock() + + t.Cleanup(func() { + sharedMu.Lock() + sharedLog, sharedInstalled = previousLog, previousInstalled + sharedMu.Unlock() + }) +} diff --git a/internal/client/client.go b/internal/client/client.go index f40a1057b4..7c522d868a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -18,10 +18,12 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/errclass" + identitypkg "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ) @@ -32,20 +34,20 @@ type RawApiRequest struct { URL string Params map[string]interface{} Data interface{} - As core.Identity + As identitypkg.Identity ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers) } // APIClient wraps lark.Client for all Lark Open API calls. type APIClient struct { - Config *core.CliConfig + Config *configpkg.CliConfig SDK *lark.Client // All Lark API calls go through SDK HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.) ErrOut io.Writer // debug/progress output Credential *credential.CredentialProvider } -func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (string, error) { +func (c *APIClient) resolveAccessToken(ctx context.Context, as identitypkg.Identity) (string, error) { result, err := c.Credential.ResolveToken(ctx, credential.NewTokenSpec(as, c.Config.AppID)) if err != nil { var unavailableErr *credential.TokenUnavailableError @@ -67,10 +69,10 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s // newTokenMissingError builds the typed *errs.AuthenticationError that // resolveAccessToken returns when no usable token is available for the -// requested identity. cause is the underlying credential-chain error (or nil +// requested identitypkg. cause is the underlying credential-chain error (or nil // for the defensive empty-token branch) and is preserved for errors.Is / // errors.Unwrap traversal without being serialized on the wire. -func newTokenMissingError(as core.Identity, cause error) error { +func newTokenMissingError(as identitypkg.Identity, cause error) error { return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "no access token available for %s", as). WithHint("run: lark-cli auth login to re-authorize"). @@ -118,7 +120,7 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark // contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified // (a typed *errs.* from resolveAccessToken's missing-credential paths or // elsewhere) flow through unchanged. -func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { +func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as identitypkg.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { var opts []larkcore.RequestOptionFunc token, err := c.resolveAccessToken(ctx, as) @@ -154,7 +156,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c // any extra headers from opts are applied automatically. // HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB), // closed, and returned as a typed *errs.NetworkError — callers only receive successful responses. -func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) { +func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as identitypkg.Identity, opts ...Option) (*http.Response, error) { cfg := buildConfig(opts) // Resolve auth @@ -262,7 +264,7 @@ func (r *cancelOnCloseBody) Close() error { return err } -func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) { +func buildStreamURL(brand brandpkg.Brand, req *larkcore.ApiReq) (string, error) { requestURL := req.ApiPath if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") { var pathSegs []string @@ -281,7 +283,7 @@ func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) } pathSegs = append(pathSegs, url.PathEscape(pathValue)) } - endpoints := core.ResolveEndpoints(brand) + endpoints := brandpkg.ResolveEndpoints(brand) requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/") } if query := req.QueryParams.Encode(); query != "" { @@ -490,7 +492,7 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt // the canonical Category/Subtype + identity-aware extension fields (MissingScopes, // ConsoleURL, etc.) for known Lark codes; unknown codes still surface as // *errs.APIError{Subtype: unknown}. -func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error { +func (c *APIClient) CheckResponse(result interface{}, identity identitypkg.Identity) error { resultMap, ok := result.(map[string]interface{}) if !ok || resultMap == nil { return nil diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 97018078d0..5f6051ff6c 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -19,10 +19,12 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" internalauth "github.com/larksuite/cli/internal/auth" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) @@ -59,7 +61,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu lark.WithHttpClient(httpClient), ) testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil) - cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu} return &APIClient{ SDK: sdk, ErrOut: errBuf, @@ -464,13 +466,13 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) { ac := &APIClient{ HTTP: &http.Client{Timeout: 5 * time.Millisecond}, Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil), - Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu}, } resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: srv.URL, - }, core.AsBot) + }, identity.AsBot) if err != nil { t.Fatalf("DoStream() error = %v", err) } @@ -499,13 +501,13 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) { ac := &APIClient{ HTTP: &http.Client{Transport: rt}, Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil), - Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu}, } _, err := ac.DoStream(context.Background(), &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: "/open-apis/drive/v1/files/file_token/download", - }, core.AsBot) + }, identity.AsBot) if err == nil { t.Fatal("expected DNS error from DoStream transport, got nil") } @@ -533,10 +535,10 @@ func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T ac := &APIClient{ HTTP: &http.Client{}, Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil), - Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu}, } - _, err := ac.resolveAccessToken(context.Background(), core.AsUser) + _, err := ac.resolveAccessToken(context.Background(), identity.AsUser) if err == nil { t.Fatal("expected error when no token available, got nil") } @@ -573,10 +575,10 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t ac := &APIClient{ HTTP: &http.Client{}, Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil), - Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu}, } - _, err := ac.resolveAccessToken(context.Background(), core.AsUser) + _, err := ac.resolveAccessToken(context.Background(), identity.AsUser) if err == nil { t.Fatal("expected error when credential chain signals need_user_authorization, got nil") } @@ -613,13 +615,13 @@ func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) ac := &APIClient{ HTTP: &http.Client{}, Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil), - Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu}, } _, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: "/open-apis/contact/v3/users/me", - }, core.AsUser) + }, identity.AsUser) if err == nil { t.Fatal("expected auth error, got nil") @@ -647,7 +649,7 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) { _, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: "/open-apis/contact/v3/users/me", - }, core.AsBot) + }, identity.AsBot) if err == nil { t.Fatal("expected error from broken transport, got nil") diff --git a/internal/client/dostream_test.go b/internal/client/dostream_test.go index 0a33868a43..44c16d0e97 100644 --- a/internal/client/dostream_test.go +++ b/internal/client/dostream_test.go @@ -11,16 +11,18 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" ) func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu} + config := &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu} factory, _, _, reg := cmdutil.TestFactory(t, config) reg.Register(&httpmock.Stub{ Method: http.MethodGet, @@ -40,7 +42,7 @@ func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) { _, err = client.DoStream(context.Background(), &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: "/open-apis/drive/v1/medias/file_token/download", - }, core.AsBot) + }, identity.AsBot) var netErr *errs.NetworkError if !errors.As(err, &netErr) { t.Fatalf("expected *errs.NetworkError, got %T %v", err, err) diff --git a/internal/client/pagination.go b/internal/client/pagination.go index 91dc067ed5..8d79170f6e 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -7,15 +7,15 @@ import ( "fmt" "io" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) // PaginationOptions contains pagination control options. type PaginationOptions struct { - PageLimit int // max pages to fetch; 0 = unlimited (default: 10) - PageDelay int // ms, default 200 - Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty + PageLimit int // max pages to fetch; 0 = unlimited (default: 10) + PageDelay int // ms, default 200 + Identity identity.Identity // identity passed to checkErr; defaults to AsUser when empty } func mergePagedResults(w io.Writer, results []interface{}) interface{} { diff --git a/internal/client/response.go b/internal/client/response.go index 36ddc1ca38..df7b86dbb9 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -16,7 +16,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/core" + identitypkg "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ) @@ -34,11 +34,11 @@ type ResponseOptions struct { CommandPath string // raw cobra CommandPath() for content safety scanning // Identity is forwarded to CheckError (default or caller-supplied) so the // classifier can populate identity-aware fields (e.g. PermissionError.Identity). - // Defaults to core.AsUser when empty. - Identity core.Identity + // Defaults to identitypkg.AsUser when empty. + Identity identitypkg.Identity // CheckError is called on parsed JSON results. Nil defaults to (*APIClient).CheckResponse // with the Identity field (or AsUser when unset). - CheckError func(result interface{}, identity core.Identity) error + CheckError func(result interface{}, identity identitypkg.Identity) error } // httpStatusError classifies an HTTP error response by status when the body @@ -69,7 +69,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { ct := resp.Header.Get("Content-Type") identity := opts.Identity if identity == "" { - identity = core.AsUser + identity = identitypkg.AsUser } check := opts.CheckError if check == nil { @@ -77,7 +77,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { // *errs.PermissionError / AuthenticationError / etc. A zero-value // *APIClient is safe here because BuildAPIError gracefully degrades // identity-aware fields (ConsoleURL etc.) when AppID is empty. - check = func(r interface{}, id core.Identity) error { + check = func(r interface{}, id identitypkg.Identity) error { return (&APIClient{}).CheckResponse(r, id) } } diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 28ae282659..f8f3a57906 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -17,8 +17,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs/localfileio" ) @@ -210,7 +210,7 @@ func TestHandleResponse_JSON(t *testing.T) { var out bytes.Buffer var errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ - Identity: core.AsBot, + Identity: identity.AsBot, Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}, @@ -302,7 +302,7 @@ func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testin var errOut bytes.Buffer err = HandleResponse(resp, ResponseOptions{ Format: tt.format, - Identity: core.AsBot, + Identity: identity.AsBot, Out: &out, ErrOut: &errOut, CommandPath: "lark-cli api GET", @@ -328,7 +328,7 @@ func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) { var out bytes.Buffer var errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ - Identity: core.AsBot, + Identity: identity.AsBot, JqExpr: ".data.id", Out: &out, ErrOut: &errOut, diff --git a/internal/cmdutil/dryrun.go b/internal/cmdutil/dryrun.go index 4afa6f75f5..d5b71fd2c0 100644 --- a/internal/cmdutil/dryrun.go +++ b/internal/cmdutil/dryrun.go @@ -14,7 +14,8 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ) @@ -26,7 +27,7 @@ type DryRunOutputOptions struct { Format string JqExpr string CommandPath string - Identity core.Identity + Identity identity.Identity Out io.Writer ErrOut io.Writer } @@ -246,7 +247,7 @@ func encodeParams(params map[string]interface{}) string { // buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL, // query params, and the app/user context common to every dry-run. -func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI { +func buildDryRunPreview(request client.RawApiRequest, config *configpkg.CliConfig) *DryRunAPI { dr := NewDryRunAPI().call(request.Method, request.URL) if len(request.Params) > 0 { dr.Params(request.Params) @@ -258,7 +259,7 @@ func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *D // PrintDryRunWithFile outputs a dry-run summary for file upload requests. // Instead of serializing the Formdata body, it shows file metadata. -func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error { +func PrintDryRunWithFile(request client.RawApiRequest, config *configpkg.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error { dr := buildDryRunPreview(request, config) filePathDisplay := file.FilePath if filePathDisplay == "" { @@ -277,7 +278,7 @@ func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, o // PrintDryRun outputs a standardised dry-run summary using DryRunAPI. // When format is "pretty", outputs human-readable text; otherwise JSON. -func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error { +func PrintDryRun(request client.RawApiRequest, config *configpkg.CliConfig, opts DryRunOutputOptions) error { dr := buildDryRunPreview(request, config) if !util.IsNil(request.Data) { dr.Body(request.Data) diff --git a/internal/cmdutil/dryrun_test.go b/internal/cmdutil/dryrun_test.go index 6056bce188..a21c870336 100644 --- a/internal/cmdutil/dryrun_test.go +++ b/internal/cmdutil/dryrun_test.go @@ -13,7 +13,8 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" ) func TestDryRunAPI_SingleGET(t *testing.T) { @@ -151,10 +152,10 @@ func TestPrintDryRun_JSON(t *testing.T) { Method: "GET", URL: "/open-apis/test", As: "user", - }, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{ + }, &configpkg.CliConfig{AppID: "app123"}, DryRunOutputOptions{ Format: "json", CommandPath: "lark-cli api", - Identity: core.AsUser, + Identity: identity.AsUser, Out: &buf, ErrOut: &errBuf, }) @@ -201,9 +202,9 @@ func TestPrintDryRun_Pretty(t *testing.T) { URL: "/open-apis/test", Data: map[string]interface{}{"key": "val"}, As: "bot", - }, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{ + }, &configpkg.CliConfig{AppID: "app456"}, DryRunOutputOptions{ Format: "pretty", - Identity: core.AsBot, + Identity: identity.AsBot, Out: &buf, ErrOut: &errBuf, }) @@ -231,10 +232,10 @@ func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) { Method: "GET", URL: "/open-apis/test", As: "bot", - }, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{ + }, &configpkg.CliConfig{AppID: "app123"}, DryRunOutputOptions{ Format: "json", JqExpr: ".data.api[0].url", - Identity: core.AsBot, + Identity: identity.AsBot, Out: &buf, ErrOut: io.Discard, }) @@ -252,9 +253,9 @@ func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) { Method: "POST", URL: "/open-apis/drive/v1/files/upload_all", As: "bot", - }, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{ + }, &configpkg.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{ Format: "json", - Identity: core.AsBot, + Identity: identity.AsBot, Out: &buf, ErrOut: io.Discard, }, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}}) @@ -293,9 +294,9 @@ func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) { Method: "OPTIONS", URL: "/open-apis/test", As: "bot", - }, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{ + }, &configpkg.CliConfig{AppID: "app123"}, DryRunOutputOptions{ Format: "json", - Identity: core.AsBot, + Identity: identity.AsBot, Out: &buf, ErrOut: io.Discard, }) @@ -317,7 +318,7 @@ func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) { err := PrintDryRun(client.RawApiRequest{ Method: "GET", URL: "/open-apis/test", - }, &core.CliConfig{}, DryRunOutputOptions{ + }, &configpkg.CliConfig{}, DryRunOutputOptions{ Format: "json", Out: &buf, ErrOut: io.Discard, diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 1b167b7863..c753ecbbd0 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -17,8 +17,9 @@ import ( extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/keychain" ) @@ -30,15 +31,15 @@ type InvocationContext struct { } type Factory struct { - Config func() (*core.CliConfig, error) // lazily loads app config from Credential - HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers) - LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls - IOStreams *IOStreams // stdin/stdout/stderr streams + Config func() (*configpkg.CliConfig, error) // lazily loads app config from Credential + HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers) + LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls + IOStreams *IOStreams // stdin/stdout/stderr streams Invocation InvocationContext // Immutable call context; do not mutate after Factory construction. Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests) IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected - ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call + ResolvedIdentity identity.Identity // identity resolved by the last ResolveAs call CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun Credential *credential.CredentialProvider @@ -60,11 +61,11 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO { // ResolveAs returns the effective identity type. // If the user explicitly passed --as, use that value; otherwise use the configured default. // When the value is "auto" (or unset), auto-detect based on credential hints. -func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity { +func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs identity.Identity) identity.Identity { f.IdentityAutoDetected = false if cmd != nil && cmd.Flags().Changed("as") { - if flagAs != core.AsAuto { + if flagAs != identity.AsAuto { f.ResolvedIdentity = flagAs return flagAs } @@ -81,7 +82,7 @@ func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core hint := f.resolveIdentityHint(ctx) if cmd == nil || !cmd.Flags().Changed("as") { - if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto { + if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != identity.AsAuto { f.ResolvedIdentity = defaultAs return f.ResolvedIdentity } @@ -94,18 +95,18 @@ func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core return result } -func resolveDefaultAsFromHint(hint *credential.IdentityHint) core.Identity { +func resolveDefaultAsFromHint(hint *credential.IdentityHint) identity.Identity { if hint != nil { return hint.DefaultAs } return "" } -func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity { +func autoDetectIdentityFromHint(hint *credential.IdentityHint) identity.Identity { if hint != nil && hint.AutoAs != "" { return hint.AutoAs } - return core.AsBot + return identity.AsBot } func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint { @@ -122,7 +123,7 @@ func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityH // CheckIdentity verifies the resolved identity is in the supported list. // On success, sets f.ResolvedIdentity. On failure, returns an error // tailored to whether the identity was explicit (--as) or auto-detected. -func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { +func (f *Factory) CheckIdentity(as identity.Identity, supported []string) error { for _, t := range supported { if string(as) == t { f.ResolvedIdentity = as @@ -147,27 +148,27 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { // ResolveStrictMode returns the effective strict mode by reading // Account.SupportedIdentities from the credential provider chain. -func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode { +func (f *Factory) ResolveStrictMode(ctx context.Context) identity.StrictMode { if f.Credential == nil { - return core.StrictModeOff + return identity.StrictModeOff } acct, err := f.Credential.ResolveAccount(ctx) if err != nil || acct == nil { - return core.StrictModeOff + return identity.StrictModeOff } ids := extcred.IdentitySupport(acct.SupportedIdentities) switch { case ids.BotOnly(): - return core.StrictModeBot + return identity.StrictModeBot case ids.UserOnly(): - return core.StrictModeUser + return identity.StrictModeUser default: - return core.StrictModeOff + return identity.StrictModeOff } } // CheckStrictMode returns an error if strict mode is active and identity is not allowed. -func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error { +func (f *Factory) CheckStrictMode(ctx context.Context, as identity.Identity) error { mode := f.ResolveStrictMode(ctx) if mode.IsActive() && !mode.AllowsIdentity(as) { return errs.NewValidationError(errs.SubtypeInvalidArgument, @@ -189,7 +190,7 @@ func (f *Factory) NewAPIClient() (*client.APIClient, error) { // NewAPIClientWithConfig creates an APIClient with an explicit config. // Use this when the caller has already resolved the correct config. -func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) { +func (f *Factory) NewAPIClientWithConfig(cfg *configpkg.CliConfig) (*client.APIClient, error) { sdk, err := f.LarkClient() if err != nil { return nil, err diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 9527346a7b..909dfa8746 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -15,10 +15,12 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/brand" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/authlog" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/registry" @@ -26,6 +28,7 @@ import ( _ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider "github.com/larksuite/cli/internal/transport" _ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider + "github.com/larksuite/cli/internal/workspace" ) // NewDefault creates a production Factory with cached closures. @@ -46,16 +49,18 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { // Workspace detection: determines which config subtree to use. // Must run before any config or credential load, since those paths are // workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged. - ws := core.DetectWorkspaceFromEnv(os.Getenv) - core.SetCurrentWorkspace(ws) + ws := workspace.DetectWorkspaceFromEnv(os.Getenv) + workspace.SetCurrentWorkspace(ws) - // Inject workspace-aware dir into keychain's log system. - // This breaks the core↔keychain import cycle by using a function variable. - keychain.RuntimeDirFunc = core.GetRuntimeDir + // Auth diagnostics: install the one logger the whole process shares, now + // that the workspace is known. authlog cannot resolve the workspace-aware + // directory itself (core imports keychain, which imports authlog), so this + // is the only place that can supply it. + authlog.SetShared(authlog.New(authlog.Options{RuntimeDir: workspace.GetRuntimeDir})) // Phase 0: FileIO provider (no dependency) f.FileIOProvider = fileio.GetProvider() - workspaceConfig := core.NewConfigSnapshot() + workspaceConfig := configpkg.NewConfigSnapshot() // Phase 1: HttpClient (no credential dependency) f.HttpClient = cachedHttpClientFunc(f, workspaceConfig) @@ -70,7 +75,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { }) // Phase 3: Runtime config contains resolved account data only. - f.Config = sync.OnceValues(func() (*core.CliConfig, error) { + f.Config = sync.OnceValues(func() (*configpkg.CliConfig, error) { acct, err := f.Credential.ResolveAccount(context.Background()) if err != nil { return nil, err @@ -159,7 +164,7 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun Transport: sdkTransport, CheckRedirect: safeRedirectPolicy, })) - ep := core.ResolveEndpoints(acct.Brand) + ep := brand.ResolveEndpoints(acct.Brand) opts = append(opts, lark.WithOpenBaseUrl(ep.Open)) return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil }) diff --git a/internal/cmdutil/factory_default_test.go b/internal/cmdutil/factory_default_test.go index 1f47971ceb..90154cfd36 100644 --- a/internal/cmdutil/factory_default_test.go +++ b/internal/cmdutil/factory_default_test.go @@ -8,12 +8,15 @@ import ( "errors" "testing" + "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/errs" _ "github.com/larksuite/cli/extension/credential/env" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" - "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/identity" + "github.com/larksuite/cli/internal/secret" "github.com/larksuite/cli/internal/vfs/localfileio" ) @@ -29,40 +32,40 @@ func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO { } func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) { - t.Setenv(envvars.CliAppID, "") - t.Setenv(envvars.CliAppSecret, "") - t.Setenv(envvars.CliUserAccessToken, "") - t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envnames.CliAppID, "") + t.Setenv(envnames.CliAppSecret, "") + t.Setenv(envnames.CliUserAccessToken, "") + t.Setenv(envnames.CliTenantAccessToken, "") dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - bot := core.StrictModeBot - multi := &core.MultiAppConfig{ + bot := identity.StrictModeBot + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "default", AppId: "app-default", - AppSecret: core.PlainSecret("secret-default"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-default"), + Brand: brand.Feishu, }, { Name: "target", AppId: "app-target", - AppSecret: core.PlainSecret("secret-target"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-target"), + Brand: brand.Feishu, StrictMode: &bot, }, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } f := NewDefault(nil, InvocationContext{Profile: "target"}) - if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot { - t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot) + if got := f.ResolveStrictMode(context.Background()); got != identity.StrictModeBot { + t.Fatalf("ResolveStrictMode() = %q, want %q", got, identity.StrictModeBot) } cfg, err := f.Config() if err != nil { @@ -77,32 +80,32 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) { } func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testing.T) { - t.Setenv(envvars.CliAppID, "") - t.Setenv(envvars.CliAppSecret, "") - t.Setenv(envvars.CliUserAccessToken, "") - t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envnames.CliAppID, "") + t.Setenv(envnames.CliAppSecret, "") + t.Setenv(envnames.CliUserAccessToken, "") + t.Setenv(envnames.CliTenantAccessToken, "") dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - multi := &core.MultiAppConfig{ + multi := &configpkg.MultiAppConfig{ CurrentApp: "default", - Apps: []core.AppConfig{ + Apps: []configpkg.AppConfig{ { Name: "default", AppId: "app-default", - AppSecret: core.PlainSecret("secret-default"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("secret-default"), + Brand: brand.Feishu, }, }, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig() error = %v", err) } f := NewDefault(nil, InvocationContext{Profile: "missing"}) - if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff { - t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff) + if got := f.ResolveStrictMode(context.Background()); got != identity.StrictModeOff { + t.Fatalf("ResolveStrictMode() = %q, want %q", got, identity.StrictModeOff) } _, err := f.Config() if err == nil { @@ -118,19 +121,19 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi } func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) { - t.Setenv(envvars.CliAppID, "env-app") - t.Setenv(envvars.CliAppSecret, "env-secret") - t.Setenv(envvars.CliDefaultAs, "user") - t.Setenv(envvars.CliUserAccessToken, "") - t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envnames.CliAppID, "env-app") + t.Setenv(envnames.CliAppSecret, "env-secret") + t.Setenv(envnames.CliDefaultAs, "user") + t.Setenv(envnames.CliUserAccessToken, "") + t.Setenv(envnames.CliTenantAccessToken, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f := NewDefault(nil, InvocationContext{}) cmd := newCmdWithAsFlag("auto", false) got := f.ResolveAs(context.Background(), cmd, "auto") - if got != core.AsUser { - t.Fatalf("ResolveAs() = %q, want %q", got, core.AsUser) + if got != identity.AsUser { + t.Fatalf("ResolveAs() = %q, want %q", got, identity.AsUser) } if f.IdentityAutoDetected { t.Fatal("IdentityAutoDetected = true, want false") @@ -138,11 +141,11 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) { } func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) { - t.Setenv(envvars.CliAppID, "env-app") - t.Setenv(envvars.CliAppSecret, "env-secret") - t.Setenv(envvars.CliDefaultAs, "") - t.Setenv(envvars.CliUserAccessToken, "uat-token") - t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envnames.CliAppID, "env-app") + t.Setenv(envnames.CliAppSecret, "env-secret") + t.Setenv(envnames.CliDefaultAs, "") + t.Setenv(envnames.CliUserAccessToken, "uat-token") + t.Setenv(envnames.CliTenantAccessToken, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f := NewDefault(nil, InvocationContext{}) @@ -163,11 +166,11 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) } func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testing.T) { - t.Setenv(envvars.CliAppID, "env-app") - t.Setenv(envvars.CliAppSecret, "") - t.Setenv(envvars.CliDefaultAs, "") - t.Setenv(envvars.CliUserAccessToken, "uat-token") - t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envnames.CliAppID, "env-app") + t.Setenv(envnames.CliAppSecret, "") + t.Setenv(envnames.CliDefaultAs, "") + t.Setenv(envnames.CliUserAccessToken, "uat-token") + t.Setenv(envnames.CliTenantAccessToken, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f := NewDefault(nil, InvocationContext{}) diff --git a/internal/cmdutil/factory_http_test.go b/internal/cmdutil/factory_http_test.go index 0cea52878c..dca61ae76c 100644 --- a/internal/cmdutil/factory_http_test.go +++ b/internal/cmdutil/factory_http_test.go @@ -7,14 +7,14 @@ import ( "io" "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) { isEnabled := false - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "test-app"}) f.IOStreams.ErrOut = io.Discard - fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}}) + fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &configpkg.MultiAppConfig{RiskControl: &isEnabled}}) c1, err := fn() if err != nil { @@ -35,9 +35,9 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) { func TestCachedHttpClientFunc_HasTimeout(t *testing.T) { isEnabled := false - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "test-app"}) f.IOStreams.ErrOut = io.Discard - fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}}) + fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &configpkg.MultiAppConfig{RiskControl: &isEnabled}}) c, _ := fn() if c.Timeout == 0 { t.Error("expected non-zero timeout") @@ -46,9 +46,9 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) { func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) { isEnabled := false - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "test-app"}) f.IOStreams.ErrOut = io.Discard - fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}}) + fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &configpkg.MultiAppConfig{RiskControl: &isEnabled}}) c, _ := fn() if c.CheckRedirect == nil { t.Error("expected CheckRedirect to be set (safeRedirectPolicy)") diff --git a/internal/cmdutil/factory_proxy_warn_test.go b/internal/cmdutil/factory_proxy_warn_test.go index 0bf2e4bed4..f17652a4c4 100644 --- a/internal/cmdutil/factory_proxy_warn_test.go +++ b/internal/cmdutil/factory_proxy_warn_test.go @@ -7,9 +7,9 @@ import ( "io" "testing" + "github.com/larksuite/cli/envnames" _ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/envvars" + configpkg "github.com/larksuite/cli/internal/config" ) // installProxyWarnSpy replaces warnIfProxied with a counter for one test and @@ -42,10 +42,10 @@ func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) { t.Run(tc.name, func(t *testing.T) { calls := installProxyWarnSpy(t) - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "test-app"}) f.IOStreams.ErrOut = io.Discard f.IOStreams.StderrIsTerminal = tc.terminal - fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}}) + fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &configpkg.MultiAppConfig{RiskControl: &isEnabled}}) if _, err := fn(); err != nil { t.Fatalf("http client init: %v", err) } @@ -64,11 +64,11 @@ func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) { func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) { for _, tc := range proxyWarnGateCases { t.Run(tc.name, func(t *testing.T) { - t.Setenv(envvars.CliAppID, "env-app") - t.Setenv(envvars.CliAppSecret, "env-secret") - t.Setenv(envvars.CliDefaultAs, "") - t.Setenv(envvars.CliUserAccessToken, "") - t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envnames.CliAppID, "env-app") + t.Setenv(envnames.CliAppSecret, "env-secret") + t.Setenv(envnames.CliDefaultAs, "") + t.Setenv(envnames.CliUserAccessToken, "") + t.Setenv(envnames.CliTenantAccessToken, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) calls := installProxyWarnSpy(t) diff --git a/internal/cmdutil/factory_test.go b/internal/cmdutil/factory_test.go index 97eed80975..e788bd1765 100644 --- a/internal/cmdutil/factory_test.go +++ b/internal/cmdutil/factory_test.go @@ -11,11 +11,13 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/envnames" "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" - "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) @@ -32,30 +34,30 @@ func newCmdWithAsFlag(asValue string, changed bool) *cobra.Command { // --- ResolveAs tests --- func TestResolveAs_ExplicitAs(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := newCmdWithAsFlag("bot", true) - got := f.ResolveAs(context.Background(), cmd, core.AsBot) - if got != core.AsBot { + got := f.ResolveAs(context.Background(), cmd, identity.AsBot) + if got != identity.AsBot { t.Errorf("want bot, got %s", got) } if f.IdentityAutoDetected { t.Error("IdentityAutoDetected should be false for explicit --as") } - if f.ResolvedIdentity != core.AsBot { + if f.ResolvedIdentity != identity.AsBot { t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity) } } func TestResolveAs_ExplicitAsUser(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := newCmdWithAsFlag("user", true) - got := f.ResolveAs(context.Background(), cmd, core.AsUser) - if got != core.AsUser { + got := f.ResolveAs(context.Background(), cmd, identity.AsUser) + if got != identity.AsUser { t.Errorf("want user, got %s", got) } - if f.ResolvedIdentity != core.AsUser { + if f.ResolvedIdentity != identity.AsUser { t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity) } } @@ -63,11 +65,11 @@ func TestResolveAs_ExplicitAsUser(t *testing.T) { func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) { // --as auto explicitly: should fall through to auto-detect // Config has no UserOpenId → auto-detect returns bot - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := newCmdWithAsFlag("auto", true) got := f.ResolveAs(context.Background(), cmd, "auto") - if got != core.AsBot { + if got != identity.AsBot { t.Errorf("want bot (auto-detect, no login), got %s", got) } if !f.IdentityAutoDetected { @@ -76,14 +78,14 @@ func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) { } func TestResolveAs_DefaultAs_FromConfig(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{ + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{ AppID: "a", AppSecret: "s", DefaultAs: "bot", }) cmd := newCmdWithAsFlag("auto", false) // --as not changed got := f.ResolveAs(context.Background(), cmd, "auto") - if got != core.AsBot { + if got != identity.AsBot { t.Errorf("want bot (from default-as config), got %s", got) } if f.IdentityAutoDetected { @@ -92,13 +94,13 @@ func TestResolveAs_DefaultAs_FromConfig(t *testing.T) { } func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) { - t.Setenv(envvars.CliDefaultAs, "user") + t.Setenv(envnames.CliDefaultAs, "user") - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := newCmdWithAsFlag("auto", false) got := f.ResolveAs(context.Background(), cmd, "auto") - if got != core.AsBot { + if got != identity.AsBot { t.Errorf("want bot (env default-as should not bypass config source), got %s", got) } if !f.IdentityAutoDetected { @@ -108,7 +110,7 @@ func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) { func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) { // default-as = "auto" should fall through to auto-detect - f, _, _, _ := TestFactory(t, &core.CliConfig{ + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{ AppID: "a", AppSecret: "s", DefaultAs: "auto", }) @@ -116,7 +118,7 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) { got := f.ResolveAs(context.Background(), cmd, "auto") // No UserOpenId → auto-detect returns bot - if got != core.AsBot { + if got != identity.AsBot { t.Errorf("want bot (auto-detect), got %s", got) } if !f.IdentityAutoDetected { @@ -125,10 +127,10 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) { } func TestResolveAs_NilCmd_AutoDetect(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) got := f.ResolveAs(context.Background(), nil, "auto") - if got != core.AsBot { + if got != identity.AsBot { t.Errorf("want bot, got %s", got) } } @@ -136,34 +138,34 @@ func TestResolveAs_NilCmd_AutoDetect(t *testing.T) { // --- CheckIdentity tests --- func TestCheckIdentity_Supported(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) - err := f.CheckIdentity(core.AsBot, []string{"bot", "user"}) + err := f.CheckIdentity(identity.AsBot, []string{"bot", "user"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if f.ResolvedIdentity != core.AsBot { + if f.ResolvedIdentity != identity.AsBot { t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity) } } func TestCheckIdentity_Supported_UserOnly(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) - err := f.CheckIdentity(core.AsUser, []string{"user"}) + err := f.CheckIdentity(identity.AsUser, []string{"user"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if f.ResolvedIdentity != core.AsUser { + if f.ResolvedIdentity != identity.AsUser { t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity) } } func TestCheckIdentity_Unsupported_Explicit(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) f.IdentityAutoDetected = false // explicit --as - err := f.CheckIdentity(core.AsUser, []string{"bot"}) + err := f.CheckIdentity(identity.AsUser, []string{"bot"}) if err == nil { t.Fatal("expected error") } @@ -176,10 +178,10 @@ func TestCheckIdentity_Unsupported_Explicit(t *testing.T) { } func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) f.IdentityAutoDetected = true - err := f.CheckIdentity(core.AsUser, []string{"bot"}) + err := f.CheckIdentity(identity.AsUser, []string{"bot"}) var ve *errs.ValidationError if !errors.As(err, &ve) { t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) @@ -195,7 +197,7 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) { // --- NewAPIClient / NewAPIClientWithConfig tests --- func TestNewAPIClient(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", Brand: brand.Lark} f, _, _, _ := TestFactory(t, cfg) ac, err := f.NewAPIClient() @@ -208,7 +210,7 @@ func TestNewAPIClient(t *testing.T) { } func TestNewAPIClientWithConfig(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", Brand: brand.Lark} f, _, _, _ := TestFactory(t, cfg) ac, err := f.NewAPIClientWithConfig(cfg) @@ -227,7 +229,7 @@ func TestNewAPIClientWithConfig(t *testing.T) { } func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", Brand: brand.Lark} f, _, _, _ := TestFactory(t, cfg) f.IOStreams = nil @@ -243,40 +245,40 @@ func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) { // --- ResolveStrictMode tests --- func TestResolveStrictMode_Off(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) - if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff { + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) + if got := f.ResolveStrictMode(context.Background()); got != identity.StrictModeOff { t.Errorf("expected off, got %q", got) } } func TestResolveStrictMode_BotFromAccount(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2 + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2 f, _, _, _ := TestFactory(t, cfg) - if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot { + if got := f.ResolveStrictMode(context.Background()); got != identity.StrictModeBot { t.Errorf("expected bot, got %q", got) } } func TestResolveStrictMode_UserFromAccount(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1 + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1 f, _, _, _ := TestFactory(t, cfg) - if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeUser { + if got := f.ResolveStrictMode(context.Background()); got != identity.StrictModeUser { t.Errorf("expected user, got %q", got) } } func TestResolveStrictMode_BothIdentities(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3 + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3 f, _, _, _ := TestFactory(t, cfg) - if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff { + if got := f.ResolveStrictMode(context.Background()); got != identity.StrictModeOff { t.Errorf("expected off when both supported, got %q", got) } } func TestResolveStrictMode_NilCredential(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) f.Credential = nil - if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff { + if got := f.ResolveStrictMode(context.Background()); got != identity.StrictModeOff { t.Errorf("expected off with nil credential, got %q", got) } } @@ -284,17 +286,17 @@ func TestResolveStrictMode_NilCredential(t *testing.T) { // --- CheckStrictMode tests --- func TestCheckStrictMode_BotMode_BotAllowed(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} f, _, _, _ := TestFactory(t, cfg) - if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil { + if err := f.CheckStrictMode(context.Background(), identity.AsBot); err != nil { t.Errorf("bot should be allowed in bot mode, got: %v", err) } } func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} f, _, _, _ := TestFactory(t, cfg) - err := f.CheckStrictMode(context.Background(), core.AsUser) + err := f.CheckStrictMode(context.Background(), identity.AsUser) if err == nil { t.Fatal("expected error for user in bot mode") } @@ -304,28 +306,28 @@ func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) { } func TestCheckStrictMode_UserMode_UserAllowed(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} f, _, _, _ := TestFactory(t, cfg) - if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil { + if err := f.CheckStrictMode(context.Background(), identity.AsUser); err != nil { t.Errorf("user should be allowed in user mode, got: %v", err) } } func TestCheckStrictMode_UserMode_BotBlocked(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} f, _, _, _ := TestFactory(t, cfg) - err := f.CheckStrictMode(context.Background(), core.AsBot) + err := f.CheckStrictMode(context.Background(), identity.AsBot) if err == nil { t.Fatal("expected error for bot in user mode") } } func TestCheckStrictMode_Off_BothAllowed(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) - if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil { + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) + if err := f.CheckStrictMode(context.Background(), identity.AsUser); err != nil { t.Errorf("user should be allowed when off: %v", err) } - if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil { + if err := f.CheckStrictMode(context.Background(), identity.AsBot); err != nil { t.Errorf("bot should be allowed when off: %v", err) } } @@ -333,31 +335,31 @@ func TestCheckStrictMode_Off_BothAllowed(t *testing.T) { // --- ResolveAs strict mode tests --- func TestResolveAs_StrictModeBot_ForceBot(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} f, _, _, _ := TestFactory(t, cfg) cmd := newCmdWithAsFlag("auto", false) got := f.ResolveAs(context.Background(), cmd, "auto") - if got != core.AsBot { + if got != identity.AsBot { t.Errorf("bot mode should force bot, got %s", got) } } func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} f, _, _, _ := TestFactory(t, cfg) cmd := newCmdWithAsFlag("auto", false) got := f.ResolveAs(context.Background(), cmd, "auto") - if got != core.AsUser { + if got != identity.AsUser { t.Errorf("user mode should force user, got %s", got) } } func TestResolveAs_StrictModeUser_PreservesExplicitBot(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} f, _, _, _ := TestFactory(t, cfg) cmd := newCmdWithAsFlag("bot", true) - got := f.ResolveAs(context.Background(), cmd, core.AsBot) - if got != core.AsBot { + got := f.ResolveAs(context.Background(), cmd, identity.AsBot) + if got != identity.AsBot { t.Errorf("explicit bot should be preserved for strict-mode validation, got %s", got) } if err := f.CheckStrictMode(context.Background(), got); err == nil { @@ -366,11 +368,11 @@ func TestResolveAs_StrictModeUser_PreservesExplicitBot(t *testing.T) { } func TestResolveAs_StrictModeBot_PreservesExplicitUser(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} f, _, _, _ := TestFactory(t, cfg) cmd := newCmdWithAsFlag("user", true) - got := f.ResolveAs(context.Background(), cmd, core.AsUser) - if got != core.AsUser { + got := f.ResolveAs(context.Background(), cmd, identity.AsUser) + if got != identity.AsUser { t.Errorf("explicit user should be preserved for strict-mode validation, got %s", got) } if err := f.CheckStrictMode(context.Background(), got); err == nil { @@ -379,21 +381,21 @@ func TestResolveAs_StrictModeBot_PreservesExplicitUser(t *testing.T) { } func TestResolveAs_StrictModeUser_ExplicitAutoForcesUser(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} f, _, _, _ := TestFactory(t, cfg) cmd := newCmdWithAsFlag("auto", true) - got := f.ResolveAs(context.Background(), cmd, core.AsAuto) - if got != core.AsUser { + got := f.ResolveAs(context.Background(), cmd, identity.AsAuto) + if got != identity.AsUser { t.Errorf("--as auto should use strict-mode user identity, got %s", got) } } func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) { - cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2} + cfg := &configpkg.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2} f, _, _, _ := TestFactory(t, cfg) cmd := newCmdWithAsFlag("auto", false) got := f.ResolveAs(context.Background(), cmd, "auto") - if got != core.AsBot { + if got != identity.AsBot { t.Errorf("bot mode should override default-as user, got %s", got) } } diff --git a/internal/cmdutil/identity.go b/internal/cmdutil/identity.go index 897cac5bdc..d748b34636 100644 --- a/internal/cmdutil/identity.go +++ b/internal/cmdutil/identity.go @@ -7,12 +7,13 @@ import ( "fmt" "io" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" ) // PrintIdentity outputs the current identity to stderr so callers (including AI agents) // can see which identity is being used for the API call. -func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) { +func PrintIdentity(w io.Writer, as identity.Identity, config *configpkg.CliConfig, autoDetected bool) { if as.IsBot() { if autoDetected { fmt.Fprintln(w, "[identity: bot (auto — not logged in; `auth login` for user identity)]") diff --git a/internal/cmdutil/identity_flag_test.go b/internal/cmdutil/identity_flag_test.go index f4d1c0fb55..149eefeb90 100644 --- a/internal/cmdutil/identity_flag_test.go +++ b/internal/cmdutil/identity_flag_test.go @@ -8,12 +8,12 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/spf13/cobra" ) func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := &cobra.Command{Use: "test"} AddAPIIdentityFlag(context.Background(), cmd, f, nil) @@ -31,7 +31,7 @@ func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) { } func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{ + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{ AppID: "a", AppSecret: "s", SupportedIdentities: 2, }) cmd := &cobra.Command{Use: "test"} @@ -51,7 +51,7 @@ func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) { } func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := &cobra.Command{Use: "test"} AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"bot"}) @@ -70,7 +70,7 @@ func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) { // TC-10: AuthTypes=["user"] → usage contains "identity type: user" and NOT "bot". func TestAddShortcutIdentityFlag_UserOnlyAuthTypes(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := &cobra.Command{Use: "test"} AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user"}) @@ -93,7 +93,7 @@ func TestAddShortcutIdentityFlag_UserOnlyAuthTypes(t *testing.T) { // TC-11: AuthTypes=["user","bot"] → usage == "identity type: user | bot". func TestAddShortcutIdentityFlag_UserBotAuthTypes(t *testing.T) { - f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f, _, _, _ := TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"}) cmd := &cobra.Command{Use: "test"} AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user", "bot"}) diff --git a/internal/cmdutil/identity_test.go b/internal/cmdutil/identity_test.go index bb6e4aee62..6f38d1cd04 100644 --- a/internal/cmdutil/identity_test.go +++ b/internal/cmdutil/identity_test.go @@ -8,12 +8,13 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" ) func TestPrintIdentity_BotExplicit(t *testing.T) { var buf bytes.Buffer - PrintIdentity(&buf, core.AsBot, nil, false) + PrintIdentity(&buf, identity.AsBot, nil, false) if !strings.Contains(buf.String(), "[identity: bot]") { t.Errorf("unexpected output: %s", buf.String()) } @@ -21,7 +22,7 @@ func TestPrintIdentity_BotExplicit(t *testing.T) { func TestPrintIdentity_BotAutoDetected(t *testing.T) { var buf bytes.Buffer - PrintIdentity(&buf, core.AsBot, nil, true) + PrintIdentity(&buf, identity.AsBot, nil, true) if !strings.Contains(buf.String(), "auto") { t.Errorf("expected auto hint, got: %s", buf.String()) } @@ -29,8 +30,8 @@ func TestPrintIdentity_BotAutoDetected(t *testing.T) { func TestPrintIdentity_UserWithOpenId(t *testing.T) { var buf bytes.Buffer - cfg := &core.CliConfig{UserOpenId: "ou_abc123"} - PrintIdentity(&buf, core.AsUser, cfg, false) + cfg := &configpkg.CliConfig{UserOpenId: "ou_abc123"} + PrintIdentity(&buf, identity.AsUser, cfg, false) if !strings.Contains(buf.String(), "ou_abc123") { t.Errorf("expected UserOpenId in output, got: %s", buf.String()) } @@ -38,7 +39,7 @@ func TestPrintIdentity_UserWithOpenId(t *testing.T) { func TestPrintIdentity_UserWithoutOpenId(t *testing.T) { var buf bytes.Buffer - PrintIdentity(&buf, core.AsUser, &core.CliConfig{}, false) + PrintIdentity(&buf, identity.AsUser, &configpkg.CliConfig{}, false) if !strings.Contains(buf.String(), "[identity: user]") { t.Errorf("unexpected output: %s", buf.String()) } @@ -46,7 +47,7 @@ func TestPrintIdentity_UserWithoutOpenId(t *testing.T) { func TestPrintIdentity_UserNilConfig(t *testing.T) { var buf bytes.Buffer - PrintIdentity(&buf, core.AsUser, nil, false) + PrintIdentity(&buf, identity.AsUser, nil, false) if !strings.Contains(buf.String(), "[identity: user]") { t.Errorf("unexpected output: %s", buf.String()) } diff --git a/internal/cmdutil/risk.go b/internal/cmdutil/risk.go index 29ce402bb7..731fa30bcf 100644 --- a/internal/cmdutil/risk.go +++ b/internal/cmdutil/risk.go @@ -4,19 +4,19 @@ package cmdutil import ( - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/risk" "github.com/spf13/cobra" ) const riskLevelAnnotationKey = "risk_level" -// Risk level constants — aliases of the canonical core.Risk* values, re-exported +// Risk level constants — aliases of the canonical risk.Risk* values, re-exported // here so command code gets the risk vocabulary and the SetRisk/GetRisk helpers // from one package. core is the single source of truth. const ( - RiskRead = core.RiskRead - RiskWrite = core.RiskWrite - RiskHighRiskWrite = core.RiskHighRiskWrite + RiskRead = risk.RiskRead + RiskWrite = risk.RiskWrite + RiskHighRiskWrite = risk.RiskHighRiskWrite ) // SetRisk stores a command's static risk level on cobra annotations so the diff --git a/internal/cmdutil/risk_control.go b/internal/cmdutil/risk_control.go index 36479ca641..515079e83c 100644 --- a/internal/cmdutil/risk_control.go +++ b/internal/cmdutil/risk_control.go @@ -4,12 +4,12 @@ package cmdutil import ( - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/riskcontrol" ) type workspaceConfigSource interface { - MultiAppConfig() (*core.MultiAppConfig, error) + MultiAppConfig() (*configpkg.MultiAppConfig, error) } // resolveSDKHostSignalSource applies workspace policy at the SDK transport diff --git a/internal/cmdutil/risk_control_test.go b/internal/cmdutil/risk_control_test.go index b54c4c65bc..554bceaa3c 100644 --- a/internal/cmdutil/risk_control_test.go +++ b/internal/cmdutil/risk_control_test.go @@ -7,15 +7,15 @@ import ( "errors" "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" ) type staticWorkspaceConfig struct { - config *core.MultiAppConfig + config *configpkg.MultiAppConfig err error } -func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) { +func (s staticWorkspaceConfig) MultiAppConfig() (*configpkg.MultiAppConfig, error) { return s.config, s.err } @@ -26,8 +26,8 @@ func TestResolveSDKHostSignalSource(t *testing.T) { config workspaceConfigSource wantSource bool }{ - {name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true}, - {name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}}, + {name: "workspace default on", config: staticWorkspaceConfig{config: &configpkg.MultiAppConfig{}}, wantSource: true}, + {name: "workspace opt-out", config: staticWorkspaceConfig{config: &configpkg.MultiAppConfig{RiskControl: &disabled}}}, {name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}}, {name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}}, {name: "nil config value", config: staticWorkspaceConfig{}}, diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go index 8d8be452bb..1ba01f5b43 100644 --- a/internal/cmdutil/testing.go +++ b/internal/cmdutil/testing.go @@ -13,8 +13,9 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/vfs" @@ -29,7 +30,7 @@ func (n *noopKeychain) Remove(service, account string) error { return nil // TestFactory creates a Factory for testing. // Returns (factory, stdout buffer, stderr buffer, http mock registry). -func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { +func TestFactory(t *testing.T, config *configpkg.CliConfig) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { t.Helper() reg := &httpmock.Registry{} @@ -52,7 +53,7 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, lark.WithHeaders(BaseSecurityHeaders()), } if config.Brand != "" { - opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand))) + opts = append(opts, lark.WithOpenBaseUrl(brand.ResolveOpenBaseURL(config.Brand))) } testLarkClient = lark.NewClient(config.AppID, credential.RuntimeAppSecret(config.AppSecret), opts...) } @@ -65,7 +66,7 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, ) f := &Factory{ - Config: func() (*core.CliConfig, error) { return config, nil }, + Config: func() (*configpkg.CliConfig, error) { return config, nil }, HttpClient: func() (*http.Client, error) { return mockClient, nil }, LarkClient: func() (*lark.Client, error) { return testLarkClient, nil }, IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf}, @@ -77,7 +78,7 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, } type testDefaultAcct struct { - config *core.CliConfig + config *configpkg.CliConfig } func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Account, error) { diff --git a/internal/cmdutil/testing_test.go b/internal/cmdutil/testing_test.go index e12d5370ef..4e201272a2 100644 --- a/internal/cmdutil/testing_test.go +++ b/internal/cmdutil/testing_test.go @@ -8,15 +8,16 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" ) func TestTestFactory_ReplacesGlobals(t *testing.T) { - config := &core.CliConfig{ + config := &configpkg.CliConfig{ AppID: "test-app", AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, } f, stdout, stderr, reg := TestFactory(t, config) @@ -51,7 +52,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) { if err != nil { t.Fatalf("HttpClient() error: %v", err) } - baseURL := core.ResolveOpenBaseURL(core.BrandFeishu) + baseURL := brand.ResolveOpenBaseURL(brand.Feishu) req, _ := http.NewRequest("GET", baseURL+"/test", nil) resp, err := httpClient.Do(req) if err != nil { diff --git a/internal/core/config.go b/internal/config/config.go similarity index 67% rename from internal/core/config.go rename to internal/config/config.go index 9a96065938..4d5bd3523d 100644 --- a/internal/core/config.go +++ b/internal/config/config.go @@ -1,35 +1,26 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package config import ( "encoding/json" "errors" "fmt" - "path/filepath" "strings" "unicode/utf8" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/secret" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) -// Identity represents the caller identity for API requests. -type Identity string - -const ( - AsUser Identity = "user" - AsBot Identity = "bot" - AsAuto Identity = "auto" -) - -// IsBot returns true if the identity is bot. -func (id Identity) IsBot() bool { return id == AsBot } - // AppUser is a logged-in user record stored in config. type AppUser struct { UserOpenId string `json:"userOpenId"` @@ -38,14 +29,14 @@ type AppUser struct { // AppConfig is a per-app configuration entry (stored format — secrets may be unresolved). type AppConfig struct { - Name string `json:"name,omitempty"` - AppId string `json:"appId"` - AppSecret SecretInput `json:"appSecret"` - Brand LarkBrand `json:"brand"` - Lang i18n.Lang `json:"lang,omitempty"` - DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto - StrictMode *StrictMode `json:"strictMode,omitempty"` - Users []AppUser `json:"users"` + Name string `json:"name,omitempty"` + AppId string `json:"appId"` + AppSecret secret.SecretInput `json:"appSecret"` + Brand brand.Brand `json:"brand"` + Lang i18n.Lang `json:"lang,omitempty"` + DefaultAs identity.Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto + StrictMode *identity.StrictMode `json:"strictMode,omitempty"` + Users []AppUser `json:"users"` } // ProfileName returns the display name for this app config. @@ -59,11 +50,11 @@ func (a *AppConfig) ProfileName() string { // MultiAppConfig is the multi-app config file format. type MultiAppConfig struct { - StrictMode StrictMode `json:"strictMode,omitempty"` - RiskControl *bool `json:"riskControl,omitempty"` - CurrentApp string `json:"currentApp,omitempty"` - PreviousApp string `json:"previousApp,omitempty"` - Apps []AppConfig `json:"apps"` + StrictMode identity.StrictMode `json:"strictMode,omitempty"` + RiskControl *bool `json:"riskControl,omitempty"` + CurrentApp string `json:"currentApp,omitempty"` + PreviousApp string `json:"previousApp,omitempty"` + Apps []AppConfig `json:"apps"` } // RiskControlEnabled resolves the workspace policy. An omitted preference @@ -163,8 +154,8 @@ type CliConfig struct { ProfileName string AppID string AppSecret string - Brand LarkBrand - DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file) + Brand brand.Brand + DefaultAs identity.Identity // AsUser | AsBot | AsAuto | "" (from config file) UserOpenId string UserName string Lang i18n.Lang @@ -181,19 +172,6 @@ func (c *CliConfig) CanBot() bool { return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0 } -// GetConfigDir returns the config directory path for the current workspace. -// When workspace is local (default), this returns the same path as before -// (LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli) — fully backward-compatible. -// When workspace is openclaw/hermes, returns base/openclaw or base/hermes. -func GetConfigDir() string { - return GetRuntimeDir() -} - -// GetConfigPath returns the config file path for the current workspace. -func GetConfigPath() string { - return filepath.Join(GetConfigDir(), "config.json") -} - // ErrMalformedConfig marks a config-load failure caused by malformed file // content (unparseable JSON, structurally empty) rather than a missing or // unreadable file. Callers classify with errors.Is rather than sniffing the @@ -202,7 +180,7 @@ var ErrMalformedConfig = errors.New("malformed config") // LoadMultiAppConfig loads multi-app config from disk. func LoadMultiAppConfig() (*MultiAppConfig, error) { - data, err := vfs.ReadFile(GetConfigPath()) + data, err := vfs.ReadFile(workspace.GetConfigPath()) if err != nil { return nil, err } @@ -219,7 +197,7 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { // SaveMultiAppConfig saves config to disk. func SaveMultiAppConfig(config *MultiAppConfig) error { - dir := GetConfigDir() + dir := workspace.GetConfigDir() if err := vfs.MkdirAll(dir, 0700); err != nil { return err } @@ -227,22 +205,7 @@ func SaveMultiAppConfig(config *MultiAppConfig) error { if err != nil { return err } - return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600) -} - -// RequireConfig loads the single-app config using the default profile resolution. -func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) { - return RequireConfigForProfile(kc, "") -} - -// RequireConfigForProfile loads the single-app config for a specific profile. -// Resolution priority: profileOverride > config.CurrentApp > Apps[0]. -func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { - raw, err := LoadMultiAppConfig() - if err != nil || raw == nil || len(raw.Apps) == 0 { - return nil, NotConfiguredError() - } - return ResolveConfigFromMulti(raw, kc, profileOverride) + return validate.AtomicWrite(workspace.GetConfigPath(), append(data, '\n'), 0600) } // ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig. @@ -254,13 +217,13 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames())) } - if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { + if err := secret.ValidateSecretKeyMatch(app.AppId, app.AppSecret, reconfigureHint()); err != nil { return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync"). WithHint("%s", err.Error()). WithCause(err) } - secret, err := ResolveSecretInput(app.AppSecret, kc) + resolvedSecret, err := secret.ResolveSecretInput(app.AppSecret, kc) if err != nil { if errs.IsTyped(err) { return nil, err @@ -274,8 +237,8 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro cfg := &CliConfig{ ProfileName: app.ProfileName(), AppID: app.AppId, - AppSecret: secret, - Brand: ParseBrand(string(app.Brand)), + AppSecret: resolvedSecret, + Brand: brand.ParseBrand(string(app.Brand)), Lang: app.Lang, DefaultAs: app.DefaultAs, } @@ -286,24 +249,6 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro return cfg, nil } -// RequireAuth loads config and ensures a user is logged in. -func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) { - return RequireAuthForProfile(kc, "") -} - -// RequireAuthForProfile loads config for a profile and ensures a user is logged in. -func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { - cfg, err := RequireConfigForProfile(kc, profileOverride) - if err != nil { - return nil, err - } - if cfg.UserOpenId == "" { - return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"). - WithHint("run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.") - } - return cfg, nil -} - // formatProfileNames joins profile names for display. func formatProfileNames(names []string) string { if len(names) == 0 { diff --git a/internal/core/config_snapshot.go b/internal/config/config_snapshot.go similarity index 98% rename from internal/core/config_snapshot.go rename to internal/config/config_snapshot.go index 63977e7e21..96e8928600 100644 --- a/internal/core/config_snapshot.go +++ b/internal/config/config_snapshot.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package config import ( "io/fs" diff --git a/internal/core/config_snapshot_test.go b/internal/config/config_snapshot_test.go similarity index 98% rename from internal/core/config_snapshot_test.go rename to internal/config/config_snapshot_test.go index fb933ed96a..05a27bbacf 100644 --- a/internal/core/config_snapshot_test.go +++ b/internal/config/config_snapshot_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package config import ( "errors" diff --git a/internal/core/config_strict_mode_test.go b/internal/config/config_strict_mode_test.go similarity index 76% rename from internal/core/config_strict_mode_test.go rename to internal/config/config_strict_mode_test.go index 95c79a8d86..1626ea770a 100644 --- a/internal/core/config_strict_mode_test.go +++ b/internal/config/config_strict_mode_test.go @@ -1,17 +1,21 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package config import ( "encoding/json" "testing" + + "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/internal/identity" + "github.com/larksuite/cli/internal/secret" ) func TestMultiAppConfig_StrictMode_JSON(t *testing.T) { // StrictMode="" should be omitted (omitempty) m := &MultiAppConfig{ - Apps: []AppConfig{{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}}, + Apps: []AppConfig{{AppId: "a", AppSecret: secret.PlainSecret("s"), Brand: brand.Feishu, Users: []AppUser{}}}, } data, _ := json.Marshal(m) if string(data) != `{"apps":[{"appId":"a","appSecret":"s","brand":"feishu","users":[]}]}` { @@ -19,7 +23,7 @@ func TestMultiAppConfig_StrictMode_JSON(t *testing.T) { } // StrictMode="bot" should be present - m.StrictMode = StrictModeBot + m.StrictMode = identity.StrictModeBot data, _ = json.Marshal(m) var parsed map[string]interface{} json.Unmarshal(data, &parsed) @@ -30,7 +34,7 @@ func TestMultiAppConfig_StrictMode_JSON(t *testing.T) { func TestAppConfig_StrictMode_JSON(t *testing.T) { // StrictMode nil should be omitted - app := &AppConfig{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}} + app := &AppConfig{AppId: "a", AppSecret: secret.PlainSecret("s"), Brand: brand.Feishu, Users: []AppUser{}} data, _ := json.Marshal(app) var parsed map[string]interface{} json.Unmarshal(data, &parsed) @@ -39,7 +43,7 @@ func TestAppConfig_StrictMode_JSON(t *testing.T) { } // StrictMode = pointer to "user" - v := StrictModeUser + v := identity.StrictModeUser app.StrictMode = &v data, _ = json.Marshal(app) json.Unmarshal(data, &parsed) @@ -48,7 +52,7 @@ func TestAppConfig_StrictMode_JSON(t *testing.T) { } // StrictMode = pointer to "off" (explicit off — should be present, not omitted) - voff := StrictModeOff + voff := identity.StrictModeOff app.StrictMode = &voff data, _ = json.Marshal(app) json.Unmarshal(data, &parsed) diff --git a/internal/core/config_test.go b/internal/config/config_test.go similarity index 83% rename from internal/core/config_test.go rename to internal/config/config_test.go index 233ef94d43..26933a451e 100644 --- a/internal/core/config_test.go +++ b/internal/config/config_test.go @@ -1,15 +1,17 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package config import ( "encoding/json" "errors" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/secret" ) // stubKeychain is a minimal KeychainAccess that always returns ErrNotFound. @@ -23,8 +25,8 @@ func (stubKeychain) Remove(service, account string) error { return nil } func TestAppConfig_LangSerialization(t *testing.T) { app := AppConfig{ - AppId: "cli_test", AppSecret: PlainSecret("secret"), - Brand: BrandFeishu, Lang: "en", Users: []AppUser{}, + AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, Lang: "en", Users: []AppUser{}, } data, err := json.Marshal(app) if err != nil { @@ -42,8 +44,8 @@ func TestAppConfig_LangSerialization(t *testing.T) { func TestAppConfig_LangOmitEmpty(t *testing.T) { app := AppConfig{ - AppId: "cli_test", AppSecret: PlainSecret("secret"), - Brand: BrandFeishu, Users: []AppUser{}, + AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, Users: []AppUser{}, } data, err := json.Marshal(app) if err != nil { @@ -64,8 +66,8 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) { config := &MultiAppConfig{ RiskControl: &disabled, Apps: []AppConfig{{ - AppId: "cli_test", AppSecret: PlainSecret("s"), - Brand: BrandLark, Lang: "zh", Users: []AppUser{}, + AppId: "cli_test", AppSecret: secret.PlainSecret("s"), + Brand: brand.Lark, Lang: "zh", Users: []AppUser{}, }}, } data, err := json.MarshalIndent(config, "", " ") @@ -83,8 +85,8 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) { if got.Apps[0].Lang != "zh" { t.Errorf("Lang = %q, want %q", got.Apps[0].Lang, "zh") } - if got.Apps[0].Brand != BrandLark { - t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark) + if got.Apps[0].Brand != brand.Lark { + t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, brand.Lark) } if got.RiskControl == nil || *got.RiskControl { t.Errorf("RiskControl = %v, want explicit false", got.RiskControl) @@ -96,11 +98,11 @@ func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) { Apps: []AppConfig{ { AppId: "cli_new_app", - AppSecret: SecretInput{Ref: &SecretRef{ + AppSecret: secret.SecretInput{Ref: &secret.SecretRef{ Source: "keychain", ID: "appsecret:cli_old_app", }}, - Brand: BrandFeishu, + Brand: brand.Feishu, }, }, } @@ -123,8 +125,8 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) { Apps: []AppConfig{ { AppId: "cli_abc", - AppSecret: PlainSecret("my-secret"), - Brand: BrandFeishu, + AppSecret: secret.PlainSecret("my-secret"), + Brand: brand.Feishu, }, }, } @@ -143,8 +145,8 @@ func TestResolveConfigFromMulti_CarriesLang(t *testing.T) { Apps: []AppConfig{ { AppId: "cli_abc", - AppSecret: PlainSecret("my-secret"), - Brand: BrandFeishu, + AppSecret: secret.PlainSecret("my-secret"), + Brand: brand.Feishu, Lang: "en", }, }, @@ -161,17 +163,17 @@ func TestResolveConfigFromMulti_CarriesLang(t *testing.T) { func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) { // Keychain ref matches appId, so validation passes. - // The subsequent ResolveSecretInput will fail (no real keychain), + // The subsequent secret.ResolveSecretInput will fail (no real keychain), // but that proves the mismatch check itself passed. raw := &MultiAppConfig{ Apps: []AppConfig{ { AppId: "cli_abc", - AppSecret: SecretInput{Ref: &SecretRef{ + AppSecret: secret.SecretInput{Ref: &secret.SecretRef{ Source: "keychain", ID: "appsecret:cli_abc", }}, - Brand: BrandFeishu, + Brand: brand.Feishu, }, }, } @@ -200,8 +202,8 @@ func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) { { Name: "active", AppId: "cli_active", - AppSecret: PlainSecret("secret"), - Brand: BrandFeishu, + AppSecret: secret.PlainSecret("secret"), + Brand: brand.Feishu, }, }, } @@ -241,14 +243,14 @@ func TestCliConfig_CanBot(t *testing.T) { func TestResolveConfigFromMulti_NormalizesBrand(t *testing.T) { multi := &MultiAppConfig{Apps: []AppConfig{{ AppId: "cli_x", - AppSecret: PlainSecret("test-secret"), - Brand: LarkBrand(" LARK "), + AppSecret: secret.PlainSecret("test-secret"), + Brand: brand.Brand(" LARK "), }}} cfg, err := ResolveConfigFromMulti(multi, nil, "") if err != nil { t.Fatalf("ResolveConfigFromMulti error = %v", err) } - if cfg.Brand != BrandLark { - t.Errorf("Brand = %q, want %q (normalized at ingress)", cfg.Brand, BrandLark) + if cfg.Brand != brand.Lark { + t.Errorf("Brand = %q, want %q (normalized at ingress)", cfg.Brand, brand.Lark) } } diff --git a/internal/core/notconfigured.go b/internal/config/notconfigured.go similarity index 93% rename from internal/core/notconfigured.go rename to internal/config/notconfigured.go index 068ea86cfa..b29f1bba51 100644 --- a/internal/core/notconfigured.go +++ b/internal/config/notconfigured.go @@ -1,13 +1,14 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package config import ( "errors" "os" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/workspace" ) // isMalformedConfigError reports whether a config load failure indicates a @@ -69,8 +70,8 @@ const ( // NotConfiguredError returns the canonical "not configured" error, with a // hint that depends on the active workspace: // -// - WorkspaceLocal → suggest `config init --new` (creates a new app). -// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help` +// - workspace.WorkspaceLocal → suggest `config init --new` (creates a new app). +// - workspace.WorkspaceOpenClaw / workspace.WorkspaceHermes → point at `config bind --help` // rather than a ready-to-run command, because binding is policy-laden: // the user must pick an identity preset (bot-only vs user-default), // and re-binding may overwrite an existing one. The help text walks @@ -79,7 +80,7 @@ const ( // All "config not loaded yet" call sites should use this helper rather than // hand-rolling a hint, so AI agents always get a workspace-correct next step. func NotConfiguredError() error { - ws := CurrentWorkspace() + ws := workspace.CurrentWorkspace() if ws.IsLocal() { return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured"). WithHint("%s", localInitHint) @@ -97,7 +98,7 @@ func NotConfiguredError() error { // Agent → `config bind --help` so the AI reads the binding workflow and // confirms identity preset with the user before running the actual command. func reconfigureHint() string { - if CurrentWorkspace().IsLocal() { + if workspace.CurrentWorkspace().IsLocal() { return "please run `lark-cli config init` to reconfigure" } return agentBindHint @@ -108,7 +109,7 @@ func reconfigureHint() string { // workspaces a missing profile typically means the binding was wiped while // the workspace marker remained — re-binding is the correct fix, not init. func NoActiveProfileError() error { - ws := CurrentWorkspace() + ws := workspace.CurrentWorkspace() if ws.IsLocal() { return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile"). WithHint("%s", localInitHint) diff --git a/internal/core/notconfigured_test.go b/internal/config/notconfigured_test.go similarity index 91% rename from internal/core/notconfigured_test.go rename to internal/config/notconfigured_test.go index d546fbe4a1..621581c6d8 100644 --- a/internal/core/notconfigured_test.go +++ b/internal/config/notconfigured_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package config import ( "errors" @@ -10,6 +10,7 @@ import ( "testing" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/workspace" ) // saveAndRestoreWorkspace ensures package-level currentWorkspace is reset @@ -17,13 +18,13 @@ import ( // accident. func saveAndRestoreWorkspace(t *testing.T) { t.Helper() - prev := CurrentWorkspace() - t.Cleanup(func() { SetCurrentWorkspace(prev) }) + prev := workspace.CurrentWorkspace() + t.Cleanup(func() { workspace.SetCurrentWorkspace(prev) }) } func TestNotConfiguredError_Local(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceLocal) + workspace.SetCurrentWorkspace(workspace.WorkspaceLocal) err := NotConfiguredError() var cfgErr *errs.ConfigError @@ -46,7 +47,7 @@ func TestNotConfiguredError_Local(t *testing.T) { func TestNotConfiguredError_OpenClaw(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceOpenClaw) + workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw) err := NotConfiguredError() var cfgErr *errs.ConfigError @@ -74,7 +75,7 @@ func TestNotConfiguredError_OpenClaw(t *testing.T) { func TestNotConfiguredError_Hermes(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceHermes) + workspace.SetCurrentWorkspace(workspace.WorkspaceHermes) err := NotConfiguredError() var cfgErr *errs.ConfigError @@ -94,7 +95,7 @@ func TestNotConfiguredError_Hermes(t *testing.T) { func TestNoActiveProfileError_Local(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceLocal) + workspace.SetCurrentWorkspace(workspace.WorkspaceLocal) err := NoActiveProfileError() var cfgErr *errs.ConfigError @@ -108,7 +109,7 @@ func TestNoActiveProfileError_Local(t *testing.T) { func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceOpenClaw) + workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw) err := NoActiveProfileError() var cfgErr *errs.ConfigError @@ -122,7 +123,7 @@ func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) { func TestReconfigureHint_Local(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceLocal) + workspace.SetCurrentWorkspace(workspace.WorkspaceLocal) got := reconfigureHint() if !strings.Contains(got, "config init") { @@ -132,7 +133,7 @@ func TestReconfigureHint_Local(t *testing.T) { func TestReconfigureHint_Agent(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceHermes) + workspace.SetCurrentWorkspace(workspace.WorkspaceHermes) got := reconfigureHint() if !strings.Contains(got, "config bind --help") { @@ -142,7 +143,7 @@ func TestReconfigureHint_Agent(t *testing.T) { func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) { saveAndRestoreWorkspace(t) - SetCurrentWorkspace(WorkspaceLocal) + workspace.SetCurrentWorkspace(workspace.WorkspaceLocal) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) _, err := LoadOrNotConfigured() diff --git a/internal/credential/credential_provider.go b/internal/credential/credential_provider.go index 8f20be11d9..496ca14edc 100644 --- a/internal/credential/credential_provider.go +++ b/internal/credential/credential_provider.go @@ -13,7 +13,8 @@ import ( extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/auth" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" ) // DefaultAccountResolver is implemented by the default account provider. @@ -69,15 +70,15 @@ func (s extensionTokenSource) ResolveIdentityHint(ctx context.Context, acct *Acc // Extension sources verify user identity via enrichUserInfo, so a resolved // UserOpenId is sufficient here; no keychain-backed token status lookup is needed. if acct.UserOpenId != "" { - hint.AutoAs = core.AsUser + hint.AutoAs = identity.AsUser return hint, nil } ids := extcred.IdentitySupport(acct.SupportedIdentities) switch { case ids.UserOnly(): - hint.AutoAs = core.AsUser + hint.AutoAs = identity.AsUser case ids.BotOnly(): - hint.AutoAs = core.AsBot + hint.AutoAs = identity.AsBot } return hint, nil } @@ -112,19 +113,19 @@ func (s defaultTokenSource) ResolveIdentityHint(ctx context.Context, acct *Accou } hint.DefaultAs = acct.DefaultAs if acct.UserOpenId == "" { - hint.AutoAs = core.AsBot + hint.AutoAs = identity.AsBot return hint, nil } stored := getStoredToken(acct.AppID, acct.UserOpenId) if stored == nil { - hint.AutoAs = core.AsBot + hint.AutoAs = identity.AsBot return hint, nil } if getStoredTokenStatus(stored) == "expired" { - hint.AutoAs = core.AsBot + hint.AutoAs = identity.AsBot return hint, nil } - hint.AutoAs = core.AsUser + hint.AutoAs = identity.AsUser return hint, nil } @@ -203,7 +204,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er p.selectedSource = defaultTokenSource{resolver: p.defaultToken} return acct, nil } - return nil, core.NotConfiguredError() + return nil, configpkg.NotConfiguredError() } // enrichUserInfo resolves user identity when extension provides a UAT. @@ -372,8 +373,8 @@ func convertAccount(ext *extcred.Account) *Account { return &Account{ AppID: ext.AppID, AppSecret: ext.AppSecret, - Brand: core.LarkBrand(ext.Brand), - DefaultAs: core.Identity(ext.DefaultAs), + Brand: ext.Brand, + DefaultAs: identity.Identity(ext.DefaultAs), ProfileName: ext.ProfileName, UserOpenId: ext.OpenID, SupportedIdentities: uint8(ext.SupportedIdentities), diff --git a/internal/credential/credential_provider_test.go b/internal/credential/credential_provider_test.go index 6dd13a985d..a66311b52a 100644 --- a/internal/credential/credential_provider_test.go +++ b/internal/credential/credential_provider_test.go @@ -11,9 +11,10 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/auth" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" ) type mockExtProvider struct { @@ -150,7 +151,7 @@ func TestCredentialProvider_TokenFallsToDefault(t *testing.T) { func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) { cp := NewCredentialProvider( []extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}}, - &mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu}}, + &mockDefaultAcct{account: &Account{AppID: "default_app", Brand: brand.Feishu}}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil, ) @@ -223,11 +224,11 @@ func TestCredentialProvider_ResolveIdentityHint_FromExtensionAccount(t *testing. if err != nil { t.Fatalf("ResolveIdentityHint() error = %v", err) } - if hint.DefaultAs != core.AsUser { - t.Fatalf("ResolveIdentityHint() defaultAs = %q, want %q", hint.DefaultAs, core.AsUser) + if hint.DefaultAs != identity.AsUser { + t.Fatalf("ResolveIdentityHint() defaultAs = %q, want %q", hint.DefaultAs, identity.AsUser) } - if hint.AutoAs != core.AsUser { - t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser) + if hint.AutoAs != identity.AsUser { + t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, identity.AsUser) } } @@ -251,7 +252,7 @@ func TestCredentialProvider_ResolveIdentityHint_DefaultSourceUsesStoredTokenStat cp := NewCredentialProvider( nil, - &mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}}, + &mockDefaultAcct{account: &Account{AppID: "default_app", Brand: brand.Feishu, UserOpenId: "ou_default"}}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil, ) @@ -260,8 +261,8 @@ func TestCredentialProvider_ResolveIdentityHint_DefaultSourceUsesStoredTokenStat if err != nil { t.Fatalf("ResolveIdentityHint() error = %v", err) } - if hint.AutoAs != core.AsUser { - t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser) + if hint.AutoAs != identity.AsUser { + t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, identity.AsUser) } } @@ -286,7 +287,7 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) { cp := NewCredentialProvider( nil, - &mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}}, + &mockDefaultAcct{account: &Account{AppID: "default_app", Brand: brand.Feishu, UserOpenId: "ou_default"}}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil, ) @@ -296,8 +297,8 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) { if err != nil { t.Fatalf("ResolveIdentityHint() error = %v", err) } - if hint.AutoAs != core.AsUser { - t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser) + if hint.AutoAs != identity.AsUser { + t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, identity.AsUser) } } @@ -329,7 +330,7 @@ func TestCredentialProvider_ResolveAccountDoesNotEnrichWithTokenFromDifferentPro []extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}}, &mockDefaultAcct{account: &Account{ AppID: "default_app", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_default", UserName: "Default User", }}, diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index d7401b62ed..033a3ee467 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -12,8 +12,9 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/keychain" extcred "github.com/larksuite/cli/extension/credential" @@ -74,12 +75,12 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) { // Load config once — used for both credentials and strict mode. - multi, err := core.LoadMultiAppConfig() + multi, err := configpkg.LoadMultiAppConfig() if err != nil { - return nil, core.NotConfiguredError() + return nil, configpkg.NotConfiguredError() } - cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile) + cfg, err := configpkg.ResolveConfigFromMulti(multi, p.keychain(), p.profile) if err != nil { return nil, err } @@ -89,18 +90,18 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, // strictModeToIdentitySupport maps the config-level strict mode to // the SupportedIdentities bitflag using an already-loaded MultiAppConfig. -func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 { +func strictModeToIdentitySupport(multi *configpkg.MultiAppConfig, profileOverride string) uint8 { app := multi.CurrentAppConfig(profileOverride) - var mode core.StrictMode + var mode identity.StrictMode if app != nil && app.StrictMode != nil { mode = *app.StrictMode } else { mode = multi.StrictMode } switch mode { - case core.StrictModeBot: + case identity.StrictModeBot: return uint8(extcred.SupportsBot) - case core.StrictModeUser: + case identity.StrictModeUser: return uint8(extcred.SupportsUser) default: return 0 diff --git a/internal/credential/integration_test.go b/internal/credential/integration_test.go index de173d1948..50202cacf6 100644 --- a/internal/credential/integration_test.go +++ b/internal/credential/integration_test.go @@ -7,13 +7,16 @@ import ( "context" "testing" + "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/envnames" extcred "github.com/larksuite/cli/extension/credential" envprovider "github.com/larksuite/cli/extension/credential/env" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" - "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/secret" ) type noopKC struct{} @@ -23,9 +26,9 @@ func (n *noopKC) Set(service, account, value string) error { return nil } func (n *noopKC) Remove(service, account string) error { return nil } func TestFullChain_EnvWins(t *testing.T) { - t.Setenv(envvars.CliAppID, "env_app") - t.Setenv(envvars.CliAppSecret, "env_secret") - t.Setenv(envvars.CliUserAccessToken, "env_uat") + t.Setenv(envnames.CliAppID, "env_app") + t.Setenv(envnames.CliAppSecret, "env_secret") + t.Setenv(envnames.CliUserAccessToken, "env_uat") ep := &envprovider.Provider{} cp := credential.NewCredentialProvider( @@ -82,21 +85,21 @@ func (m *mockDefaultTokenProvider) ResolveToken(ctx context.Context, req credent } func TestFullChain_ConfigStrictMode(t *testing.T) { - t.Setenv(envvars.CliAppID, "") - t.Setenv(envvars.CliAppSecret, "") + t.Setenv(envnames.CliAppID, "") + t.Setenv(envnames.CliAppSecret, "") dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - botMode := core.StrictModeBot - multi := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + botMode := identity.StrictModeBot + multi := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ AppId: "cfg_app", - AppSecret: core.PlainSecret("cfg_secret"), - Brand: core.BrandLark, + AppSecret: secret.PlainSecret("cfg_secret"), + Brand: brand.Lark, StrictMode: &botMode, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatal(err) } @@ -125,19 +128,19 @@ func TestFullChain_ConfigStrictMode(t *testing.T) { // consumers (mail signature, etc.) silently fall back to defaults — defeating // the whole point of persisting --lang. func TestFullChain_LangSurvivesProductionPath(t *testing.T) { - t.Setenv(envvars.CliAppID, "") - t.Setenv(envvars.CliAppSecret, "") + t.Setenv(envnames.CliAppID, "") + t.Setenv(envnames.CliAppSecret, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - multi := &core.MultiAppConfig{ - Apps: []core.AppConfig{{ + multi := &configpkg.MultiAppConfig{ + Apps: []configpkg.AppConfig{{ AppId: "cfg_app", - AppSecret: core.PlainSecret("cfg_secret"), - Brand: core.BrandFeishu, + AppSecret: secret.PlainSecret("cfg_secret"), + Brand: brand.Feishu, Lang: i18n.LangJaJP, }}, } - if err := core.SaveMultiAppConfig(multi); err != nil { + if err := configpkg.SaveMultiAppConfig(multi); err != nil { t.Fatalf("SaveMultiAppConfig: %v", err) } diff --git a/internal/credential/tat_fetch.go b/internal/credential/tat_fetch.go index 0d916245d4..2c776d80f3 100644 --- a/internal/credential/tat_fetch.go +++ b/internal/credential/tat_fetch.go @@ -12,7 +12,7 @@ import ( "net/url" "strings" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" ) // FetchTAT performs a single HTTP POST to mint a tenant access token via the @@ -31,9 +31,9 @@ import ( // deterministic credential rejection apart from upstream/transport noise. // // The caller owns the context timeout. -func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) { - ep := core.ResolveEndpoints(brand) - endpoint := ep.Accounts + core.OAuthTokenV3Path +func FetchTAT(ctx context.Context, httpClient *http.Client, brand brandpkg.Brand, appID, appSecret string) (string, error) { + ep := brandpkg.ResolveEndpoints(brand) + endpoint := ep.Accounts + brandpkg.OAuthTokenV3Path form := url.Values{} form.Set("grant_type", "client_credentials") diff --git a/internal/credential/tat_fetch_test.go b/internal/credential/tat_fetch_test.go index a899d205ac..bd427598c4 100644 --- a/internal/credential/tat_fetch_test.go +++ b/internal/credential/tat_fetch_test.go @@ -12,8 +12,8 @@ import ( "strings" "testing" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" ) // stubRoundTripper lets us assert request shape and return canned responses. @@ -48,7 +48,7 @@ func TestFetchTAT_Success(t *testing.T) { } hc := &http.Client{Transport: rt} - token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + token, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -79,7 +79,7 @@ func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) { rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`} hc := &http.Client{Transport: rt} - token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + token, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected error for invalid_client") } @@ -106,7 +106,7 @@ func TestFetchTAT_OtherClientError_Typed(t *testing.T) { rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`} hc := &http.Client{Transport: rt} - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + _, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected error for invalid_scope") } @@ -127,7 +127,7 @@ func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) { rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`} hc := &http.Client{Transport: rt} - tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + tok, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)") } @@ -146,7 +146,7 @@ func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) { rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`} hc := &http.Client{Transport: rt} - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + _, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected error for {code, msg} response") } @@ -165,7 +165,7 @@ func TestFetchTAT_ServerError_Untyped(t *testing.T) { rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`} hc := &http.Client{Transport: rt} - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + _, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected error for server_error") } @@ -191,7 +191,7 @@ func TestFetchTAT_RateLimit_Untyped(t *testing.T) { rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body} hc := &http.Client{Transport: rt} - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + _, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected error for rate-limit") } @@ -209,7 +209,7 @@ func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) { for _, code := range []int{401, 403, 500, 503} { rt := &stubRoundTripper{respCode: code, respBody: `whatever`} hc := &http.Client{Transport: rt} - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + _, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatalf("HTTP %d: expected error", code) } @@ -224,7 +224,7 @@ func TestFetchTAT_TransportError_Untyped(t *testing.T) { rt := &stubRoundTripper{err: sentinel} hc := &http.Client{Transport: rt} - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + _, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected error") } @@ -240,7 +240,7 @@ func TestFetchTAT_ParseError_Untyped(t *testing.T) { rt := &stubRoundTripper{respCode: 200, respBody: `not json`} hc := &http.Client{Transport: rt} - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + _, err := FetchTAT(context.Background(), hc, brandpkg.Feishu, "cli_app", "secret_x") if err == nil { t.Fatal("expected parse error") } @@ -251,11 +251,11 @@ func TestFetchTAT_ParseError_Untyped(t *testing.T) { func TestFetchTAT_BrandRouting(t *testing.T) { tests := []struct { - brand core.LarkBrand + brand brandpkg.Brand wantURL string }{ - {core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"}, - {core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"}, + {brandpkg.Feishu, "https://accounts.feishu.cn/oauth/v3/token"}, + {brandpkg.Lark, "https://accounts.larksuite.com/oauth/v3/token"}, } for _, tc := range tests { t.Run(string(tc.brand), func(t *testing.T) { @@ -283,7 +283,7 @@ func TestFetchTAT_ContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // pre-canceled - _, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b") + _, err := FetchTAT(ctx, hc, brandpkg.Feishu, "a", "b") if err == nil { t.Fatal("expected error for canceled context") } diff --git a/internal/credential/types.go b/internal/credential/types.go index 430b22ba87..9924827715 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -8,20 +8,22 @@ import ( "fmt" "strings" + "github.com/larksuite/cli/brand" extcred "github.com/larksuite/cli/extension/credential" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" + identitypkg "github.com/larksuite/cli/internal/identity" ) // Account is the credential-layer view of the active runtime account. // It intentionally mirrors only the resolved fields needed by runtime auth -// and identity selection, without exposing core.CliConfig as a dependency. +// and identity selection, without exposing configpkg.CliConfig as a dependency. type Account struct { ProfileName string AppID string AppSecret string - Brand core.LarkBrand - DefaultAs core.Identity + Brand brand.Brand + DefaultAs identitypkg.Identity UserOpenId string UserName string Lang i18n.Lang @@ -55,7 +57,7 @@ func normalizeAccountAppSecret(secret string) string { } // AccountFromCliConfig copies the resolved config view into a credential.Account. -func AccountFromCliConfig(cfg *core.CliConfig) *Account { +func AccountFromCliConfig(cfg *configpkg.CliConfig) *Account { if cfg == nil { return nil } @@ -74,15 +76,15 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account { // ToCliConfig copies the credential-layer account into the downstream config // shape, normalizing the brand so runtime consumers never see raw casing. -func (a *Account) ToCliConfig() *core.CliConfig { +func (a *Account) ToCliConfig() *configpkg.CliConfig { if a == nil { return nil } - return &core.CliConfig{ + return &configpkg.CliConfig{ ProfileName: a.ProfileName, AppID: a.AppID, AppSecret: normalizeAccountAppSecret(a.AppSecret), - Brand: core.ParseBrand(string(a.Brand)), + Brand: brand.ParseBrand(string(a.Brand)), DefaultAs: a.DefaultAs, UserOpenId: a.UserOpenId, UserName: a.UserName, @@ -132,10 +134,10 @@ type TokenResult struct { Scopes string // optional, space-separated; empty = skip scope pre-check } -// IdentityHint is credential-layer guidance for resolving the effective identity. +// IdentityHint is credential-layer guidance for resolving the effective identitypkg. type IdentityHint struct { - DefaultAs core.Identity - AutoAs core.Identity + DefaultAs identitypkg.Identity + AutoAs identitypkg.Identity } // TokenUnavailableError reports that no usable token was available. @@ -171,7 +173,7 @@ type TokenProvider interface { // NewTokenSpec returns a TokenSpec with the token type automatically // selected based on identity: TAT for bot, UAT for user. -func NewTokenSpec(identity core.Identity, appID string) TokenSpec { +func NewTokenSpec(identity identitypkg.Identity, appID string) TokenSpec { t := TokenTypeUAT if identity.IsBot() { t = TokenTypeTAT diff --git a/internal/credential/types_test.go b/internal/credential/types_test.go index 1c1f288534..8c65983dfb 100644 --- a/internal/credential/types_test.go +++ b/internal/credential/types_test.go @@ -6,7 +6,8 @@ package credential import ( "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" ) @@ -46,11 +47,11 @@ func TestParseTokenType(t *testing.T) { } func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ ProfileName: "target", AppID: "app-1", AppSecret: "secret-1", - Brand: core.BrandLark, + Brand: brand.Lark, DefaultAs: "user", UserOpenId: "ou_123", UserName: "alice", @@ -99,7 +100,7 @@ func TestAccountToCliConfig_TokenOnlySecretPreservesNoAppSecret(t *testing.T) { ProfileName: "env", AppID: "app-1", AppSecret: "", - Brand: core.BrandFeishu, + Brand: brand.Feishu, } cfg := acct.ToCliConfig() @@ -134,7 +135,7 @@ func TestRuntimeAppSecret_TokenOnlyUsesPlaceholder(t *testing.T) { // The credential-layer ingress normalizes brand casing for all runtime consumers. func TestToCliConfig_NormalizesBrand(t *testing.T) { acct := &Account{AppID: "cli_x", Brand: " LARK "} - if got := acct.ToCliConfig().Brand; got != core.BrandLark { - t.Errorf("Brand = %q, want %q", got, core.BrandLark) + if got := acct.ToCliConfig().Brand; got != brand.Lark { + t.Errorf("Brand = %q, want %q", got, brand.Lark) } } diff --git a/internal/credential/user_info.go b/internal/credential/user_info.go index 7631a91def..1f84533000 100644 --- a/internal/credential/user_info.go +++ b/internal/credential/user_info.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" ) type userInfo struct { @@ -18,8 +18,8 @@ type userInfo struct { } // fetchUserInfo calls /open-apis/authen/v1/user_info with a UAT to get the user's identity. -func fetchUserInfo(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, uat string) (*userInfo, error) { - ep := core.ResolveEndpoints(brand) +func fetchUserInfo(ctx context.Context, httpClient *http.Client, brand brandpkg.Brand, uat string) (*userInfo, error) { + ep := brandpkg.ResolveEndpoints(brand) url := ep.Open + "/open-apis/authen/v1/user_info" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 36b60e42d5..fc90a52f15 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -4,22 +4,9 @@ package envvars const ( - CliAppID = "LARKSUITE_CLI_APP_ID" - CliAppSecret = "LARKSUITE_CLI_APP_SECRET" - CliBrand = "LARKSUITE_CLI_BRAND" - CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN" - CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN" - CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS" - CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" - - // Sidecar proxy (auth proxy mode) - CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384" - CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar - // Content safety scanning mode CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" - CliAgentName = "LARKSUITE_CLI_AGENT_NAME" CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE" CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 34868386cd..e2a9d52315 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -10,12 +10,13 @@ import ( ) const ( + agentNameEnv = "LARKSUITE_CLI_AGENT_NAME" agentNameMaxLen = 128 agentTraceMaxLen = 1024 ) func AgentName() string { - return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen) + return sanitizeSingleLine(os.Getenv(agentNameEnv), agentNameMaxLen) } func AgentTrace() string { diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index 39903e17bf..a28277ad92 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -9,35 +9,35 @@ import ( ) func TestAgentName_EmptyWhenEnvUnset(t *testing.T) { - t.Setenv(CliAgentName, "") + t.Setenv(agentNameEnv, "") if got := AgentName(); got != "" { t.Fatalf("AgentName() = %q, want empty when env unset", got) } } func TestAgentName_ReturnsCleanValue(t *testing.T) { - t.Setenv(CliAgentName, "claude-code") + t.Setenv(agentNameEnv, "claude-code") if got := AgentName(); got != "claude-code" { t.Fatalf("AgentName() = %q, want %q", got, "claude-code") } } func TestAgentName_TrimsWhitespace(t *testing.T) { - t.Setenv(CliAgentName, " cursor ") + t.Setenv(agentNameEnv, " cursor ") if got := AgentName(); got != "cursor" { t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor") } } func TestAgentName_RejectsCRLFInjection(t *testing.T) { - t.Setenv(CliAgentName, "agent\r\nX-Evil: attack") + t.Setenv(agentNameEnv, "agent\r\nX-Evil: attack") if got := AgentName(); got != "" { t.Fatalf("AgentName() = %q, want empty for CR/LF value", got) } } func TestAgentName_RejectsControlChar(t *testing.T) { - t.Setenv(CliAgentName, "agent\x01injected") + t.Setenv(agentNameEnv, "agent\x01injected") if got := AgentName(); got != "" { t.Fatalf("AgentName() = %q, want empty for control char value", got) } @@ -45,7 +45,7 @@ func TestAgentName_RejectsControlChar(t *testing.T) { func TestAgentName_RejectsOverlongValue(t *testing.T) { longVal := strings.Repeat("a", agentNameMaxLen+1) - t.Setenv(CliAgentName, longVal) + t.Setenv(agentNameEnv, longVal) if got := AgentName(); got != "" { t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen) } diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index bc200b4de3..f274677a42 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -9,19 +9,19 @@ import ( "net/url" "strings" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" ) // ClassifyContext is the contextual data BuildAPIError uses to populate // identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL). // Brand and Identity are plain strings at this boundary; ConsoleURL normalizes -// Brand through core.ParseBrand, so callers can pass a raw brand string without -// coupling this contract to core's brand enum. +// Brand through brandpkg.ParseBrand, so callers can pass a raw brand string without +// coupling this contract to the brand enum. type ClassifyContext struct { Brand string // "feishu" | "lark" — drives console_url host AppID string // placed in console_url - Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary + Identity string // "user" / "bot" / "" — caller converts identitypkg.Identity at the boundary LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm } @@ -462,7 +462,7 @@ func ConsoleURL(brand, appID string, scopes []string) string { // parameters via `&`/`#`. The brand→host mapping is owned by core so the // open-platform base URL stays a single source of truth. base := fmt.Sprintf("%s/page/scope-apply?clientID=%s", - core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID)) + brandpkg.ResolveOpenBaseURL(brandpkg.ParseBrand(brand)), url.QueryEscape(appID)) if len(scopes) == 0 { return base } diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 849285694d..1230baa6c0 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -17,13 +17,13 @@ import ( "sync" "time" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/busdiscover" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/source" "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/lockfile" + "github.com/larksuite/cli/internal/workspace" ) const ( @@ -73,7 +73,7 @@ func (b *Bus) Run(ctx context.Context) error { // alive.lock before bind: closes the cleanup-TOCTOU race where two newly forked // buses each unlink and rebind the socket. Brief retry covers stop-then-restart. - eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(b.appID)) + eventsDir := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(b.appID)) pidHandle, pidErr := acquireAliveLock(eventsDir) if pidErr != nil { if errors.Is(pidErr, lockfile.ErrHeld) { diff --git a/internal/event/busdiscover/busdiscover.go b/internal/event/busdiscover/busdiscover.go index 53e67cf930..5c50675725 100644 --- a/internal/event/busdiscover/busdiscover.go +++ b/internal/event/busdiscover/busdiscover.go @@ -8,7 +8,7 @@ import ( "path/filepath" "time" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/workspace" ) type Process struct { @@ -22,7 +22,7 @@ type Scanner interface { } func Default() Scanner { - return &fsScanner{eventsDir: filepath.Join(core.GetConfigDir(), "events")} + return &fsScanner{eventsDir: filepath.Join(workspace.GetConfigDir(), "events")} } type fsScanner struct { diff --git a/internal/event/consume/startup.go b/internal/event/consume/startup.go index 890e1c7723..35d11691f8 100644 --- a/internal/event/consume/startup.go +++ b/internal/event/consume/startup.go @@ -17,12 +17,12 @@ import ( "time" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) const ( @@ -64,7 +64,7 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain // ErrHeld = another consume is forking; let dial retry catch its bus. pid, forkErr := forkBus(tr, appID, profileName, domain) if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) { - eventsRoot := filepath.Join(core.GetConfigDir(), "events") + eventsRoot := filepath.Join(workspace.GetConfigDir(), "events") return nil, errs.NewInternalError(errs.SubtypeUnknown, "failed to start event bus daemon: %s", forkErr). WithCause(forkErr). @@ -86,7 +86,7 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain } } - logPath := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.log") + logPath := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.log") fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.") fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.") fmt.Fprintf(errOut, "[event] logs: %s\n", logPath) @@ -125,7 +125,7 @@ func probeAndDialBus(tr transport.IPC, addr string) (net.Conn, error) { // forkBus holds bus.fork.lock until the spawned daemon is dial-able, so concurrent callers can't race past the empty-socket gap and fork independent buses. func forkBus(tr transport.IPC, appID, profileName, domain string) (int, error) { - lockPath := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.fork.lock") + lockPath := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.fork.lock") if err := vfs.MkdirAll(filepath.Dir(lockPath), 0700); err != nil { return 0, err } diff --git a/internal/event/transport/transport_unix.go b/internal/event/transport/transport_unix.go index 86dec4f4bf..470eded50e 100644 --- a/internal/event/transport/transport_unix.go +++ b/internal/event/transport/transport_unix.go @@ -10,9 +10,9 @@ import ( "path/filepath" "time" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) const dialTimeout = 5 * time.Second // matches winio.DialPipe for cross-platform symmetry @@ -36,7 +36,7 @@ func (t *unixTransport) Dial(addr string) (net.Conn, error) { // Address: NOT os.UserHomeDir — honours LARKSUITE_CLI_CONFIG_DIR override. func (t *unixTransport) Address(appID string) string { - return filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.sock") + return filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.sock") } func (t *unixTransport) Cleanup(addr string) { diff --git a/internal/core/strict_mode.go b/internal/identity/strict_mode.go similarity index 76% rename from internal/core/strict_mode.go rename to internal/identity/strict_mode.go index c9cfb42985..07fe90fc6e 100644 --- a/internal/core/strict_mode.go +++ b/internal/identity/strict_mode.go @@ -1,7 +1,19 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package identity + +// Identity represents the caller identity for API requests. +type Identity string + +const ( + AsUser Identity = "user" + AsBot Identity = "bot" + AsAuto Identity = "auto" +) + +// IsBot returns true if the identity is bot. +func (id Identity) IsBot() bool { return id == AsBot } // StrictMode represents the identity restriction policy. type StrictMode string diff --git a/internal/core/strict_mode_test.go b/internal/identity/strict_mode_test.go similarity index 98% rename from internal/core/strict_mode_test.go rename to internal/identity/strict_mode_test.go index 5d67b1546d..30f50fdc76 100644 --- a/internal/core/strict_mode_test.go +++ b/internal/identity/strict_mode_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package identity import "testing" diff --git a/internal/identitydiag/diagnostics.go b/internal/identitydiag/diagnostics.go index 0e7fb1cc96..83cfb9d713 100644 --- a/internal/identitydiag/diagnostics.go +++ b/internal/identitydiag/diagnostics.go @@ -13,11 +13,13 @@ import ( "strings" "time" + "github.com/larksuite/cli/brand" extcred "github.com/larksuite/cli/extension/credential" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/identity" ) const ( @@ -58,7 +60,7 @@ type Identity struct { // Diagnose checks bot and user identities separately. When verify is false, // it only reports local readiness and skips server calls. -func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Result { +func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig, verify bool) Result { if ctx == nil { ctx = context.Background() } @@ -87,7 +89,7 @@ func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string { return name } -func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result { +func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig, provider string, verify bool) Result { if cfg == nil || cfg.AppID == "" { notConfigured := Identity{ Status: StatusNotConfigured, @@ -106,7 +108,7 @@ func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConf } } -func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity { +func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig, provider string, supported, verify bool) Identity { if !supported { return notProvidedExternally("Bot", provider) } @@ -128,7 +130,7 @@ func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliC return id } -func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity { +func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig, provider string, supported, verify bool) Identity { if !supported { return notProvidedExternally("User", provider) } @@ -153,7 +155,7 @@ func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.Cli if !verify { return id } - if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil { + if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identity.AsUser, cfg.AppID)); err != nil { return externalVerifyFailed(id, "User", provider, err) } id.Verified = boolPtr(true) @@ -187,7 +189,7 @@ func externalCredentialHint(provider string) string { return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider) } -func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity { +func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig, verify bool) Identity { if cfg == nil || cfg.AppID == "" { return Identity{ Status: StatusNotConfigured, @@ -250,7 +252,7 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v return id } -func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity { +func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig, verify bool) Identity { if cfg == nil || cfg.AppID == "" { return Identity{ Status: StatusNotConfigured, @@ -337,11 +339,11 @@ func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, return id } -func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig) (string, error) { +func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig) (string, error) { if f == nil || f.Credential == nil { return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT} } - result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, cfg.AppID)) + result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identity.AsBot, cfg.AppID)) if err != nil { return "", err } @@ -356,14 +358,14 @@ type botInfo struct { AppName string } -func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, token string) (*botInfo, error) { +func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *configpkg.CliConfig, token string) (*botInfo, error) { httpClient, err := f.HttpClient() if err != nil { return nil, fmt.Errorf("create HTTP client: %w", err) } ctx, cancel := context.WithTimeout(ctx, verifyTimeout) defer cancel() - url := strings.TrimRight(core.ResolveEndpoints(cfg.Brand).Open, "/") + "/open-apis/bot/v3/info" + url := strings.TrimRight(brand.ResolveEndpoints(cfg.Brand).Open, "/") + "/open-apis/bot/v3/info" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err diff --git a/internal/identitydiag/diagnostics_test.go b/internal/identitydiag/diagnostics_test.go index fa4a32341b..70d3e11a86 100644 --- a/internal/identitydiag/diagnostics_test.go +++ b/internal/identitydiag/diagnostics_test.go @@ -10,17 +10,18 @@ import ( "testing" "time" + "github.com/larksuite/cli/brand" extcred "github.com/larksuite/cli/extension/credential" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/zalando/go-keyring" ) func TestDiagnose_NoUserReportsBotReadyAndUserMissing(t *testing.T) { - cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu} f, _, _, _ := cmdutil.TestFactory(t, cfg) got := Diagnose(context.Background(), f, cfg, false) @@ -33,7 +34,7 @@ func TestDiagnose_NoUserReportsBotReadyAndUserMissing(t *testing.T) { } func TestDiagnose_BotIdentityNotConfigured(t *testing.T) { - cfg := &core.CliConfig{AppID: "test-app", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{AppID: "test-app", Brand: brand.Feishu} f, _, _, _ := cmdutil.TestFactory(t, cfg) got := Diagnose(context.Background(), f, cfg, false) @@ -43,7 +44,7 @@ func TestDiagnose_BotIdentityNotConfigured(t *testing.T) { } func TestDiagnose_VerifyBotIdentity(t *testing.T) { - cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu} f, _, _, reg := cmdutil.TestFactory(t, cfg) stub := &httpmock.Stub{ Method: http.MethodGet, @@ -79,10 +80,10 @@ func TestDiagnose_VerifyUserIdentity(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-user", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_user", UserName: "tester", } @@ -135,7 +136,7 @@ func TestDiagnose_VerifyUserIdentity(t *testing.T) { } func TestDiagnose_VerifyBotIdentity_HTTPErrorSurfacesEnvelope(t *testing.T) { - cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu} f, _, _, reg := cmdutil.TestFactory(t, cfg) reg.Register(&httpmock.Stub{ Method: http.MethodGet, @@ -160,7 +161,7 @@ func TestDiagnose_VerifyBotIdentity_HTTPErrorSurfacesEnvelope(t *testing.T) { } func TestDiagnose_VerifyBotIdentity_BusinessErrorCode(t *testing.T) { - cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu} f, _, _, reg := cmdutil.TestFactory(t, cfg) reg.Register(&httpmock.Stub{ Method: http.MethodGet, @@ -185,10 +186,10 @@ func TestDiagnose_VerifyUserIdentity_ServerRejects(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-reject", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_user", UserName: "tester", } @@ -241,10 +242,10 @@ func TestDiagnose_UserIdentityExpired(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-expired", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_expired", UserName: "tester", } @@ -275,10 +276,10 @@ func TestDiagnose_UserIdentityExpired(t *testing.T) { func TestDiagnose_BotIdentityStrictUserOnly(t *testing.T) { // SupportedIdentities = SupportsUser (1) only — bot path should be // reported as not_configured even though an app secret is present. - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, SupportedIdentities: 1, } f, _, _, _ := cmdutil.TestFactory(t, cfg) @@ -290,7 +291,7 @@ func TestDiagnose_BotIdentityStrictUserOnly(t *testing.T) { } func TestDiagnose_UserIdentityMissingAppConfig(t *testing.T) { - cfg := &core.CliConfig{Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{Brand: brand.Feishu} f, _, _, _ := cmdutil.TestFactory(t, cfg) got := Diagnose(context.Background(), f, cfg, false) @@ -320,10 +321,10 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-needs-refresh", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_refresh", UserName: "tester", } @@ -368,13 +369,13 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext return p.token, nil } -func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory { +func externalFactory(prov *fakeExtProvider, cfg *configpkg.CliConfig) *cmdutil.Factory { cred := credential.NewCredentialProvider( []extcred.Provider{prov}, nil, nil, func() (*http.Client, error) { return nil, nil }, ) return &cmdutil.Factory{ - Config: func() (*core.CliConfig, error) { return cfg, nil }, + Config: func() (*configpkg.CliConfig, error) { return cfg, nil }, Credential: cred, IOStreams: &cmdutil.IOStreams{}, } @@ -398,7 +399,7 @@ func assertExternalHint(t *testing.T, hint string) { } func TestDiagnose_External_UserReady(t *testing.T) { - cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"} + cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"} f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) got := Diagnose(context.Background(), f, cfg, false) @@ -420,7 +421,7 @@ func TestDiagnose_External_UserReady(t *testing.T) { } func TestDiagnose_External_UserNotSignedIn(t *testing.T) { - cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)} + cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsAll)} f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) got := Diagnose(context.Background(), f, cfg, false) @@ -431,7 +432,7 @@ func TestDiagnose_External_UserNotSignedIn(t *testing.T) { } func TestDiagnose_External_BotOnly(t *testing.T) { - cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"} + cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"} f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) got := Diagnose(context.Background(), f, cfg, false) @@ -447,7 +448,7 @@ func TestDiagnose_External_BotOnly(t *testing.T) { } func TestDiagnose_External_UserOnly(t *testing.T) { - cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"} + cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Lark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"} f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) got := Diagnose(context.Background(), f, cfg, false) @@ -461,7 +462,7 @@ func TestDiagnose_External_UserOnly(t *testing.T) { } func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) { - cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"} + cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"} f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg) got := Diagnose(context.Background(), f, cfg, true) @@ -471,7 +472,7 @@ func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) { } func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) { - cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"} + cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"} f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) got := Diagnose(context.Background(), f, cfg, true) diff --git a/shortcuts/im/convert_lib/card.go b/internal/imcontent/card.go similarity index 99% rename from shortcuts/im/convert_lib/card.go rename to internal/imcontent/card.go index e58bf49595..635fb84e21 100644 --- a/shortcuts/im/convert_lib/card.go +++ b/internal/imcontent/card.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import ( "encoding/json" @@ -1639,7 +1639,7 @@ func (c *cardConverter) convertAt(prop cardObj) string { if name, _ := mention["name"].(string); name != "" { userName = name } - if id := extractMentionOpenId(mention["id"]); id != "" { + if id := extractMentionOpenID(mention["id"]); id != "" { actualUserID = id fromMentions = true } diff --git a/shortcuts/im/convert_lib/card_test.go b/internal/imcontent/card_test.go similarity index 99% rename from shortcuts/im/convert_lib/card_test.go rename to internal/imcontent/card_test.go index 1c25486eaf..1f41f8eadd 100644 --- a/shortcuts/im/convert_lib/card_test.go +++ b/internal/imcontent/card_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import ( "strings" diff --git a/shortcuts/im/convert_lib/card_userdsl.go b/internal/imcontent/card_userdsl.go similarity index 99% rename from shortcuts/im/convert_lib/card_userdsl.go rename to internal/imcontent/card_userdsl.go index 62a0168079..bbf7e3bb77 100644 --- a/shortcuts/im/convert_lib/card_userdsl.go +++ b/internal/imcontent/card_userdsl.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import ( "encoding/json" @@ -39,7 +39,7 @@ func buildMentionAtMap(mentions []interface{}) map[string]string { item, _ := raw.(map[string]interface{}) key, _ := item["key"].(string) name, _ := item["name"].(string) - openID := extractMentionOpenId(item["id"]) + openID := extractMentionOpenID(item["id"]) if key == "" { continue } diff --git a/shortcuts/im/convert_lib/card_userdsl_test.go b/internal/imcontent/card_userdsl_test.go similarity index 99% rename from shortcuts/im/convert_lib/card_userdsl_test.go rename to internal/imcontent/card_userdsl_test.go index 6f73ea3f86..7e1f0f2f7f 100644 --- a/shortcuts/im/convert_lib/card_userdsl_test.go +++ b/internal/imcontent/card_userdsl_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import ( "encoding/json" diff --git a/internal/imcontent/convert.go b/internal/imcontent/convert.go new file mode 100644 index 0000000000..25a688d43a --- /dev/null +++ b/internal/imcontent/convert.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package imcontent converts Lark message content into human-readable text. +package imcontent + +import "fmt" + +// ContentConverter converts one message type's raw content. +type ContentConverter interface { + Convert(ctx *ConvertContext) string +} + +// ConvertContext contains the data needed by pure message conversion. +type ConvertContext struct { + RawContent string + MentionMap map[string]string + Mentions []interface{} +} + +var converters = map[string]ContentConverter{ + "text": textConverter{}, + "post": postConverter{}, + "image": imageConverter{}, + "file": fileConverter{}, + "audio": audioMsgConverter{}, + "video": videoMsgConverter{}, + "media": videoMsgConverter{}, + "sticker": stickerConverter{}, + "interactive": interactiveConverter{}, + "share_chat": shareChatConverter{}, + "share_user": shareUserConverter{}, + "location": locationConverter{}, + "merge_forward": mergeForwardConverter{}, + "folder": folderConverter{}, + "share_calendar_event": calendarEventConverter{}, + "calendar": calendarInviteConverter{}, + "general_calendar": generalCalendarConverter{}, + "video_chat": videoChatConverter{}, + "system": systemConverter{}, + "todo": todoConverter{}, + "vote": voteConverter{}, + "hongbao": hongbaoConverter{}, +} + +// ConvertBodyContent converts a raw message body to human-readable text. +func ConvertBodyContent(messageType string, ctx *ConvertContext) string { + if ctx == nil || ctx.RawContent == "" { + return "" + } + if converter, ok := converters[messageType]; ok { + return converter.Convert(ctx) + } + return fmt.Sprintf("[%s]", messageType) +} + +type mergeForwardConverter struct{} + +func (mergeForwardConverter) Convert(ctx *ConvertContext) string { + ids := ParseMergeForwardIDs(ctx.RawContent) + if len(ids) > 0 { + return fmt.Sprintf("[Merged forward: %d messages]", len(ids)) + } + return "[Merged forward]" +} + +// ParseMergeForwardIDs extracts message IDs from merge_forward content. +func ParseMergeForwardIDs(raw string) []string { + parsed, err := ParseJSONObject(raw) + if err != nil { + return nil + } + rawIDs, _ := parsed["create_message_ids"].([]interface{}) + ids := make([]string, 0, len(rawIDs)) + for _, rawID := range rawIDs { + if id, ok := rawID.(string); ok { + ids = append(ids, id) + } + } + return ids +} + +func extractMentionOpenID(id interface{}) string { + switch value := id.(type) { + case string: + return value + case map[string]interface{}: + openID, _ := value["open_id"].(string) + return openID + default: + return "" + } +} diff --git a/internal/imcontent/helpers.go b/internal/imcontent/helpers.go new file mode 100644 index 0000000000..7ae06d271c --- /dev/null +++ b/internal/imcontent/helpers.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package imcontent + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +// ParseJSONObject parses a raw JSON string into a map. +func ParseJSONObject(raw string) (map[string]interface{}, error) { + var value map[string]interface{} + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, err + } + return value, nil +} + +func invalidJSONPlaceholder(kind string) string { + if kind == "" { + return "[Invalid JSON content]" + } + return fmt.Sprintf("[Invalid %s JSON]", kind) +} + +// BuildMentionKeyMap builds a key-to-name lookup from a message mentions array. +func BuildMentionKeyMap(mentions []interface{}) map[string]string { + result := map[string]string{} + for _, raw := range mentions { + item, _ := raw.(map[string]interface{}) + key, _ := item["key"].(string) + name, _ := item["name"].(string) + if key != "" && name != "" { + result[key] = name + } + } + return result +} + +// ResolveMentionKeys replaces mention keys in text with @name format. +func ResolveMentionKeys(text string, mentionMap map[string]string) string { + for key, name := range mentionMap { + text = strings.ReplaceAll(text, key, "@"+name) + } + return text +} + +// FormatTimestamp converts a Unix timestamp string in seconds or milliseconds +// to local time. It returns an empty string for empty or invalid values. +func FormatTimestamp(timestamp string) string { + if timestamp == "" { + return "" + } + value, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || value == 0 { + return "" + } + if len(strings.TrimLeft(timestamp, "+-")) >= 13 { + value /= 1000 + } + return time.Unix(value, 0).Local().Format("2006-01-02 15:04:05") +} + +var xmlBodyEscaper = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", +) + +func xmlEscapeBody(value string) string { + return xmlBodyEscaper.Replace(value) +} + +func escapeMDLinkText(value string) string { + value = strings.ReplaceAll(value, `[`, `\[`) + value = strings.ReplaceAll(value, `]`, `\]`) + return value +} + +// ExtractPostBlocksText extracts plain text from post-style content blocks. +func ExtractPostBlocksText(blocks []interface{}) string { + var lines []string + for _, paragraph := range blocks { + elements, _ := paragraph.([]interface{}) + var builder strings.Builder + for _, raw := range elements { + element, _ := raw.(map[string]interface{}) + builder.WriteString(renderPostElem(element)) + } + if line := builder.String(); line != "" { + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} diff --git a/shortcuts/im/convert_lib/media.go b/internal/imcontent/media.go similarity index 99% rename from shortcuts/im/convert_lib/media.go rename to internal/imcontent/media.go index 2bac70dc45..bbb295cf50 100644 --- a/shortcuts/im/convert_lib/media.go +++ b/internal/imcontent/media.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import "fmt" diff --git a/shortcuts/im/convert_lib/misc.go b/internal/imcontent/misc.go similarity index 97% rename from shortcuts/im/convert_lib/misc.go rename to internal/imcontent/misc.go index 9ee9c53f20..0fb8173cc5 100644 --- a/shortcuts/im/convert_lib/misc.go +++ b/internal/imcontent/misc.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import ( "fmt" @@ -133,8 +133,8 @@ func formatCalendarContent(parsed map[string]interface{}, tag, extraAttrs string inner = append(inner, summary) } - start := formatTimestamp(startTime) - end := formatTimestamp(endTime) + start := FormatTimestamp(startTime) + end := FormatTimestamp(endTime) if start != "" && end != "" { inner = append(inner, start+" ~ "+end) } else if start != "" { @@ -213,13 +213,13 @@ func (todoConverter) Convert(ctx *ConvertContext) string { inner = append(inner, title) } if blocks, ok := summary["content"].([]interface{}); ok { - if text := extractPostBlocksText(blocks); text != "" { + if text := ExtractPostBlocksText(blocks); text != "" { inner = append(inner, text) } } } if dueTime, _ := parsed["due_time"].(string); dueTime != "" { - if formatted := formatTimestamp(dueTime); formatted != "" { + if formatted := FormatTimestamp(dueTime); formatted != "" { inner = append(inner, "Due: "+formatted) } } diff --git a/shortcuts/im/convert_lib/text.go b/internal/imcontent/text.go similarity index 96% rename from shortcuts/im/convert_lib/text.go rename to internal/imcontent/text.go index c047be7fd7..4e643ea0fc 100644 --- a/shortcuts/im/convert_lib/text.go +++ b/internal/imcontent/text.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import ( "fmt" @@ -97,6 +97,11 @@ func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} { return nil } +// UnwrapPostLocale returns the post body for the first supported locale shape. +func UnwrapPostLocale(parsed map[string]interface{}) map[string]interface{} { + return unwrapPostLocale(parsed) +} + // renderPostElem renders a single post (rich-text) element to its inline text // form: text/a/at carry their content through applyPostStyle for text.style // Markdown emphasis, emotion becomes :emoji_type:, md is passed through raw, diff --git a/shortcuts/im/convert_lib/text_test.go b/internal/imcontent/text_test.go similarity index 99% rename from shortcuts/im/convert_lib/text_test.go rename to internal/imcontent/text_test.go index 235b5857cd..4df877d8a1 100644 --- a/shortcuts/im/convert_lib/text_test.go +++ b/internal/imcontent/text_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package convertlib +package imcontent import "testing" diff --git a/internal/keychain/auth_log.go b/internal/keychain/auth_log.go deleted file mode 100644 index 7b175942c8..0000000000 --- a/internal/keychain/auth_log.go +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package keychain - -import ( - "fmt" - "log" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" -) - -// RuntimeDirFunc returns the workspace-aware config directory. -// Default: falls back to LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli (pre-workspace behavior). -// Injected by cmdutil.NewDefault → core.GetRuntimeDir after workspace detection. -// This avoids an import cycle (core → keychain → core). -var RuntimeDirFunc = defaultRuntimeDir - -func defaultRuntimeDir() string { - if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { - return dir - } - home, err := vfs.UserHomeDir() - if err != nil || home == "" { - // Silent fallback to a relative ".lark-cli": this package has no - // IOStreams in scope, so we cannot surface a warning here without - // violating the IOStreams injection boundary (enforced by lint). - // Users who hit this path should set LARKSUITE_CLI_CONFIG_DIR - // explicitly; the relative path will otherwise surface as an - // explicit I/O error at first use. - home = "" - } - return filepath.Join(home, ".lark-cli") -} - -var ( - authResponseLogger *log.Logger - authResponseLoggerOnce = &sync.Once{} - - authResponseLogNow = time.Now - authResponseLogArgs = func() []string { return os.Args } -) - -func authLogDir() string { - // LARKSUITE_CLI_LOG_DIR is the highest-priority override. - // When set, it bypasses workspace subtree routing entirely. - if dir := os.Getenv("LARKSUITE_CLI_LOG_DIR"); dir != "" { - safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_LOG_DIR") - if err == nil { - return safeDir - } - } - - // Fall back to the workspace-aware runtime dir. RuntimeDirFunc is injected - // by factory after workspace detection; before injection it defaults to - // the pre-workspace behavior so older call paths remain correct. - return filepath.Join(RuntimeDirFunc(), "logs") -} - -func initAuthLogger() { - authResponseLoggerOnce.Do(func() { - if authResponseLogger != nil { - return - } - - dir := authLogDir() - now := authResponseLogNow() - if err := vfs.MkdirAll(dir, 0700); err != nil { - return - } - - logName := fmt.Sprintf("auth-%s.log", now.Format("2006-01-02")) - logPath := filepath.Join(dir, logName) - if f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil { - authResponseLogger = log.New(f, "", 0) - cleanupOldLogs(dir, now) - } - }) -} - -func FormatAuthCmdline(args []string) string { - if len(args) == 0 { - return "" - } - - if len(args) <= 3 { - return strings.Join(args, " ") - } - - return strings.Join(args[:3], " ") + " ..." -} - -func LogAuthResponse(path string, status int, logID string) { - initAuthLogger() - if authResponseLogger == nil { - return - } - - authResponseLogger.Printf( - "[lark-cli] auth-response: time=%s path=%s status=%d x-tt-logid=%s cmdline=%s", - authResponseLogNow().Format(time.RFC3339Nano), - path, - status, - logID, - FormatAuthCmdline(authResponseLogArgs()), - ) -} - -func LogAuthError(component, op string, err error) { - if err == nil { - return - } - - initAuthLogger() - if authResponseLogger == nil { - return - } - - authResponseLogger.Printf( - "[lark-cli] auth-error: time=%s component=%s op=%s error=%q cmdline=%s", - authResponseLogNow().Format(time.RFC3339Nano), - component, - op, - err.Error(), - FormatAuthCmdline(authResponseLogArgs()), - ) -} - -func SetAuthLogHooksForTest(logger *log.Logger, now func() time.Time, args func() []string) func() { - prevLogger := authResponseLogger - prevNow := authResponseLogNow - prevArgs := authResponseLogArgs - prevOnce := authResponseLoggerOnce - - authResponseLogger = logger - authResponseLoggerOnce = &sync.Once{} - - if now != nil { - authResponseLogNow = now - } - if args != nil { - authResponseLogArgs = args - } - - return func() { - authResponseLogger = prevLogger - authResponseLogNow = prevNow - authResponseLogArgs = prevArgs - authResponseLoggerOnce = prevOnce - } -} - -func cleanupOldLogs(dir string, now time.Time) { - defer func() { - if r := recover(); r != nil { - fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] background log cleanup panicked: %v\n", r) - } - }() - - entries, err := vfs.ReadDir(dir) - if err != nil { - return - } - - now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - cutoff := now.AddDate(0, 0, -7) - for _, entry := range entries { - if entry.IsDir() || !strings.HasPrefix(entry.Name(), "auth-") || !strings.HasSuffix(entry.Name(), ".log") { - continue - } - - dateStr := strings.TrimPrefix(entry.Name(), "auth-") - dateStr = strings.TrimSuffix(dateStr, ".log") - - logDate, err := time.Parse("2006-01-02", dateStr) - if err != nil { - continue - } - - logDate = time.Date(logDate.Year(), logDate.Month(), logDate.Day(), 0, 0, 0, 0, now.Location()) - if logDate.Before(cutoff) { - _ = vfs.Remove(filepath.Join(dir, entry.Name())) - } - } -} diff --git a/internal/keychain/auth_log_test.go b/internal/keychain/auth_log_test.go deleted file mode 100644 index 423d2b1cbd..0000000000 --- a/internal/keychain/auth_log_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package keychain - -import ( - "path/filepath" - "testing" -) - -// TestAuthLogDir_UsesValidatedLogDirEnv verifies that a valid absolute -// LARKSUITE_CLI_LOG_DIR is normalized and used as the auth log directory. -func TestAuthLogDir_UsesValidatedLogDirEnv(t *testing.T) { - base := t.TempDir() - base, _ = filepath.EvalSymlinks(base) - t.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(base, "logs", "..", "auth")) - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "") - - got := authLogDir() - want := filepath.Join(base, "auth") - if got != want { - t.Fatalf("authLogDir() = %q, want %q", got, want) - } -} - -// TestAuthLogDir_InvalidLogDirFallsBackToConfigDir verifies that an invalid -// LARKSUITE_CLI_LOG_DIR falls back to LARKSUITE_CLI_CONFIG_DIR/logs. -func TestAuthLogDir_InvalidLogDirFallsBackToConfigDir(t *testing.T) { - t.Setenv("LARKSUITE_CLI_LOG_DIR", "relative-logs") - configDir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) - - got := authLogDir() - want := filepath.Join(configDir, "logs") - if got != want { - t.Fatalf("authLogDir() = %q, want %q", got, want) - } -} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 334442efbb..4dc88d95df 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -10,6 +10,7 @@ import ( "fmt" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/authlog" ) var ( @@ -46,7 +47,7 @@ func wrapError(op string, err error) error { func() { defer func() { recover() }() - LogAuthError("keychain", op, fmt.Errorf("keychain %s error: %w", op, err)) + authlog.Shared().LogError("keychain", op, fmt.Errorf("keychain %s error: %w", op, err)) }() return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg). diff --git a/internal/keychain/keychain_other.go b/internal/keychain/keychain_other.go index d0d094dd5a..783bc8a784 100644 --- a/internal/keychain/keychain_other.go +++ b/internal/keychain/keychain_other.go @@ -35,7 +35,8 @@ func StorageDir(service string) string { home, err := vfs.UserHomeDir() if err != nil || home == "" { // If home is missing, fallback to relative path and print warning. - // This matches the behavior in internal/core/config.go. + // The relative fallback matches internal/workspace.GetBaseConfigDir, + // which cannot warn because it has no IOStreams in scope. fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) } xdgData := filepath.Join(home, ".local", "share") diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go index 6f64298d06..2e46f11322 100644 --- a/internal/lockfile/lockfile.go +++ b/internal/lockfile/lockfile.go @@ -10,8 +10,8 @@ import ( "path/filepath" "regexp" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) // safeIDChars strips path-traversal chars from app IDs. @@ -34,7 +34,7 @@ func ForSubscribe(appID string) (*LockFile, error) { if appID == "" { return nil, fmt.Errorf("app ID must not be empty") } - dir := filepath.Join(core.GetConfigDir(), "locks") + dir := filepath.Join(workspace.GetConfigDir(), "locks") if err := vfs.MkdirAll(dir, 0700); err != nil { return nil, fmt.Errorf("create lock dir: %w", err) } diff --git a/internal/meta/identity.go b/internal/meta/identity.go index 392e0c4ed3..23354cf03f 100644 --- a/internal/meta/identity.go +++ b/internal/meta/identity.go @@ -9,9 +9,9 @@ import "sort" // accepts. It is a distinct type so the two directions of the token<->identity // mapping below cannot be swapped silently — a bare string compiles on either // side of a string/string signature, a Token does not. The CLI identity -// vocabulary ("bot"/"user") already has a home in internal/core (core.Identity); -// meta is a leaf and must not import core, so the identity side stays a plain -// string here and is typed at the core boundary. +// vocabulary ("bot"/"user") already has a home in internal/identity +// (identity.Identity); meta is a leaf and must not import it, so the identity +// side stays a plain string here and is typed at that boundary instead. type Token string const ( diff --git a/internal/binding/json_pointer.go b/internal/openclawbind/json_pointer.go similarity index 99% rename from internal/binding/json_pointer.go rename to internal/openclawbind/json_pointer.go index e3f46e9002..fa3688a0ec 100644 --- a/internal/binding/json_pointer.go +++ b/internal/openclawbind/json_pointer.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "fmt" diff --git a/internal/binding/json_pointer_test.go b/internal/openclawbind/json_pointer_test.go similarity index 99% rename from internal/binding/json_pointer_test.go rename to internal/openclawbind/json_pointer_test.go index 26f0ec9610..190bfbab3f 100644 --- a/internal/binding/json_pointer_test.go +++ b/internal/openclawbind/json_pointer_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "testing" diff --git a/internal/binding/lark_channel.go b/internal/openclawbind/lark_channel.go similarity index 96% rename from internal/binding/lark_channel.go rename to internal/openclawbind/lark_channel.go index f80afb53ad..a07e55fce6 100644 --- a/internal/binding/lark_channel.go +++ b/internal/openclawbind/lark_channel.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "encoding/json" @@ -18,7 +18,7 @@ type LarkChannelRoot struct { // Secrets is an optional registry of secret providers — same shape as // openclaw's `secrets` block. Lets bridge declare `exec` provider scripts // (for AES-encrypted secret backends), `env` allowlists, or `file` - // indirection rules. Resolved by binding.ResolveSecretInput. + // indirection rules. Resolved by ResolveSecretInput. Secrets *SecretsConfig `json:"secrets,omitempty"` } diff --git a/internal/binding/lark_channel_test.go b/internal/openclawbind/lark_channel_test.go similarity index 99% rename from internal/binding/lark_channel_test.go rename to internal/openclawbind/lark_channel_test.go index 4908556b48..9b19994e1e 100644 --- a/internal/binding/lark_channel_test.go +++ b/internal/openclawbind/lark_channel_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "os" diff --git a/internal/binding/reader.go b/internal/openclawbind/reader.go similarity index 96% rename from internal/binding/reader.go rename to internal/openclawbind/reader.go index 1778212150..2845cd7a2f 100644 --- a/internal/binding/reader.go +++ b/internal/openclawbind/reader.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "encoding/json" diff --git a/internal/binding/reader_test.go b/internal/openclawbind/reader_test.go similarity index 99% rename from internal/binding/reader_test.go rename to internal/openclawbind/reader_test.go index 0f9611e641..7c37f1bd24 100644 --- a/internal/binding/reader_test.go +++ b/internal/openclawbind/reader_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "os" diff --git a/internal/binding/secret_resolve.go b/internal/openclawbind/secret_resolve.go similarity index 99% rename from internal/binding/secret_resolve.go rename to internal/openclawbind/secret_resolve.go index da85369135..e11acf82cb 100644 --- a/internal/binding/secret_resolve.go +++ b/internal/openclawbind/secret_resolve.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "fmt" diff --git a/internal/binding/secret_resolve_exec.go b/internal/openclawbind/secret_resolve_exec.go similarity index 98% rename from internal/binding/secret_resolve_exec.go rename to internal/openclawbind/secret_resolve_exec.go index eb4a8632a2..160bcf2340 100644 --- a/internal/binding/secret_resolve_exec.go +++ b/internal/openclawbind/secret_resolve_exec.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "bytes" @@ -11,6 +11,8 @@ import ( "os/exec" "path/filepath" "time" + + "github.com/larksuite/cli/internal/secaudit" ) // execRequest is the JSON payload sent to exec provider's stdin. @@ -69,7 +71,7 @@ func prepareExecRun(ref *SecretRef, providerName string, pc *ProviderConfig, get return nil, fmt.Errorf("exec provider command is empty") } - securePath, err := AssertSecurePath(AuditParams{ + securePath, err := secaudit.AssertSecurePath(secaudit.AuditParams{ TargetPath: pc.Command, Label: "exec provider command", TrustedDirs: pc.TrustedDirs, diff --git a/internal/binding/secret_resolve_exec_test.go b/internal/openclawbind/secret_resolve_exec_test.go similarity index 99% rename from internal/binding/secret_resolve_exec_test.go rename to internal/openclawbind/secret_resolve_exec_test.go index dbc6610cfc..2e029fc76c 100644 --- a/internal/binding/secret_resolve_exec_test.go +++ b/internal/openclawbind/secret_resolve_exec_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "fmt" diff --git a/internal/binding/secret_resolve_file.go b/internal/openclawbind/secret_resolve_file.go similarity index 96% rename from internal/binding/secret_resolve_file.go rename to internal/openclawbind/secret_resolve_file.go index 26b5f731c7..ce54ff7b61 100644 --- a/internal/binding/secret_resolve_file.go +++ b/internal/openclawbind/secret_resolve_file.go @@ -1,13 +1,14 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "encoding/json" "fmt" "strings" + "github.com/larksuite/cli/internal/secaudit" "github.com/larksuite/cli/internal/vfs" ) @@ -34,7 +35,7 @@ func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) { targetPath := expandTildePath(pc.Path) // Security audit on file path - securePath, err := AssertSecurePath(AuditParams{ + securePath, err := secaudit.AssertSecurePath(secaudit.AuditParams{ TargetPath: targetPath, Label: "secrets.providers file path", TrustedDirs: pc.TrustedDirs, diff --git a/internal/binding/secret_resolve_file_test.go b/internal/openclawbind/secret_resolve_file_test.go similarity index 99% rename from internal/binding/secret_resolve_file_test.go rename to internal/openclawbind/secret_resolve_file_test.go index fc43508dcb..4a68e19264 100644 --- a/internal/binding/secret_resolve_file_test.go +++ b/internal/openclawbind/secret_resolve_file_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "os" diff --git a/internal/binding/secret_resolve_test.go b/internal/openclawbind/secret_resolve_test.go similarity index 99% rename from internal/binding/secret_resolve_test.go rename to internal/openclawbind/secret_resolve_test.go index 1d377d28dd..07203809bc 100644 --- a/internal/binding/secret_resolve_test.go +++ b/internal/openclawbind/secret_resolve_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "testing" diff --git a/internal/binding/tilde.go b/internal/openclawbind/tilde.go similarity index 99% rename from internal/binding/tilde.go rename to internal/openclawbind/tilde.go index fe2933e1b2..26b6d43a43 100644 --- a/internal/binding/tilde.go +++ b/internal/openclawbind/tilde.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "os" diff --git a/internal/binding/tilde_test.go b/internal/openclawbind/tilde_test.go similarity index 99% rename from internal/binding/tilde_test.go rename to internal/openclawbind/tilde_test.go index 3d64723033..e054aed0b8 100644 --- a/internal/binding/tilde_test.go +++ b/internal/openclawbind/tilde_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "os" diff --git a/internal/binding/types.go b/internal/openclawbind/types.go similarity index 99% rename from internal/binding/types.go rename to internal/openclawbind/types.go index 8bf83813ce..b371418b03 100644 --- a/internal/binding/types.go +++ b/internal/openclawbind/types.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "encoding/json" diff --git a/internal/binding/types_test.go b/internal/openclawbind/types_test.go similarity index 99% rename from internal/binding/types_test.go rename to internal/openclawbind/types_test.go index 3710307c5e..2f0c69f351 100644 --- a/internal/binding/types_test.go +++ b/internal/openclawbind/types_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package openclawbind import ( "encoding/json" diff --git a/internal/output/emitter_legacy_compat_test.go b/internal/output/emitter_legacy_compat_test.go index 6063a898cc..36877d9df5 100644 --- a/internal/output/emitter_legacy_compat_test.go +++ b/internal/output/emitter_legacy_compat_test.go @@ -20,10 +20,12 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcs "github.com/larksuite/cli/extension/contentsafety" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -402,7 +404,7 @@ func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleO factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}} runtime := common.TestNewRuntimeContextForAPI( - context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot, + context.Background(), leaf, &configpkg.CliConfig{Brand: brand.Feishu}, factory, identity.AsBot, ) runtime.Format = opts.format runtime.JqExpr = opts.jq diff --git a/internal/outputdir/outputdir.go b/internal/outputdir/outputdir.go new file mode 100644 index 0000000000..c177d9efb8 --- /dev/null +++ b/internal/outputdir/outputdir.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package outputdir validates and creates directories used for command output. +package outputdir + +import ( + "path/filepath" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +// Ensure creates an output directory with owner-only permissions. +func Ensure(path string) error { + if !filepath.IsAbs(path) { + resolved, err := validate.SafeOutputPath(path) + if err != nil { + return err + } + path = resolved + } + return vfs.MkdirAll(path, 0700) +} diff --git a/internal/qualitygate/deptest/layering-edges.txt b/internal/qualitygate/deptest/layering-edges.txt new file mode 100644 index 0000000000..a433077d43 --- /dev/null +++ b/internal/qualitygate/deptest/layering-edges.txt @@ -0,0 +1 @@ +# from denied owner reason added_at diff --git a/internal/qualitygate/deptest/layering_test.go b/internal/qualitygate/deptest/layering_test.go new file mode 100644 index 0000000000..d3cb57e280 --- /dev/null +++ b/internal/qualitygate/deptest/layering_test.go @@ -0,0 +1,2395 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deptest + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "go/build/constraint" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "slices" + "sort" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/vfs" + "gopkg.in/yaml.v3" +) + +const modulePath = "github.com/larksuite/cli" + +// Mode selects whether a rule checks direct or transitive dependencies. +type Mode int + +const ( + // Direct checks package imports. + Direct Mode = iota + // Transitive checks the complete dependency set. + Transitive +) + +// Rule defines one package-layer dependency restriction. +type Rule struct { + Name string + Mode Mode + FromPrefix string + Denied []string + // ExceptFrom exempts import paths matched exactly, for packages whose whole + // job is to sit on the boundary the rule draws: a runtime gate, an assembly + // root, a demo of the fork pattern. Reach for ExceptEdges instead whenever + // the package merely happens to need one or two of the denied imports, since + // exempting it wholesale also clears every denied import added later. + ExceptFrom []string + // ExceptEdges exempts single (from, denied) pairs, both matched exactly. The + // package stays under the rule for everything else. + ExceptEdges []layeringEdge + // AllowedRepoDeps inverts the check. When set, every dependency inside this + // module that is not listed here (matched exactly) is a violation and Denied + // is unused. Dependencies outside the module - standard library and + // third-party packages - are never considered. Prefer this over Denied + // whenever the rule name promises a surface rather than a blocklist, so the + // guarantee cannot drift as the repository grows new top-level trees. + AllowedRepoDeps []string +} + +// examplesPrefix is the plugin-SDK example tree. Each subdirectory is a +// standalone main package demonstrating the sanctioned wrapper-main pattern: +// register a plugin from init, then hand the process to cmd.Execute. The +// customer forks built by tests/plugin_e2e/harness.go have the same shape. +const examplesPrefix = modulePath + "/extension/platform/examples" + +var rules = []Rule{ + { + Name: "extension-zero-internal", + Mode: Transitive, + FromPrefix: modulePath + "/extension", + Denied: []string{modulePath + "/internal"}, + // The wrapper-main demos import cmd deliberately — that fork is the + // thing they exist to show — so every internal package they reach is + // inherited through cmd rather than chosen by the demo. Exempting + // them by exact path (never by directory name) keeps two properties: + // a future examples/ subdirectory inherits no exemption, and + // examples-surface-only still stops a demo from reaching down on its + // own. Listing these edges in layering-edges.txt instead would also + // wedge the ratchet: they track cmd's transitive set, so any new + // internal package under cmd would demand a new row, which + // check-layering-ratchet.sh refuses by design. + ExceptFrom: []string{ + examplesPrefix + "/audit-observer", + examplesPrefix + "/readonly-policy", + }, + }, + { + // Demos exist to show the wrapper-main pattern, so the assembled CLI and + // the public plugin SDK are the only repository packages they may reach + // for. This is an allowlist rather than a few denied prefixes because + // extension-zero-internal exempts these packages from the transitive + // check: pinning the direct surface is the only thing that keeps the + // inherited chain bounded, and a blocklist would silently permit every + // tree nobody thought to deny (events, errs, cmd subpackages). + Name: "examples-surface-only", + Mode: Direct, + FromPrefix: examplesPrefix, + AllowedRepoDeps: []string{ + modulePath + "/cmd", + modulePath + "/extension/platform", + }, + }, + { + Name: "events-no-shortcuts", + Mode: Transitive, + FromPrefix: modulePath + "/events", + Denied: []string{modulePath + "/shortcuts"}, + }, + { + Name: "shortcuts-runtime-gate", + Mode: Direct, + FromPrefix: modulePath + "/shortcuts", + Denied: []string{ + modulePath + "/internal/auth", + modulePath + "/internal/keychain", + modulePath + "/internal/credential", + modulePath + "/internal/client", + modulePath + "/internal/vfs", + }, + // shortcuts/common is the runtime gate itself, so it holds all five. + ExceptFrom: []string{modulePath + "/shortcuts/common"}, + // gitcred only needs these two to read the git credential helper's + // store; it stays under the rule for auth, credential and client. + ExceptEdges: []layeringEdge{ + {From: modulePath + "/shortcuts/apps/gitcred", Denied: modulePath + "/internal/keychain"}, + {From: modulePath + "/shortcuts/apps/gitcred", Denied: modulePath + "/internal/vfs"}, + }, + }, + { + Name: "cmd-assembly-only", + Mode: Direct, + FromPrefix: modulePath + "/cmd", + Denied: []string{modulePath + "/shortcuts"}, + ExceptFrom: []string{ + modulePath + "/cmd", + modulePath + "/cmd/auth", + }, + }, + { + Name: "errs-leaf", + Mode: Direct, + FromPrefix: modulePath + "/errs", + Denied: []string{modulePath + "/"}, + }, + { + Name: "internal-no-upper", + Mode: Direct, + FromPrefix: modulePath + "/internal", + Denied: []string{ + modulePath + "/cmd", + modulePath + "/shortcuts", + modulePath + "/events", + }, + // The command-tree collector has to walk the assembled tree to export it; + // deptest_test.go asserts that dependency exists. It stays under the rule + // for shortcuts and events. + ExceptEdges: []layeringEdge{ + {From: modulePath + "/internal/qualitygate/cmd/manifest-export", Denied: modulePath + "/cmd"}, + }, + }, +} + +type listedPackage struct { + ImportPath string + Imports []string + Deps []string + // Dir and the file lists are only read by + // TestLayeringBuildConfigsSelectEveryFile, which needs the toolchain's own + // answer to "which files did this configuration compile". + Dir string + GoFiles []string + CgoFiles []string + TestGoFiles []string + XTestGoFiles []string +} + +type goListTarget struct { + GOOS string `yaml:"goos"` + GOARCH string `yaml:"goarch"` +} + +type commandFactory func(name string, args ...string) *exec.Cmd + +type goReleaserConfig struct { + Env []string `yaml:"env"` + Builds []goReleaserBuild `yaml:"builds"` +} + +type goReleaserBuild struct { + Builder string `yaml:"builder"` + GOOS []string `yaml:"goos"` + GOARCH []string `yaml:"goarch"` + GOARM []any `yaml:"goarm"` + GOAMD64 []any `yaml:"goamd64"` + GOARM64 []any `yaml:"goarm64"` + GOMIPS []any `yaml:"gomips"` + GOMIPS64 []any `yaml:"gomips64"` + GO386 []any `yaml:"go386"` + GOPPC64 []any `yaml:"goppc64"` + GORISCV64 []any `yaml:"goriscv64"` + Tool string `yaml:"tool"` + GoBinary string `yaml:"gobinary"` + Tags []string `yaml:"tags"` + Flags []string `yaml:"flags"` + Env []string `yaml:"env"` + Command string `yaml:"command"` + Overrides yaml.Node `yaml:"overrides"` + Targets []string `yaml:"targets"` + Ignore []map[string]any `yaml:"ignore"` + Skip bool `yaml:"skip"` +} + +type distTarget struct { + GOOS string + GOARCH string +} + +var layeringBuildTargets = []goListTarget{ + {GOOS: "linux", GOARCH: "amd64"}, + {GOOS: "linux", GOARCH: "arm64"}, + {GOOS: "linux", GOARCH: "riscv64"}, + {GOOS: "darwin", GOARCH: "amd64"}, + {GOOS: "darwin", GOARCH: "arm64"}, + {GOOS: "windows", GOARCH: "amd64"}, + {GOOS: "windows", GOARCH: "arm64"}, +} + +// maxBuildConstraintTerms bounds the satisfying-set search. The search is +// exponential in the number of distinct terms in one constraint, so a +// pathological expression would hang the suite instead of failing it. +const maxBuildConstraintTerms = 12 + +// layeringBuildConfigs derives the -tags values whose import graphs are unioned +// before the rules are evaluated. +// +// This is derived rather than hand-kept because a list of individual tags cannot +// express what `go list` needs. A file constrained by `foo && bar` is selected +// by neither `-tags foo` nor `-tags bar`; only `-tags foo,bar` selects it. A +// registry that records tag names therefore accepts "foo and bar are both +// registered" as coverage while the file sits in no graph at all — and the +// remedy such a registry prints ("union the tag") is what puts it there. +// +// Deriving the sets from the constraints removes the registry, and with it the +// exclusion list that used to carve out the sidecar demo tags: every constraint +// in the tree now contributes a tag set that satisfies it, so nothing is left +// unscanned by omission. +// +// This derivation is trusted only as far as TestLayeringBuildConfigsSelectEveryFile +// confirms it against the toolchain. +func layeringBuildConfigs(t *testing.T, root string) []string { + t.Helper() + builtin := toolchainBuildTerms(t) + + // The empty set stands for the files carrying no constraint at all. + sets := map[string]bool{"": true} + for _, group := range collectBuildConstraints(t, root) { + satisfying := satisfyingTagSets(t, group.Expr, builtin) + if len(satisfying) == 0 { + t.Errorf( + "build constraint %q in %v holds under no assignment; those files can never enter a graph", + group.Text, group.Files, + ) + continue + } + // One set per constraint suffices: the rules read the union of every + // graph, so a file only has to be selected once. satisfyingTagSets + // returns the cheapest set first, which keeps a platform-only + // constraint from adding a configuration it does not need. + sets[satisfying[0]] = true + } + + configs := make([]string, 0, len(sets)) + for set := range sets { + configs = append(configs, set) + } + sort.Strings(configs) + return configs +} + +// buildConstraintGroup is one distinct //go:build expression and the files that +// carry it. Grouping by expression keeps the satisfying-set search proportional +// to the number of distinct constraints rather than to the file count. +type buildConstraintGroup struct { + Text string + Expr constraint.Expr + Files []string +} + +// collectBuildConstraints parses the //go:build line of every Go file in the +// tree. Only the header is scanned, so a constraint quoted inside a string +// literal further down cannot register as one. +func collectBuildConstraints(t *testing.T, root string) []buildConstraintGroup { + t.Helper() + + byText := make(map[string]*buildConstraintGroup) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if skipLayeringScopeDir(root, path, entry.Name()) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + + content, err := vfs.ReadFile(path) + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + + line, found := buildConstraintLine(string(content)) + if !found { + return nil + } + expr, parseErr := constraint.Parse(line) + if parseErr != nil { + t.Errorf("parse build constraint %q in %s: %v", line, relative, parseErr) + return nil + } + + group := byText[line] + if group == nil { + group = &buildConstraintGroup{Text: line, Expr: expr} + byText[line] = group + } + if !slices.Contains(group.Files, relative) { + group.Files = append(group.Files, relative) + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + + groups := make([]buildConstraintGroup, 0, len(byText)) + for _, group := range byText { + sort.Strings(group.Files) + groups = append(groups, *group) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].Text < groups[j].Text }) + return groups +} + +// skipLayeringScopeDir reports directories outside the scope the constraint walk +// and the file-selection walk share: what `go list ./...` builds for this +// module. +// +// One predicate rather than two, because the two walks feed each other. The +// constraints found by one become the configurations the other is measured +// against, so a directory in scope for one and out of scope for the other is a +// contradiction: a constraint in a nested module would add a configuration whose +// only justification is a file that module walk never requires to be selected. +func skipLayeringScopeDir(root, path, name string) bool { + // Directories the Go tool never builds from. + if name == ".git" || name == "node_modules" || name == "testdata" || strings.HasPrefix(name, "_") { + return true + } + // Nested modules: `go list ./...` stops at them, and the rules are written + // against this module's import paths. + return path != root && isModuleRoot(path) +} + +// isModuleRoot reports whether dir declares its own module. +func isModuleRoot(dir string) bool { + _, err := vfs.Stat(filepath.Join(dir, "go.mod")) + return err == nil +} + +// layeringUnreleasedPlatformFiles names files that no executed configuration +// compiles because their constraint excludes every GOOS the release matrix +// builds. The rules govern what ships, and these files enter no shipped binary, +// so they are out of scope rather than unscanned by omission. +// +// The claim is checked rather than trusted: +// TestLayeringUnreleasedPlatformFilesStayOutOfScope rejects an entry whose +// constraint names a custom build tag — a tag set could have covered that, which +// makes it the derivation's bug and not a platform gap — and rejects an entry +// that some release target does compile. +var layeringUnreleasedPlatformFiles = map[string]string{ + "internal/riskcontrol/osmodel_other.go": "fallback for a GOOS outside the release matrix (!darwin && !windows && !linux)", +} + +// buildConstraintLine returns the //go:build line from a file header. The search +// stops at the package clause because a constraint is only a constraint above +// it. +func buildConstraintLine(content string) (string, bool) { + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(strings.TrimSuffix(raw, "\r")) + if strings.HasPrefix(line, "package ") { + return "", false + } + if constraint.IsGoBuild(line) { + return line, true + } + } + return "", false +} + +// satisfyingTagSets returns every set of custom tags under which expr can hold, +// cheapest first, as comma-joined -tags values. +// +// Platform terms are free variables: layeringBuildTargets already varies GOOS +// and GOARCH, and `-tags` cannot set them anyway. A set that only becomes +// satisfying under a port the target matrix does not build is therefore +// reported as covered here and caught by +// TestLayeringBuildConfigsSelectEveryFile instead, which asks the toolchain +// rather than this model. +func satisfyingTagSets(t *testing.T, expr constraint.Expr, builtin map[string]bool) []string { + t.Helper() + + terms := constraintTerms(expr) + if len(terms) > maxBuildConstraintTerms { + t.Fatalf( + "build constraint has %d distinct terms, more than the %d this search allows; split the constraint", + len(terms), maxBuildConstraintTerms, + ) + } + + sets := map[string]bool{} + for assignment := 0; assignment < 1< 0 || buildFlagsSetTags(build.Flags) { + return nil, fmt.Errorf("release build tags are unsupported") + } + if err := validateGoReleaserBuildEnv(build.Env); err != nil { + return nil, err + } + if buildHasMicroarchitectureMatrix(build) { + return nil, fmt.Errorf("microarchitecture matrices are unsupported") + } + if len(build.Targets) > 0 { + return explicitGoReleaserTargets(build.Targets, supported) + } + + gooses := build.GOOS + if len(gooses) == 0 { + gooses = []string{"darwin", "linux", "windows"} + } + goarches := build.GOARCH + if len(goarches) == 0 { + goarches = []string{"386", "amd64", "arm64"} + } + + targets := make(map[string]struct{}) + for _, goos := range gooses { + for _, goarch := range goarches { + if goarch == "arm" { + return nil, fmt.Errorf("GOARCH=arm requires explicit GOARM support") + } + target := goos + "/" + goarch + if _, ok := supported[target]; !ok { + continue + } + ignored, err := goReleaserTargetIgnored(goos, goarch, build.Ignore) + if err != nil { + return nil, err + } + if !ignored { + targets[target] = struct{}{} + } + } + } + return targets, nil +} + +func explicitGoReleaserTargets( + configured []string, + supported map[string]distTarget, +) (map[string]struct{}, error) { + targets := make(map[string]struct{}) + for _, configuredTarget := range configured { + if configuredTarget == "go_first_class" || configuredTarget == "go_118_first_class" { + return nil, fmt.Errorf("unsupported target selector %q", configuredTarget) + } + if hasGoReleaserTemplate(configuredTarget) { + return nil, fmt.Errorf("templated target %q is unsupported", configuredTarget) + } + + parts := strings.Split(configuredTarget, "_") + if len(parts) != 2 { + return nil, fmt.Errorf("malformed target %q", configuredTarget) + } + if parts[1] == "arm" { + return nil, fmt.Errorf("target %q requires explicit GOARM support", configuredTarget) + } + target := parts[0] + "/" + parts[1] + if _, ok := supported[target]; !ok { + return nil, fmt.Errorf("unsupported Go target %q", configuredTarget) + } + targets[target] = struct{}{} + } + return targets, nil +} + +func buildFlagsSetTags(flags []string) bool { + for _, flag := range flags { + if flag == "-tags" || strings.HasPrefix(flag, "-tags=") || + flag == "--tags" || strings.HasPrefix(flag, "--tags=") { + return true + } + } + return false +} + +func containsGoReleaserTemplate(values []string) bool { + for _, value := range values { + if hasGoReleaserTemplate(value) { + return true + } + } + return false +} + +func hasGoReleaserTemplate(value string) bool { + return strings.Contains(value, "{{") || strings.Contains(value, "}}") +} + +func validateGoReleaserBuildEnv(env []string) error { + for _, entry := range env { + if hasGoReleaserTemplate(entry) { + return fmt.Errorf("templated build environment entries are unsupported") + } + name, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + switch name { + case "CGO_ENABLED": + if value != "0" { + return fmt.Errorf("CGO_ENABLED=%s is unsupported", value) + } + default: + if !strings.HasPrefix(name, "GO") { + continue + } + return fmt.Errorf("build environment variable %q is unsupported", name) + } + } + return nil +} + +func buildHasMicroarchitectureMatrix(build goReleaserBuild) bool { + return len(build.GOARM) > 0 || + len(build.GOAMD64) > 0 || + len(build.GOARM64) > 0 || + len(build.GOMIPS) > 0 || + len(build.GOMIPS64) > 0 || + len(build.GO386) > 0 || + len(build.GOPPC64) > 0 || + len(build.GORISCV64) > 0 +} + +func goReleaserTargetIgnored(goos, goarch string, ignored []map[string]any) (bool, error) { + for _, entry := range ignored { + for key := range entry { + if key != "goos" && key != "goarch" { + return false, fmt.Errorf("unsupported ignore selector %q", key) + } + } + ignoredGOOS, err := optionalString(entry, "goos") + if err != nil { + return false, err + } + ignoredGOARCH, err := optionalString(entry, "goarch") + if err != nil { + return false, err + } + if (ignoredGOOS == "" || ignoredGOOS == goos) && + (ignoredGOARCH == "" || ignoredGOARCH == goarch) { + return true, nil + } + } + return false, nil +} + +func optionalString(values map[string]any, key string) (string, error) { + value, ok := values[key] + if !ok { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("ignore selector %q must be a string", key) + } + if hasGoReleaserTemplate(text) { + return "", fmt.Errorf("templated ignore selector %q is unsupported", key) + } + return text, nil +} + +func findVersion(t *testing.T, content, pattern, source string) string { + t.Helper() + match := regexp.MustCompile(pattern).FindStringSubmatch(content) + if len(match) != 2 { + t.Fatalf("find Go version in %s", source) + } + return match[1] +} + +func TestDecodeAndMergeListedPackages(t *testing.T) { + input := strings.NewReader( + `{"ImportPath":"example.com/a","Imports":["example.com/b"],"Deps":["example.com/c"]}` + "\n" + + `{"ImportPath":"example.com/d","Imports":[],"Deps":[]}` + "\n", + ) + packages, err := decodeListedPackages(input) + if err != nil { + t.Fatalf("decodeListedPackages returned an error: %v", err) + } + if len(packages) != 2 || packages[0].ImportPath != "example.com/a" || packages[1].ImportPath != "example.com/d" { + t.Fatalf("decodeListedPackages returned unexpected packages: %+v", packages) + } + + got := mergeStrings([]string{"example.com/b", "example.com/c"}, []string{"example.com/a", "example.com/b"}) + want := []string{"example.com/a", "example.com/b", "example.com/c"} + if !slices.Equal(got, want) { + t.Fatalf("mergeStrings returned %q, want %q", got, want) + } +} + +func TestGoListPackagesSeparatesStderr(t *testing.T) { + target := goListTarget{GOOS: "linux", GOARCH: "amd64"} + tests := []struct { + name string + tags string + wantArgs []string + }{ + { + name: "default graph", + wantArgs: []string{"list", "-json", "./..."}, + }, + { + name: "authsidecar graph", + tags: "authsidecar", + wantArgs: []string{"list", "-json", "-tags", "authsidecar", "./..."}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotName string + var gotArgs []string + packages, stderr, err := loadPackagesForTarget("", target, tt.tags, func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = slices.Clone(args) + cmd := exec.Command(os.Args[0], "-test.run=^TestGoListCommandHelperProcess$") + cmd.Env = append(os.Environ(), "GO_LIST_COMMAND_HELPER=1") + return cmd + }) + if err != nil { + t.Fatalf("loadPackagesForTarget returned an error: %v", err) + } + if gotName != "go" || !slices.Equal(gotArgs, tt.wantArgs) { + t.Fatalf("command = %q %q, want go %q", gotName, gotArgs, tt.wantArgs) + } + if !strings.Contains(stderr, "go: downloading example.com/module\n") { + t.Fatalf("loadPackagesForTarget stderr = %q, want module download diagnostic", stderr) + } + if len(packages) != 1 || packages[0].ImportPath != "example.com/package" { + t.Fatalf("loadPackagesForTarget returned unexpected packages: %+v", packages) + } + }) + } +} + +func TestGoListCommandHelperProcess(t *testing.T) { + if os.Getenv("GO_LIST_COMMAND_HELPER") != "1" { + return + } + fmt.Fprintln(os.Stdout, `{"ImportPath":"example.com/package","Imports":[],"Deps":[]}`) + fmt.Fprintln(os.Stderr, "go: downloading example.com/module") + os.Exit(0) +} + +func goListPackageGraph(t *testing.T, root string) []listedPackage { + t.Helper() + configs := layeringBuildConfigs(t, root) + packagesByPath := make(map[string]listedPackage) + for _, target := range layeringBuildTargets { + for _, tags := range configs { + listed := goListPackages(t, root, target, tags) + if len(listed) == 0 { + t.Fatalf( + "GOOS=%s GOARCH=%s tags=%q go list -json ./... returned no packages; the layering graph would silently under-cover", + target.GOOS, + target.GOARCH, + tags, + ) + } + for _, pkg := range listed { + merged := packagesByPath[pkg.ImportPath] + merged.ImportPath = pkg.ImportPath + merged.Imports = mergeStrings(merged.Imports, pkg.Imports) + merged.Deps = mergeStrings(merged.Deps, pkg.Deps) + packagesByPath[pkg.ImportPath] = merged + } + } + } + + packages := make([]listedPackage, 0, len(packagesByPath)) + for _, pkg := range packagesByPath { + packages = append(packages, pkg) + } + sort.Slice(packages, func(i, j int) bool { + return packages[i].ImportPath < packages[j].ImportPath + }) + return packages +} + +func goListPackages(t *testing.T, root string, target goListTarget, tags string) []listedPackage { + t.Helper() + packages, stderr, err := loadPackagesForTarget(root, target, tags, exec.Command) + if err != nil { + t.Fatalf( + "GOOS=%s GOARCH=%s tags=%q go list -json ./... failed: %v\n%s", + target.GOOS, + target.GOARCH, + tags, + err, + stderr, + ) + } + return packages +} + +func loadPackagesForTarget( + root string, + target goListTarget, + tags string, + newCommand commandFactory, +) ([]listedPackage, string, error) { + args := []string{"list", "-json"} + if tags != "" { + args = append(args, "-tags", tags) + } + args = append(args, "./...") + cmd := newCommand("go", args...) + cmd.Dir = root + env := cmd.Env + if env == nil { + env = os.Environ() + } + cmd.Env = append( + env, + "GOOS="+target.GOOS, + "GOARCH="+target.GOARCH, + "CGO_ENABLED=0", + ) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return nil, stderr.String(), err + } + + packages, err := decodeListedPackages(&stdout) + if err != nil { + return nil, stderr.String(), fmt.Errorf("decode go list output: %w", err) + } + return packages, stderr.String(), nil +} + +func decodeListedPackages(r io.Reader) ([]listedPackage, error) { + decoder := json.NewDecoder(r) + var packages []listedPackage + for { + var pkg listedPackage + err := decoder.Decode(&pkg) + if err == io.EOF { + return packages, nil + } + if err != nil { + return nil, err + } + packages = append(packages, pkg) + } +} + +func mergeStrings(left, right []string) []string { + values := make(map[string]struct{}, len(left)+len(right)) + for _, value := range left { + values[value] = struct{}{} + } + for _, value := range right { + values[value] = struct{}{} + } + merged := make([]string, 0, len(values)) + for value := range values { + merged = append(merged, value) + } + sort.Strings(merged) + return merged +} + +func sortedKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + return keys +} + +func evaluateLayeringRule(packages []listedPackage, rule Rule) []layeringViolation { + var violations []layeringViolation + for _, pkg := range packages { + if !matchesPackagePrefix(rule.FromPrefix, pkg.ImportPath) { + continue + } + if slices.Contains(rule.ExceptFrom, pkg.ImportPath) { + continue + } + + dependencies := pkg.Imports + if rule.Mode == Transitive { + dependencies = pkg.Deps + } + for _, dependency := range dependencies { + if !ruleRejectsDependency(rule, dependency) { + continue + } + if slices.Contains(rule.ExceptEdges, layeringEdge{From: pkg.ImportPath, Denied: dependency}) { + continue + } + violations = append(violations, layeringViolation{ + layeringEdge: layeringEdge{ + From: pkg.ImportPath, + Denied: dependency, + }, + Rule: rule.Name, + }) + } + } + sort.Slice(violations, func(i, j int) bool { + if violations[i].From != violations[j].From { + return violations[i].From < violations[j].From + } + return violations[i].Denied < violations[j].Denied + }) + return violations +} + +// ruleRejectsDependency reports whether dependency breaks rule, reading the +// allowlist when the rule declares one and the denied prefixes otherwise. +func ruleRejectsDependency(rule Rule, dependency string) bool { + if len(rule.AllowedRepoDeps) == 0 { + return matchesAnyPackagePrefix(rule.Denied, dependency) + } + if !isRepoPackage(dependency) { + return false + } + return !slices.Contains(rule.AllowedRepoDeps, dependency) +} + +// isRepoPackage reports whether importPath belongs to this module rather than +// the standard library or a third-party dependency. +func isRepoPackage(importPath string) bool { + return importPath == modulePath || matchesPackagePrefix(modulePath+"/", importPath) +} + +func matchesPackagePrefix(prefix, importPath string) bool { + if importPath == prefix { + return true + } + if !strings.HasPrefix(importPath, prefix) { + return false + } + return strings.HasSuffix(prefix, "/") || importPath[len(prefix)] == '/' +} + +func matchesAnyPackagePrefix(prefixes []string, importPath string) bool { + for _, prefix := range prefixes { + if matchesPackagePrefix(prefix, importPath) { + return true + } + } + return false +} + +func findUnseededLayeringViolations( + actual []layeringViolation, + seeded map[layeringEdge]seededLayeringEdge, +) []layeringViolation { + var unseeded []layeringViolation + for _, violation := range actual { + if _, ok := seeded[violation.layeringEdge]; !ok { + unseeded = append(unseeded, violation) + } + } + return unseeded +} + +func findStaleLayeringEdges( + seeded []seededLayeringEdge, + actual map[layeringEdge]struct{}, +) []seededLayeringEdge { + var stale []seededLayeringEdge + for _, edge := range seeded { + if _, ok := actual[edge.layeringEdge]; !ok { + stale = append(stale, edge) + } + } + return stale +} + +func readLayeringEdges(t *testing.T, path string) []seededLayeringEdge { + t.Helper() + file, err := vfs.Open(path) + if err != nil { + t.Fatalf("open layering edges: %v", err) + } + defer func() { + if err := file.Close(); err != nil { + t.Errorf("close layering edges: %v", err) + } + }() + + edges, err := parseLayeringEdges(file) + if err != nil { + t.Fatalf("parse layering edges: %v", err) + } + return edges +} + +func parseLayeringEdges(r io.Reader) ([]seededLayeringEdge, error) { + scanner := bufio.NewScanner(r) + var edges []seededLayeringEdge + var problems []string + for line := 1; scanner.Scan(); line++ { + text := strings.TrimRight(scanner.Text(), "\r") + if skipLayeringEdgeLine(text) { + continue + } + parts := strings.Split(text, "\t") + if len(parts) != 5 { + problems = append(problems, malformedLayeringEdge(line)) + continue + } + // Reject rather than trim: an import path never carries surrounding + // whitespace, so a padded field is a malformed row, not one to + // silently normalize into a different identity. + if hasBlank(parts...) || hasSurroundingWhitespace(parts...) { + problems = append(problems, malformedLayeringEdge(line)) + continue + } + addedAt, dateErr := time.Parse(time.DateOnly, parts[4]) + if dateErr != nil { + problems = append(problems, malformedLayeringEdge(line)) + continue + } + edges = append(edges, seededLayeringEdge{ + layeringEdge: layeringEdge{ + From: parts[0], + Denied: parts[1], + }, + Owner: parts[2], + Reason: parts[3], + AddedAt: addedAt, + Line: line, + }) + } + if err := scanner.Err(); err != nil { + problems = append(problems, "failed to scan layering edges: "+err.Error()) + } + if len(problems) > 0 { + return nil, fmt.Errorf("%s", strings.Join(problems, "\n")) + } + return edges, nil +} + +func skipLayeringEdgeLine(text string) bool { + trimmed := strings.TrimSpace(text) + return trimmed == "" || strings.HasPrefix(trimmed, "#") +} + +func hasBlank(values ...string) bool { + for _, value := range values { + if strings.TrimSpace(value) == "" { + return true + } + } + return false +} + +func hasSurroundingWhitespace(values ...string) bool { + for _, value := range values { + if value != strings.TrimSpace(value) { + return true + } + } + return false +} + +func malformedLayeringEdge(line int) string { + return fmt.Sprintf( + "line %d: layering edge row must have five tab-separated non-empty fields with added_at in YYYY-MM-DD format", + line, + ) +} + +func indexSeededLayeringEdges(t *testing.T, edges []seededLayeringEdge) map[layeringEdge]seededLayeringEdge { + t.Helper() + indexed := make(map[layeringEdge]seededLayeringEdge, len(edges)) + for _, edge := range edges { + if previous, ok := indexed[edge.layeringEdge]; ok { + t.Fatalf( + "duplicate layering edge at lines %d and %d: from=%s denied=%s", + previous.Line, + edge.Line, + edge.From, + edge.Denied, + ) + } + indexed[edge.layeringEdge] = edge + } + return indexed +} diff --git a/internal/qualitygate/publiccontent/scan_test.go b/internal/qualitygate/publiccontent/scan_test.go index ea670a109b..152a3d67da 100644 --- a/internal/qualitygate/publiccontent/scan_test.go +++ b/internal/qualitygate/publiccontent/scan_test.go @@ -829,7 +829,7 @@ func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing. func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) { got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{ - `AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`, + `AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,`, `cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`, `os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`, `rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`, diff --git a/internal/registry/loader.go b/internal/registry/loader.go index ab2bbaba16..43257180ee 100644 --- a/internal/registry/loader.go +++ b/internal/registry/loader.go @@ -13,7 +13,7 @@ import ( "strconv" "sync" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/update" ) @@ -66,12 +66,12 @@ var ( // Init initializes the registry with default brand (feishu). // It is safe to call multiple times (sync.Once). func Init() { - InitWithBrand(core.BrandFeishu) + InitWithBrand(brandpkg.Feishu) } // ConfiguredBrand reports the brand the registry was initialized with // (empty before initialization). Diagnostics and startup-order tests use it. -func ConfiguredBrand() core.LarkBrand { +func ConfiguredBrand() brandpkg.Brand { return configuredBrand } @@ -80,7 +80,7 @@ func ConfiguredBrand() core.LarkBrand { // It is safe to call multiple times (sync.Once). // Remote fetch errors are silently ignored when embedded data is available. // If no embedded data exists and no cache is found, a synchronous fetch is attempted. -func InitWithBrand(brand core.LarkBrand) { +func InitWithBrand(brand brandpkg.Brand) { initOnce.Do(func() { configuredBrand = brand // 1. Load embedded meta_data.json as baseline (no-op if not compiled in) diff --git a/internal/registry/loader_test.go b/internal/registry/loader_test.go index d37bb1a867..1086518b8b 100644 --- a/internal/registry/loader_test.go +++ b/internal/registry/loader_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/meta" ) @@ -52,7 +52,7 @@ func initWithCache(t *testing.T, embeddedVer, cacheVer string) { t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") t.Setenv("LARKSUITE_CLI_META_TTL", "3600") seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu") - InitWithBrand(core.BrandFeishu) + InitWithBrand(brand.Feishu) } func titleOf(t *testing.T, name string) string { diff --git a/internal/registry/registrytest/registrytest.go b/internal/registry/registrytest/registrytest.go index 3b3512a45e..a6979a651d 100644 --- a/internal/registry/registrytest/registrytest.go +++ b/internal/registry/registrytest/registrytest.go @@ -16,7 +16,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/vfs" ) @@ -65,7 +65,7 @@ func Seed(testRoot string) error { cacheMeta, err := json.Marshal(registry.CacheMeta{ LastCheckAt: time.Now().Unix(), Version: fixture.Version, - Brand: string(core.BrandFeishu), + Brand: string(brand.Feishu), }) if err != nil { return err diff --git a/internal/registry/remote.go b/internal/registry/remote.go index 6f47441dc4..948f2c20aa 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -15,12 +15,13 @@ import ( "sync" "time" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/build" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) const ( @@ -53,7 +54,7 @@ type remoteResponse struct { } // configuredBrand is set by InitWithBrand and determines which API host to use. -var configuredBrand core.LarkBrand +var configuredBrand brand.Brand // --- configuration helpers --- @@ -75,7 +76,7 @@ func remoteMetaURL(version string) string { if testMetaURL != "" { return testMetaURL } - base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition" + base := brand.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition" q := "protocol=meta&client_version=" + url.QueryEscape(build.Version) if version != "" { q += "&data_version=" + url.QueryEscape(version) @@ -95,7 +96,7 @@ func metaTTL() time.Duration { // --- cache path helpers --- func cacheDir() string { - return filepath.Join(core.GetConfigDir(), "cache") + return filepath.Join(workspace.GetConfigDir(), "cache") } func cachePath() string { diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index 15c7281c7b..a5b49bc17b 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/meta" ) @@ -511,7 +511,7 @@ func TestBrandSwitchInvalidatesCache(t *testing.T) { testMetaURL = ts.URL // Init with lark brand — should invalidate feishu cache and sync fetch - InitWithBrand(core.BrandLark) + InitWithBrand(brand.Lark) // The old feishu_svc should NOT be loaded from stale cache // The new lark_svc from sync fetch should be available @@ -524,7 +524,7 @@ func TestRemoteMetaURL_BrandSpecific(t *testing.T) { testMetaURL = "" // Default URL (feishu) with no version - configuredBrand = core.BrandFeishu + configuredBrand = brand.Feishu u := remoteMetaURL("") if !strings.Contains(u, "open.feishu.cn") { t.Errorf("expected feishu URL, got %s", u) @@ -534,7 +534,7 @@ func TestRemoteMetaURL_BrandSpecific(t *testing.T) { } // Lark brand with version param - configuredBrand = core.BrandLark + configuredBrand = brand.Lark u = remoteMetaURL("1.0.3") if !strings.Contains(u, "open.larksuite.com") { t.Errorf("expected lark URL, got %s", u) diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index c1af42d9ff..cc915b465c 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -7,7 +7,7 @@ import ( "fmt" "net/url" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" ) // ExtractRequiredScopes pulls scope names out of the API error's @@ -55,13 +55,13 @@ func SelectRecommendedScopeFromStrings(scopes []string, _ string) string { // BuildConsoleScopeURL returns the developer-console "apply scope" URL for the // given app and scope, branded for feishu / lark. Returns "" when appID or // scope is empty so callers can omit the field cleanly. -func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string { +func BuildConsoleScopeURL(brand brandpkg.Brand, appID, scope string) string { if appID == "" || scope == "" { return "" } return fmt.Sprintf( "%s/page/scope-apply?clientID=%s&scopes=%s", - core.ResolveOpenBaseURL(brand), + brandpkg.ResolveOpenBaseURL(brand), url.QueryEscape(appID), url.QueryEscape(scope), ) diff --git a/internal/registry/scope_hint_test.go b/internal/registry/scope_hint_test.go index e19628d6f0..8b6fc16681 100644 --- a/internal/registry/scope_hint_test.go +++ b/internal/registry/scope_hint_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" ) func TestExtractRequiredScopes_HappyPath(t *testing.T) { @@ -45,7 +45,7 @@ func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) { } func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) { - got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create") + got := BuildConsoleScopeURL(brand.Feishu, "cli_xxx", "docs:permission.member:create") if !strings.Contains(got, "open.feishu.cn") { t.Errorf("feishu brand should use open.feishu.cn host, got %s", got) } @@ -56,17 +56,17 @@ func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) { t.Errorf("scope not URL-escaped: %s", got) } - got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive") + got = BuildConsoleScopeURL(brand.Lark, "cli_yyy", "drive:drive") if !strings.Contains(got, "open.larksuite.com") { t.Errorf("lark brand should use open.larksuite.com host, got %s", got) } } func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) { - if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" { + if got := BuildConsoleScopeURL(brand.Feishu, "", "docs:doc"); got != "" { t.Errorf("empty appID should yield empty url, got %s", got) } - if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" { + if got := BuildConsoleScopeURL(brand.Feishu, "cli_xxx", ""); got != "" { t.Errorf("empty scope should yield empty url, got %s", got) } } diff --git a/internal/registry/scopes.go b/internal/registry/scopes.go index 0a2b47f77e..0d565008b5 100644 --- a/internal/registry/scopes.go +++ b/internal/registry/scopes.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/larksuite/cli/internal/apicatalog" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/meta" ) @@ -57,7 +57,7 @@ func bestScope(scopes []string, priorities map[string]int) string { // permissive" predicate (meta.Method.SupportsToken) both live in meta, so this // only composes them — schema completion/render and service commands never // re-derive identity semantics. -func FilterForStrictMode(mode core.StrictMode) apicatalog.MethodFilter { +func FilterForStrictMode(mode identity.StrictMode) apicatalog.MethodFilter { if !mode.IsActive() { return nil } diff --git a/internal/core/risk.go b/internal/risk/risk.go similarity index 97% rename from internal/core/risk.go rename to internal/risk/risk.go index 4c9014010b..9dfa1a86b8 100644 --- a/internal/core/risk.go +++ b/internal/risk/risk.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package risk // Risk levels — the three-tier convention used across the CLI. They live here, // at the leaf, so the envelope renderer (internal/schema) and the command diff --git a/internal/riskcontrol/transport.go b/internal/riskcontrol/transport.go index 9beebdb03c..0f3e620b6c 100644 --- a/internal/riskcontrol/transport.go +++ b/internal/riskcontrol/transport.go @@ -8,7 +8,7 @@ import ( "net/url" "strings" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" internaltransport "github.com/larksuite/cli/internal/transport" ) @@ -87,10 +87,10 @@ type origin struct { } var officialFeishuOrigins = [...]origin{ - apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open), - apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open), - apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts), - apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts), + apiOrigin(brandpkg.Feishu, brandpkg.ResolveEndpoints(brandpkg.Feishu).Open), + apiOrigin(brandpkg.Lark, brandpkg.ResolveEndpoints(brandpkg.Lark).Open), + apiOrigin(brandpkg.Feishu, brandpkg.ResolveEndpoints(brandpkg.Feishu).Accounts), + apiOrigin(brandpkg.Lark, brandpkg.ResolveEndpoints(brandpkg.Lark).Accounts), } func (t *Transport) routeAllowsSignals(req *http.Request) bool { @@ -117,7 +117,7 @@ func originOf(value *url.URL) origin { return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port} } -func apiOrigin(brand core.LarkBrand, endpointURL string) origin { +func apiOrigin(brand brandpkg.Brand, endpointURL string) origin { endpoint, err := url.Parse(endpointURL) if err != nil { return origin{} diff --git a/internal/schema/assembler.go b/internal/schema/assembler.go index 6b1c43f0df..42dfa44386 100644 --- a/internal/schema/assembler.go +++ b/internal/schema/assembler.go @@ -10,8 +10,8 @@ import ( "github.com/larksuite/cli/internal/affordance" "github.com/larksuite/cli/internal/apicatalog" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" + "github.com/larksuite/cli/internal/risk" ) // Convert renders a meta.Field as a JSON-Schema Property. meta owns the value @@ -145,7 +145,7 @@ func buildInputSchema(m meta.Method) *InputSchema { addInputObject(is, "data", "", m.Data(), false, "--data") addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", m.Files(), false, "--file") - if m.Risk == core.RiskHighRiskWrite { + if m.Risk == risk.RiskHighRiskWrite { falseVal := false is.Properties.Set("yes", Property{ Type: "boolean", @@ -206,7 +206,7 @@ func buildMeta(m meta.Method) *Meta { if m.Risk != "" { out.Risk = m.Risk } else { - out.Risk = core.RiskRead + out.Risk = risk.RiskRead } if m.DocURL != "" { out.DocURL = m.DocURL diff --git a/internal/schema/lint.go b/internal/schema/lint.go index 7600527c53..da669e39c8 100644 --- a/internal/schema/lint.go +++ b/internal/schema/lint.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/risk" ) var validJSONSchemaTypes = map[string]bool{ @@ -81,7 +81,7 @@ func lintEnvelope(env Envelope) []error { } // ---- L3: cross-field self-consistency ---- - dangerExpected := env.Meta.Risk == core.RiskWrite || env.Meta.Risk == core.RiskHighRiskWrite + dangerExpected := env.Meta.Risk == risk.RiskWrite || env.Meta.Risk == risk.RiskHighRiskWrite if env.Meta.Danger != dangerExpected { errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk)) } @@ -92,7 +92,7 @@ func lintEnvelope(env Envelope) []error { if env.InputSchema != nil && env.InputSchema.Properties != nil { _, hasYes = env.InputSchema.Properties.Map["yes"] } - wantYes := env.Meta.Risk == core.RiskHighRiskWrite + wantYes := env.Meta.Risk == risk.RiskHighRiskWrite if hasYes != wantYes { errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk)) } diff --git a/internal/binding/audit.go b/internal/secaudit/audit.go similarity index 99% rename from internal/binding/audit.go rename to internal/secaudit/audit.go index 57c6a4d413..32ea687eab 100644 --- a/internal/binding/audit.go +++ b/internal/secaudit/audit.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package secaudit import ( "fmt" diff --git a/internal/binding/audit_test.go b/internal/secaudit/audit_test.go similarity index 99% rename from internal/binding/audit_test.go rename to internal/secaudit/audit_test.go index 28ff581808..7329a7e540 100644 --- a/internal/binding/audit_test.go +++ b/internal/secaudit/audit_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package binding +package secaudit import ( "fmt" diff --git a/internal/binding/audit_unix.go b/internal/secaudit/audit_unix.go similarity index 99% rename from internal/binding/audit_unix.go rename to internal/secaudit/audit_unix.go index 5e6d76a4f6..a8de3866c5 100644 --- a/internal/binding/audit_unix.go +++ b/internal/secaudit/audit_unix.go @@ -3,7 +3,7 @@ //go:build !windows -package binding +package secaudit import ( "fmt" diff --git a/internal/binding/audit_windows.go b/internal/secaudit/audit_windows.go similarity index 97% rename from internal/binding/audit_windows.go rename to internal/secaudit/audit_windows.go index b2958a0c01..a4daf86448 100644 --- a/internal/binding/audit_windows.go +++ b/internal/secaudit/audit_windows.go @@ -3,7 +3,7 @@ //go:build windows -package binding +package secaudit import ( "fmt" diff --git a/internal/binding/audit_windows_test.go b/internal/secaudit/audit_windows_test.go similarity index 97% rename from internal/binding/audit_windows_test.go rename to internal/secaudit/audit_windows_test.go index 3993fcdbc3..f53d36beed 100644 --- a/internal/binding/audit_windows_test.go +++ b/internal/secaudit/audit_windows_test.go @@ -3,7 +3,7 @@ //go:build windows -package binding +package secaudit import ( "os" diff --git a/internal/core/secret.go b/internal/secret/secret.go similarity index 99% rename from internal/core/secret.go rename to internal/secret/secret.go index a488e5dcf7..cc52640f19 100644 --- a/internal/core/secret.go +++ b/internal/secret/secret.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package secret import ( "encoding/json" diff --git a/internal/core/secret_resolve.go b/internal/secret/secret_resolve.go similarity index 94% rename from internal/core/secret_resolve.go rename to internal/secret/secret_resolve.go index 072aa2791c..00c77b148b 100644 --- a/internal/core/secret_resolve.go +++ b/internal/secret/secret_resolve.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package secret import ( "fmt" @@ -56,7 +56,7 @@ func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (Se // expected appId. This prevents silent mismatches when config.json is edited by // hand (e.g. appId changed but appSecret.id still points to the old app). // Only applicable when appSecret is a keychain SecretRef; other forms are skipped. -func ValidateSecretKeyMatch(appId string, secret SecretInput) error { +func ValidateSecretKeyMatch(appId string, secret SecretInput, reconfigureHint string) error { if secret.Ref == nil || secret.Ref.Source != "keychain" { return nil } @@ -64,7 +64,7 @@ func ValidateSecretKeyMatch(appId string, secret SecretInput) error { if secret.Ref.ID != expected { return fmt.Errorf( "appSecret keychain key %q does not match appId %q (expected %q); %s", - secret.Ref.ID, appId, expected, reconfigureHint(), + secret.Ref.ID, appId, expected, reconfigureHint, ) } return nil diff --git a/internal/core/secret_resolve_test.go b/internal/secret/secret_resolve_test.go similarity index 75% rename from internal/core/secret_resolve_test.go rename to internal/secret/secret_resolve_test.go index 79ec6b7f32..755937a9d2 100644 --- a/internal/core/secret_resolve_test.go +++ b/internal/secret/secret_resolve_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package secret import ( "strings" @@ -10,14 +10,14 @@ import ( func TestValidateSecretKeyMatch_KeychainMatches(t *testing.T) { secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}} - if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil { + if err := ValidateSecretKeyMatch("cli_abc123", secret, "reconfigure"); err != nil { t.Errorf("expected no error, got: %v", err) } } func TestValidateSecretKeyMatch_KeychainMismatch(t *testing.T) { secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_old_app"}} - err := ValidateSecretKeyMatch("cli_new_app", secret) + err := ValidateSecretKeyMatch("cli_new_app", secret, "please run `lark-cli config init` to reconfigure") if err == nil { t.Fatal("expected error for mismatched appId and keychain key") } @@ -32,27 +32,27 @@ func TestValidateSecretKeyMatch_KeychainMismatch(t *testing.T) { func TestValidateSecretKeyMatch_PlainSecret_Skipped(t *testing.T) { secret := PlainSecret("some-secret") - if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil { + if err := ValidateSecretKeyMatch("cli_abc123", secret, "reconfigure"); err != nil { t.Errorf("plain secret should be skipped, got: %v", err) } } func TestValidateSecretKeyMatch_FileRef_Skipped(t *testing.T) { secret := SecretInput{Ref: &SecretRef{Source: "file", ID: "/tmp/secret.txt"}} - if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil { + if err := ValidateSecretKeyMatch("cli_abc123", secret, "reconfigure"); err != nil { t.Errorf("file ref should be skipped, got: %v", err) } } func TestValidateSecretKeyMatch_ZeroValue_Skipped(t *testing.T) { - if err := ValidateSecretKeyMatch("cli_abc123", SecretInput{}); err != nil { + if err := ValidateSecretKeyMatch("cli_abc123", SecretInput{}, "reconfigure"); err != nil { t.Errorf("zero SecretInput should be skipped, got: %v", err) } } func TestValidateSecretKeyMatch_EmptyAppId_Mismatch(t *testing.T) { secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}} - err := ValidateSecretKeyMatch("", secret) + err := ValidateSecretKeyMatch("", secret, "reconfigure") if err == nil { t.Fatal("expected error when appId is empty but keychain key references a real app") } diff --git a/internal/security/contentsafety/provider.go b/internal/security/contentsafety/provider.go index 0ec3d76995..29de179d20 100644 --- a/internal/security/contentsafety/provider.go +++ b/internal/security/contentsafety/provider.go @@ -10,7 +10,7 @@ import ( "sync" extcs "github.com/larksuite/cli/extension/contentsafety" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/workspace" ) // regexProvider implements extcs.Provider using regex rules from config file. @@ -76,6 +76,6 @@ func (p *regexProvider) loadOrCreate(errOut io.Writer) (*Config, error) { func init() { extcs.Register(®exProvider{ - configDir: core.GetConfigDir(), + configDir: workspace.GetConfigDir(), }) } diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 8ff0e0370c..7b95037e4a 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -16,7 +16,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/vfs" ) @@ -105,7 +105,7 @@ func (r *NpmResult) CombinedOutput() string { // / RestoreAvailableOverride for testing. type Updater struct { // Brand selects the skills index/source endpoints (zero value = feishu). - Brand core.LarkBrand + Brand brand.Brand DetectOverride func() DetectResult NpmInstallOverride func(version string) *NpmResult @@ -140,12 +140,12 @@ func (u *Updater) skillsIndexURL() string { if officialSkillsIndexURL != "" { return officialSkillsIndexURL } - return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json" + return brand.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json" } // skillsSource returns the brand's skills source host for `npx skills add`. func (u *Updater) skillsSource() string { - return core.ResolveEndpoints(u.Brand).Open + return brand.ResolveEndpoints(u.Brand).Open } // DetectInstallMethod determines how the CLI was installed and whether the diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 967f8cbd7e..2f000a99f0 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -17,7 +17,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/vfs" ) @@ -519,12 +519,12 @@ func TestDetectInstallMethod_Caches(t *testing.T) { func TestSkillsBrandHosts(t *testing.T) { cases := []struct { - brand core.LarkBrand + brand brandpkg.Brand wantIndex string wantSource string }{ - {core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"}, - {core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"}, + {brandpkg.Feishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"}, + {brandpkg.Lark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"}, } for _, c := range cases { u := &Updater{Brand: c.brand} diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go index eddab1cf3a..9628ce04bd 100644 --- a/internal/skillscheck/state.go +++ b/internal/skillscheck/state.go @@ -10,9 +10,9 @@ import ( "io/fs" "path/filepath" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) const ( @@ -31,7 +31,7 @@ type SkillsState struct { } func statePath() string { - return filepath.Join(core.GetBaseConfigDir(), stateFile) + return filepath.Join(workspace.GetBaseConfigDir(), stateFile) } func ReadState() (*SkillsState, bool, error) { @@ -58,7 +58,7 @@ func ReadState() (*SkillsState, bool, error) { func WriteState(state SkillsState) error { state.ensureNonNilSlices() - if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil { + if err := vfs.MkdirAll(workspace.GetBaseConfigDir(), 0o700); err != nil { return err } data, err := json.MarshalIndent(state, "", " ") diff --git a/internal/sparkstore/git_credential.go b/internal/sparkstore/git_credential.go new file mode 100644 index 0000000000..5d599411e0 --- /dev/null +++ b/internal/sparkstore/git_credential.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sparkstore + +import ( + "errors" + "net/url" + "os" + + "github.com/larksuite/cli/internal/vfs" +) + +// AppStorage adapts Spark local state to the Git credential store interface. +type AppStorage struct{} + +func (AppStorage) Read(appID, key string) ([]byte, error) { + return Read(appID, key) +} + +func (AppStorage) Write(appID, key string, data []byte) error { + return Write(appID, key, data) +} + +func (AppStorage) Delete(appID, key string) error { + return Delete(appID, key) +} + +func (AppStorage) ListAppIDs() ([]string, error) { + entries, err := vfs.ReadDir(Root()) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return nil, storageError(err, "apps storage: read root: %v", err) + } + appIDs := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + appID, err := url.PathUnescape(e.Name()) + if err != nil { + continue + } + if err := checkSeg(appID, "appID"); err != nil { + continue + } + appIDs = append(appIDs, appID) + } + return appIDs, nil +} diff --git a/shortcuts/apps/storage.go b/internal/sparkstore/storage.go similarity index 76% rename from shortcuts/apps/storage.go rename to internal/sparkstore/storage.go index fea308a20b..73e7a2abd3 100644 --- a/shortcuts/apps/storage.go +++ b/internal/sparkstore/storage.go @@ -1,7 +1,8 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package apps +// Package sparkstore persists local state for Spark applications. +package sparkstore import ( "errors" @@ -10,9 +11,9 @@ import ( "path/filepath" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" //nolint:depguard // existing apps storage persists CLI config-dir state; it is not user file I/O. + "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) // storageRoot is the per-domain local-storage directory name under the config dir. @@ -25,7 +26,7 @@ const storageRoot = "spark" // can traverse out of the storage directory. func checkSeg(name, what string) error { if err := validate.ResourceName(name, what); err != nil { - return appsStorageError(err, "apps storage: %v", err) + return storageError(err, "apps storage: %v", err) } if name == "." { return errs.NewInternalError(errs.SubtypeStorage, "apps storage: %s must not be \".\"", what) @@ -36,7 +37,7 @@ func checkSeg(name, what string) error { // appDir returns the storage directory for one app: ~/.lark-cli/spark// // (workspace-aware). func appDir(appID string) string { - return filepath.Join(core.GetConfigDir(), storageRoot, validate.EncodePathSegment(appID)) + return filepath.Join(Root(), validate.EncodePathSegment(appID)) } // appKeyPath returns the file path for one (appID, key). @@ -44,6 +45,16 @@ func appKeyPath(appID, key string) string { return filepath.Join(appDir(appID), validate.EncodePathSegment(key)) } +// Root returns the Spark application storage root under the CLI config directory. +func Root() string { + return filepath.Join(workspace.GetConfigDir(), storageRoot) +} + +// Path returns the storage path for one application key. +func Path(appID, key string) string { + return appKeyPath(appID, key) +} + // Read returns the bytes stored under (appID, key). A missing file returns // (nil, nil). Content is opaque — callers own the format. Note: an empty stored // value is indistinguishable from a missing key (both yield nil), so this store @@ -60,7 +71,7 @@ func Read(appID, key string) ([]byte, error) { if errors.Is(err, os.ErrNotExist) { return nil, nil } - return nil, appsStorageError(err, "apps storage: read: %v", err) + return nil, storageError(err, "apps storage: read: %v", err) } return data, nil } @@ -79,10 +90,10 @@ func Write(appID, key string, data []byte) error { return err } if err := vfs.MkdirAll(appDir(appID), 0700); err != nil { - return appsStorageError(err, "apps storage: create dir: %v", err) + return storageError(err, "apps storage: create dir: %v", err) } if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil { - return appsStorageError(err, "apps storage: write: %v", err) + return storageError(err, "apps storage: write: %v", err) } return nil } @@ -96,7 +107,7 @@ func Delete(appID, key string) error { return err } if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) { - return appsStorageError(err, "apps storage: delete: %v", err) + return storageError(err, "apps storage: delete: %v", err) } return nil } @@ -113,7 +124,7 @@ func List(appID string) ([]string, error) { if errors.Is(err, os.ErrNotExist) { return []string{}, nil } - return nil, appsStorageError(err, "apps storage: read dir: %v", err) + return nil, storageError(err, "apps storage: read dir: %v", err) } keys := make([]string, 0, len(entries)) for _, e := range entries { @@ -131,3 +142,8 @@ func List(appID string) ([]string, error) { } return keys, nil } + +// storageError classifies local application state failures as internal/storage. +func storageError(err error, format string, args ...any) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err) +} diff --git a/shortcuts/apps/storage_test.go b/internal/sparkstore/storage_test.go similarity index 99% rename from shortcuts/apps/storage_test.go rename to internal/sparkstore/storage_test.go index 1546e5ab84..3fa47bf532 100644 --- a/shortcuts/apps/storage_test.go +++ b/internal/sparkstore/storage_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package apps +package sparkstore import ( "os" diff --git a/internal/transport/config.go b/internal/transport/config.go index 009e5f2c89..fcaeaf0116 100644 --- a/internal/transport/config.go +++ b/internal/transport/config.go @@ -23,13 +23,13 @@ import ( "strings" "sync" - "github.com/larksuite/cli/internal/binding" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/secaudit" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) -// ConfigFileName is the fixed config file name under core.GetConfigDir(). +// ConfigFileName is the fixed config file name under workspace.GetConfigDir(). const ( ConfigFileName = "proxy_config.json" ) @@ -48,7 +48,7 @@ type Config struct { // Path returns the absolute path to the proxy plugin config file. func Path() string { - return filepath.Join(core.GetConfigDir(), ConfigFileName) + return filepath.Join(workspace.GetConfigDir(), ConfigFileName) } // loadOnce guards one-time proxy config loading for process-wide transport reuse. @@ -94,7 +94,7 @@ func Load() (*Config, error) { // egresses and which extra CA is trusted, so a file another local user or // process can tamper with (symlink, foreign owner, group/world-writable) // could redirect credential traffic. Audit it the same way the CA file is. - safePath, err := binding.AssertSecurePath(binding.AuditParams{ + safePath, err := secaudit.AssertSecurePath(secaudit.AuditParams{ TargetPath: p, Label: ConfigFileName, AllowReadableByOthers: true, // config is not a secret; only writability/owner/symlink matter diff --git a/internal/transport/tls_ca.go b/internal/transport/tls_ca.go index 079cb6f1df..377cb07aff 100644 --- a/internal/transport/tls_ca.go +++ b/internal/transport/tls_ca.go @@ -11,8 +11,8 @@ import ( "path/filepath" "strings" - "github.com/larksuite/cli/internal/binding" "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/secaudit" "github.com/larksuite/cli/internal/vfs" ) @@ -26,7 +26,7 @@ func applyExtraRootCA(t *http.Transport, caPath string) error { if !filepath.IsAbs(caPath) { return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliCAPath, caPath) } - safeCAPath, err := binding.AssertSecurePath(binding.AuditParams{ + safeCAPath, err := secaudit.AssertSecurePath(secaudit.AuditParams{ TargetPath: caPath, Label: envvars.CliCAPath, AllowReadableByOthers: true, diff --git a/internal/update/update.go b/internal/update/update.go index 7bdc61d9c2..169430f5ba 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -16,10 +16,10 @@ import ( "sync/atomic" "time" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/workspace" ) const ( @@ -163,7 +163,7 @@ func IsCIEnv() bool { // --- state file I/O --- func statePath() string { - return filepath.Join(core.GetConfigDir(), stateFile) + return filepath.Join(workspace.GetConfigDir(), stateFile) } func loadState() (*updateState, error) { @@ -179,7 +179,7 @@ func loadState() (*updateState, error) { } func saveState(s *updateState) error { - dir := core.GetConfigDir() + dir := workspace.GetConfigDir() if err := vfs.MkdirAll(dir, 0700); err != nil { return err } diff --git a/internal/core/workspace.go b/internal/workspace/workspace.go similarity index 92% rename from internal/core/workspace.go rename to internal/workspace/workspace.go index 181a6bce1f..f0b0b8f512 100644 --- a/internal/core/workspace.go +++ b/internal/workspace/workspace.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package workspace import ( "os" @@ -159,3 +159,16 @@ func GetRuntimeDir() string { } return filepath.Join(base, string(ws)) } + +// GetConfigDir returns the config directory path for the current workspace. +// When workspace is local (default), this returns the same path as before +// (LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli) — fully backward-compatible. +// When workspace is openclaw/hermes, returns base/openclaw or base/hermes. +func GetConfigDir() string { + return GetRuntimeDir() +} + +// GetConfigPath returns the config file path for the current workspace. +func GetConfigPath() string { + return filepath.Join(GetConfigDir(), "config.json") +} diff --git a/internal/core/workspace_test.go b/internal/workspace/workspace_test.go similarity index 99% rename from internal/core/workspace_test.go rename to internal/workspace/workspace_test.go index 846fbb750d..c1555ecb43 100644 --- a/internal/core/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package core +package workspace import ( "path/filepath" diff --git a/lint/README.md b/lint/README.md index 79d246961f..e4f3be52ce 100644 --- a/lint/README.md +++ b/lint/README.md @@ -49,7 +49,7 @@ files it rejects: flagged. Host literals are permitted only inside the resolver's `ResolveEndpoints` -function body (`internal/core/types.go`) and in this rule's own host list +function body (`brand/brand.go`) and in this rule's own host list (`lint/domaincontract/scan.go`); a helper elsewhere in the resolver file returning a hardcoded host is still rejected. Comments and `_test.go` files are not scanned. Literals are unquoted before matching (escape sequences diff --git a/lint/domaincontract/scan.go b/lint/domaincontract/scan.go index ba2776de17..8b96323588 100644 --- a/lint/domaincontract/scan.go +++ b/lint/domaincontract/scan.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Package domaincontract guards the Go CLI against direct reuse of the current -// resolver-owned host FQDNs outside core.ResolveEndpoints. +// resolver-owned host FQDNs outside brand.ResolveEndpoints. package domaincontract import ( @@ -65,7 +65,7 @@ var allowlist = map[string]bool{ // resolverPath is the resolver source; host literals are permitted only // inside its ResolveEndpoints function body. -var resolverPath = filepath.FromSlash("internal/core/types.go") +var resolverPath = filepath.FromSlash("brand/brand.go") func skipDir(name string) bool { switch name { @@ -150,8 +150,8 @@ func ScanRepo(root string) ([]lintapi.Violation, error) { Action: lintapi.ActionReject, File: display, Line: pos.Line, - Message: "SDK base-URL global " + pkg.Name + "." + node.Sel.Name + " bypasses the resolver — use core.ResolveEndpoints", - Suggestion: "derive the host from core.ResolveEndpoints(brand) instead of the SDK global", + Message: "SDK base-URL global " + pkg.Name + "." + node.Sel.Name + " bypasses the resolver — use brand.ResolveEndpoints", + Suggestion: "derive the host from brand.ResolveEndpoints(brand) instead of the SDK global", }) } case *ast.BasicLit: @@ -175,8 +175,8 @@ func ScanRepo(root string) ([]lintapi.Violation, error) { Action: lintapi.ActionReject, File: display, Line: pos.Line, - Message: "hardcoded resolver host " + host + " — outbound domains must come from core.ResolveEndpoints", - Suggestion: "use core.ResolveEndpoints(brand) instead of a literal host", + Message: "hardcoded resolver host " + host + " — outbound domains must come from brand.ResolveEndpoints", + Suggestion: "use brand.ResolveEndpoints(brand) instead of a literal host", }) return true } diff --git a/lint/domaincontract/scan_test.go b/lint/domaincontract/scan_test.go index a55ade3bb3..98a4528e6a 100644 --- a/lint/domaincontract/scan_test.go +++ b/lint/domaincontract/scan_test.go @@ -42,7 +42,7 @@ func writeFile(t *testing.T, root, rel, content string) { func TestScanRepo(t *testing.T) { root := t.TempDir() // Negative: the resolver may hold the literals inside ResolveEndpoints. - writeFile(t, root, "internal/core/types.go", "package core\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n") + writeFile(t, root, "brand/brand.go", "package brand\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n") // Negative: non-resolver hosts + a comment reference must not trip the guard. writeFile(t, root, "shortcuts/x/display.go", "package x\n\n// see https://open.feishu.cn/document/foo\nvar h = \"https://www.feishu.cn\"\nvar e = \"https://example.feishu.cn\"\nvar r = \"https://registry.npmjs.org/pkg\"\n") // Negative: _test.go files may assert literals. @@ -108,7 +108,7 @@ func TestScanRepoSDKConstants(t *testing.T) { // forbiddenHosts must equal the https hosts in the resolver source, both ways; // a resolver domain change without a guard update fails here. func TestForbiddenHostsMatchResolver(t *testing.T) { - src := filepath.Join("..", "..", "internal", "core", "types.go") + src := filepath.Join("..", "..", "brand", "brand.go") fset := token.NewFileSet() file, err := parser.ParseFile(fset, src, nil, 0) if err != nil { @@ -197,8 +197,8 @@ func TestScanRepoDotImportAndCase(t *testing.T) { // ResolveEndpoints body is rejected. func TestScanRepoResolverFunctionScope(t *testing.T) { root := t.TempDir() - writeFile(t, root, "internal/core/types.go", - "package core\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n\nfunc bypass() string { return \"https://open.feishu.cn\" }\n\ntype localResolver struct{}\nfunc (localResolver) ResolveEndpoints() string { return \"https://open.feishu.cn\" }\n") + writeFile(t, root, "brand/brand.go", + "package brand\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n\nfunc bypass() string { return \"https://open.feishu.cn\" }\n\ntype localResolver struct{}\nfunc (localResolver) ResolveEndpoints() string { return \"https://open.feishu.cn\" }\n") vs, err := ScanRepo(root) if err != nil { @@ -208,8 +208,8 @@ func TestScanRepoResolverFunctionScope(t *testing.T) { t.Fatalf("got %d violations, want 2 (helper and receiver method): %+v", len(vs), vs) } for _, v := range vs { - if filepath.Base(v.File) != "types.go" { - t.Errorf("violation in %q, want types.go", v.File) + if filepath.Base(v.File) != "brand.go" { + t.Errorf("violation in %q, want brand.go", v.File) } } } diff --git a/main_noauthsidecar.go b/main_noauthsidecar.go index 514afda63d..28baa22972 100644 --- a/main_noauthsidecar.go +++ b/main_noauthsidecar.go @@ -21,7 +21,7 @@ import ( "io" "os" - "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/envnames" ) func init() { @@ -35,7 +35,7 @@ func init() { // isolation that this binary cannot provide. Factored out from init() so // tests can exercise the decision without actually calling os.Exit. func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int { - v := getenv(envvars.CliAuthProxy) + v := getenv(envnames.CliAuthProxy) if v == "" { return 0 } @@ -49,6 +49,6 @@ func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int { "To fix, either:\n"+ " - rebuild the CLI with: go build -tags authsidecar\n"+ " - or unset %s if sidecar isolation is not required\n", - envvars.CliAuthProxy, envvars.CliAuthProxy) + envnames.CliAuthProxy, envnames.CliAuthProxy) return 2 } diff --git a/main_noauthsidecar_test.go b/main_noauthsidecar_test.go index 5879015c25..7747aa9a49 100644 --- a/main_noauthsidecar_test.go +++ b/main_noauthsidecar_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/envnames" ) func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) { @@ -30,7 +30,7 @@ func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) { func TestCheckNoAuthsidecarBuild_Set(t *testing.T) { var stderr bytes.Buffer env := func(k string) string { - if k == envvars.CliAuthProxy { + if k == envnames.CliAuthProxy { return "http://127.0.0.1:16384" } return "" @@ -41,7 +41,7 @@ func TestCheckNoAuthsidecarBuild_Set(t *testing.T) { } msg := stderr.String() for _, want := range []string{ - envvars.CliAuthProxy, + envnames.CliAuthProxy, "authsidecar", // build-tag name must appear so operators can act on it "rebuild", } { diff --git a/scripts/check-layering-ratchet.sh b/scripts/check-layering-ratchet.sh new file mode 100644 index 0000000000..2d67b8697c --- /dev/null +++ b/scripts/check-layering-ratchet.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT + +layering_ratchet_extract_keys() { + local source_file="$1" + local output_file="$2" + awk -F '\t' ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + { + content = trim($0) + if (content == "" || substr(content, 1, 1) == "#") { + next + } + if (NF != 5) { + printf "Malformed layering ratchet row at %s:%d: expected five tab-separated fields.\n", FILENAME, FNR > "/dev/stderr" + exit 2 + } + from = $1 + denied = $2 + owner = $3 + reason = $4 + added_at = $5 + sub(/\r+$/, "", added_at) + if (from != trim(from) || denied != trim(denied) || owner != trim(owner) || reason != trim(reason) || added_at != trim(added_at)) { + printf "Malformed layering ratchet row at %s:%d: fields must not have surrounding whitespace.\n", FILENAME, FNR > "/dev/stderr" + exit 2 + } + if (from == "" || denied == "" || owner == "" || reason == "" || added_at !~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/) { + printf "Malformed layering ratchet row at %s:%d: fields must be non-empty and added_at must use YYYY-MM-DD.\n", FILENAME, FNR > "/dev/stderr" + exit 2 + } + print from "\t" denied + } + ' "$source_file" | LC_ALL=C sort >"$output_file" || return + + local duplicates + duplicates="$(uniq -d "$output_file")" || return + if [[ -n "$duplicates" ]]; then + echo "Layering ratchet contains duplicate (from, denied) keys: $source_file" >&2 + printf '%s\n' "$duplicates" >&2 + return 1 + fi +} + +layering_ratchet_hash_keys() { + local source_file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$source_file" | awk '{ print $1 }' || return + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$source_file" | awk '{ print $1 }' || return + return + fi + echo "Layering ratchet requires sha256sum or shasum." >&2 + return 1 +} + +layering_ratchet_validate_bootstrap_snapshot() { + local current_keys="$1" + local expected_count="$2" + local expected_hash="$3" + local current_count + local current_hash + current_count="$(wc -l <"$current_keys" | tr -d '[:space:]')" || return + current_hash="$(layering_ratchet_hash_keys "$current_keys")" || return + if [[ "$current_count" != "$expected_count" || "$current_hash" != "$expected_hash" ]]; then + echo "::error::Layering ratchet bootstrap differs from the approved $expected_count-edge snapshot." >&2 + return 1 + fi +} + +layering_ratchet_main() ( + set -euo pipefail + + local ratchet_file="internal/qualitygate/deptest/layering-edges.txt" + if root="$(git rev-parse --show-toplevel 2>/dev/null)"; then + cd "$root" || return + else + echo "Layering ratchet must run inside a Git worktree." >&2 + return 1 + fi + + local base_revision="${1:-${QUALITY_GATE_CHANGED_FROM:-}}" + # The registry first reaches the target branch with no approved exceptions. + # Only consulted while the target branch has no registry file, so bootstrap + # requires the extracted key set to stay empty. Hardcoded on purpose — a + # configurable baseline could be raised in CI without review. + local approved_initial_count="${2:-0}" + local approved_initial_hash="${3:-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855}" + if [[ -z "$base_revision" ]]; then + echo "Layering ratchet requires a base revision." >&2 + return 1 + fi + if ! git cat-file -e "$base_revision^{commit}" 2>/dev/null; then + echo "Layering ratchet base revision does not exist: $base_revision" >&2 + return 1 + fi + if [[ ! -f "$ratchet_file" ]]; then + echo "Layering ratchet file is missing: $ratchet_file" >&2 + return 1 + fi + + local tmp_dir + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/layering-ratchet.XXXXXX")" || return + local base_file="$tmp_dir/base.txt" + local base_keys="$tmp_dir/base.keys" + local current_keys="$tmp_dir/current.keys" + local additions="$tmp_dir/additions.keys" + + layering_ratchet_cleanup_current_run() { + rm -f "$base_file" "$base_keys" "$current_keys" "$additions" + rmdir "$tmp_dir" + } + trap layering_ratchet_cleanup_current_run EXIT + + layering_ratchet_extract_keys "$ratchet_file" "$current_keys" || return + + if ! git cat-file -e "$base_revision:$ratchet_file" 2>/dev/null; then + layering_ratchet_validate_bootstrap_snapshot "$current_keys" "$approved_initial_count" "$approved_initial_hash" || return + return + fi + + git show "$base_revision:$ratchet_file" >"$base_file" || return + layering_ratchet_extract_keys "$base_file" "$base_keys" || return + LC_ALL=C comm -13 "$base_keys" "$current_keys" >"$additions" || return + + if [[ -s "$additions" ]]; then + echo "::error::Layering ratchet contains new (from, denied) keys. Fix the dependency instead of adding rows." >&2 + while IFS=$'\t' read -r from denied; do + printf 'from=%s denied=%s\n' "$from" "$denied" >&2 + done <"$additions" + return 1 + fi +) + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + layering_ratchet_main "${1:-${QUALITY_GATE_CHANGED_FROM:-}}" +fi diff --git a/scripts/check-layering-ratchet.test.sh b/scripts/check-layering-ratchet.test.sh new file mode 100644 index 0000000000..08af81b3ac --- /dev/null +++ b/scripts/check-layering-ratchet.test.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +script="$repo_root/scripts/check-layering-ratchet.sh" +ratchet_file="internal/qualitygate/deptest/layering-edges.txt" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/check-layering-ratchet-test.XXXXXX")" +source "$script" + +cleanup_tmp() { + rm -rf "$tmp" +} +trap cleanup_tmp EXIT + +row() { + printf '%s\t%s\towner\treason\t2026-07-24\n' "$1" "$2" +} + +git_init() { + local dir="$1" + git init -q -b main "$dir" + git -C "$dir" config user.name test + git -C "$dir" config user.email test@example.com + mkdir -p "$dir/$(dirname "$ratchet_file")" +} + +write_rows() { + local dir="$1" + shift + { + printf '# from\tdenied\towner\treason\tadded_at\n' + while (( $# > 0 )); do + row "$1" "$2" + shift 2 + done + } >"$dir/$ratchet_file" +} + +commit_ratchet() { + local dir="$1" + git -C "$dir" add "$ratchet_file" + git -C "$dir" commit -q -m "ratchet" +} + +expect_pass() { + local dir="$1" + local base="$2" + if ! (cd "$dir" && bash "$script" "$base"); then + echo "Expected layering ratchet check to pass in $dir." >&2 + return 1 + fi +} + +expect_fail() { + local dir="$1" + local base="$2" + local expected="$3" + local output + if output="$(cd "$dir" && bash "$script" "$base" 2>&1)"; then + echo "Expected layering ratchet check to fail in $dir." >&2 + return 1 + fi + if ! grep -Fq "$expected" <<<"$output"; then + printf 'Layering ratchet failure did not include %q:\n%s\n' "$expected" "$output" >&2 + return 1 + fi +} + +hash_file() { + local source_file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$source_file" | awk '{ print $1 }' + else + shasum -a 256 "$source_file" | awk '{ print $1 }' + fi +} + +bootstrap_keys() { + local source_file="$1" + awk -F '\t' 'NF == 5 && $1 !~ /^[[:space:]]*#/ { print $1 "\t" $2 }' "$source_file" | LC_ALL=C sort +} + +expect_bootstrap_pass() { + local dir="$1" + local base="$2" + local count="$3" + local hash="$4" + if ! (cd "$dir" && layering_ratchet_main "$base" "$count" "$hash"); then + echo "Expected layering ratchet bootstrap check to pass in $dir." >&2 + return 1 + fi +} + +expect_bootstrap_fail() { + local dir="$1" + local base="$2" + local count="$3" + local hash="$4" + local output + if output="$(cd "$dir" && layering_ratchet_main "$base" "$count" "$hash" 2>&1)"; then + echo "Expected layering ratchet bootstrap check to fail in $dir." >&2 + return 1 + fi + if ! grep -Fq "bootstrap differs from the approved" <<<"$output"; then + printf 'Unexpected layering ratchet bootstrap failure:\n%s\n' "$output" >&2 + return 1 + fi +} + +expect_sourced_main_fail() { + local dir="$1" + local base="$2" + local expected="$3" + local output + if output="$(cd "$dir" && layering_ratchet_main "$base" 2>&1)"; then + echo "Expected sourced layering ratchet main to fail in $dir." >&2 + return 1 + fi + if ! grep -Fq "$expected" <<<"$output"; then + printf 'Sourced layering ratchet failure did not include %q:\n%s\n' "$expected" "$output" >&2 + return 1 + fi +} + +test_unchanged_and_metadata_changes_pass() { + local dir="$tmp/unchanged" + git_init "$dir" + write_rows "$dir" from/a denied/a from/b denied/b + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + expect_pass "$dir" "$base" + sed -i.bak 's/\towner\treason\t/\tnew-owner\tnew-reason\t/' "$dir/$ratchet_file" + rm -f "$dir/$ratchet_file.bak" + expect_pass "$dir" "$base" +} + +test_deletion_passes() { + local dir="$tmp/deletion" + git_init "$dir" + write_rows "$dir" from/a denied/a from/b denied/b + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + write_rows "$dir" from/a denied/a + expect_pass "$dir" "$base" +} + +test_addition_fails() { + local dir="$tmp/addition" + git_init "$dir" + write_rows "$dir" from/a denied/a + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + write_rows "$dir" from/a denied/a from/b denied/b + expect_fail "$dir" "$base" "from=from/b denied=denied/b" +} + +test_equal_count_replacement_fails() { + local dir="$tmp/replacement" + git_init "$dir" + write_rows "$dir" from/a denied/a from/b denied/b + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + write_rows "$dir" from/a denied/a from/c denied/c + expect_fail "$dir" "$base" "from=from/c denied=denied/c" +} + +test_malformed_and_missing_current_file_fail() { + local dir="$tmp/malformed" + git_init "$dir" + write_rows "$dir" from/a denied/a + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + printf 'from/a\tdenied/a\towner\treason\n' >"$dir/$ratchet_file" + expect_fail "$dir" "$base" "expected five tab-separated fields" + expect_sourced_main_fail "$dir" "$base" "expected five tab-separated fields" + rm -f "$dir/$ratchet_file" + expect_fail "$dir" "$base" "Layering ratchet file is missing" +} + +test_surrounding_whitespace_fails() { + local dir="$tmp/surrounding-whitespace" + git_init "$dir" + write_rows "$dir" from/a denied/a + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + printf '# from\tdenied\towner\treason\tadded_at\nfrom/a\t denied/a \towner\treason\t2026-07-24\n' >"$dir/$ratchet_file" + expect_fail "$dir" "$base" "fields must not have surrounding whitespace" + expect_sourced_main_fail "$dir" "$base" "fields must not have surrounding whitespace" +} + +test_crlf_rows_pass() { + local dir="$tmp/crlf" + git_init "$dir" + write_rows "$dir" from/a denied/a + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + printf '# from\tdenied\towner\treason\tadded_at\r\nfrom/a\tdenied/a\towner\treason\t2026-07-24\r\n' >"$dir/$ratchet_file" + expect_pass "$dir" "$base" +} + +test_duplicate_key_fails() { + local dir="$tmp/duplicate" + git_init "$dir" + write_rows "$dir" from/a denied/a + commit_ratchet "$dir" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + write_rows "$dir" from/a denied/a from/a denied/a + expect_fail "$dir" "$base" "duplicate (from, denied) keys" +} + +test_bootstrap_requires_the_approved_snapshot() { + local dir="$tmp/bootstrap" + git_init "$dir" + printf 'base\n' >"$dir/base.txt" + git -C "$dir" add base.txt + git -C "$dir" commit -q -m "base" + local base + base="$(git -C "$dir" rev-parse HEAD)" + + local args=() + local index + for index in $(seq 1 39); do + args+=("from/$index" "denied/$index") + done + write_rows "$dir" "${args[@]}" + local keys_file="$dir/initial.keys" + bootstrap_keys "$dir/$ratchet_file" >"$keys_file" + local expected_hash + expected_hash="$(hash_file "$keys_file")" + expect_bootstrap_pass "$dir" "$base" 39 "$expected_hash" + + args[76]="from/replacement" + write_rows "$dir" "${args[@]}" + expect_bootstrap_fail "$dir" "$base" 39 "$expected_hash" + + args[76]="from/39" + args+=("from/40" "denied/40") + write_rows "$dir" "${args[@]}" + expect_bootstrap_fail "$dir" "$base" 39 "$expected_hash" +} + +test_invalid_base_fails() { + local dir="$tmp/invalid-base" + git_init "$dir" + write_rows "$dir" from/a denied/a + commit_ratchet "$dir" + expect_fail "$dir" missing-revision "base revision does not exist" +} + +test_unchanged_and_metadata_changes_pass +test_deletion_passes +test_addition_fails +test_equal_count_replacement_fails +test_malformed_and_missing_current_file_fail +test_surrounding_whitespace_fails +test_crlf_rows_pass +test_duplicate_key_fails +test_bootstrap_requires_the_approved_snapshot +test_invalid_base_fails diff --git a/scripts/ci-workflow.test.sh b/scripts/ci-workflow.test.sh index 6acdb0adc1..b6856f887e 100644 --- a/scripts/ci-workflow.test.sh +++ b/scripts/ci-workflow.test.sh @@ -170,6 +170,21 @@ if grep -Fq '${{ secrets.' <<<"$script_test_section"; then exit 1 fi +if ! grep -Fq 'bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"' <<<"$lint_section"; then + echo "lint should enforce the layering ratchet with the tested key-set checker" + exit 1 +fi + +if grep -Fq "grep -vcE" <<<"$lint_section"; then + echo "lint should not enforce the layering ratchet by row count alone" + exit 1 +fi + +if grep -Fq "LAYERING_RATCHET_INITIAL_" "$workflow"; then + echo "CI must use the immutable checked-in layering bootstrap snapshot" + exit 1 +fi + if grep -Fq "metadata-gate:" "$workflow"; then echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact" exit 1 diff --git a/shortcuts/application/slash_command_list_test.go b/shortcuts/application/slash_command_list_test.go index cd44e0cb97..551957e025 100644 --- a/shortcuts/application/slash_command_list_test.go +++ b/shortcuts/application/slash_command_list_test.go @@ -9,15 +9,16 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) -func appTestConfig() *core.CliConfig { - return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu} +func appTestConfig() *configpkg.CliConfig { + return &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu} } // mountAndRun mounts the shortcut under a parent cobra command and runs it. diff --git a/shortcuts/apps/apps_create_test.go b/shortcuts/apps/apps_create_test.go index 93c02fba5a..43055e0081 100644 --- a/shortcuts/apps/apps_create_test.go +++ b/shortcuts/apps/apps_create_test.go @@ -12,9 +12,10 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) @@ -25,10 +26,10 @@ func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *http t.Helper() t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, stdout, _, reg := cmdutil.TestFactory(t, cfg) diff --git a/shortcuts/apps/apps_errors.go b/shortcuts/apps/apps_errors.go index a00998e1fc..41cdbfdb82 100644 --- a/shortcuts/apps/apps_errors.go +++ b/shortcuts/apps/apps_errors.go @@ -30,12 +30,6 @@ func appsFailedPreconditionError(format string, args ...any) *errs.ValidationErr return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...) } -// appsStorageError classifies a local credential/state storage failure -// (read, write, delete, list) as internal/storage. -func appsStorageError(err error, format string, args ...any) *errs.InternalError { - return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err) -} - // appsExternalToolError classifies a runtime failure of an external tool the // CLI shells out to (git, npx) as internal/external_tool. The tool output is // carried in the message; recovery guidance goes in the hint. diff --git a/shortcuts/apps/apps_html_publish_test.go b/shortcuts/apps/apps_html_publish_test.go index a9526442ec..e2a918481f 100644 --- a/shortcuts/apps/apps_html_publish_test.go +++ b/shortcuts/apps/apps_html_publish_test.go @@ -14,11 +14,13 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" ) @@ -525,10 +527,10 @@ func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry t.Helper() t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, _, _, reg := cmdutil.TestFactory(t, cfg) @@ -536,7 +538,7 @@ func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry rt := common.TestNewRuntimeContextForAPI( context.Background(), &cobra.Command{Use: "+tos-test"}, - cfg, factory, core.AsUser, + cfg, factory, identity.AsUser, ) return rt, reg } diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go index 04f0dbb331..83c8df726f 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -17,9 +17,10 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/testutil/gitcmd" "github.com/larksuite/cli/shortcuts/common" @@ -755,10 +756,10 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf t.Helper() t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg) diff --git a/shortcuts/apps/apps_meta_test.go b/shortcuts/apps/apps_meta_test.go index 3d410c8dda..0d89ddac40 100644 --- a/shortcuts/apps/apps_meta_test.go +++ b/shortcuts/apps/apps_meta_test.go @@ -9,20 +9,22 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" ) func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { t.Helper() - cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_meta_test"} + cfg := &configpkg.CliConfig{Brand: brand.Feishu, AppID: "cli_meta_test"} f, _, _, reg := cmdutil.TestFactory(t, cfg) rt := common.TestNewRuntimeContextForAPI( context.Background(), &cobra.Command{Use: "+meta-test"}, - cfg, f, core.AsUser, + cfg, f, identity.AsUser, ) return rt, reg } diff --git a/shortcuts/apps/apps_openapi_key_list_test.go b/shortcuts/apps/apps_openapi_key_list_test.go index 6e3548b142..c897cf9d2c 100644 --- a/shortcuts/apps/apps_openapi_key_list_test.go +++ b/shortcuts/apps/apps_openapi_key_list_test.go @@ -10,9 +10,11 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -21,10 +23,10 @@ import ( // bool flag 传 "true"/"false"。被本组所有命令测试复用。 func newOpenAPIKeyRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { t.Helper() - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) @@ -45,7 +47,7 @@ func newOpenAPIKeyRCtx(t *testing.T, flagDefs map[string]string, flags map[strin for name, val := range flags { _ = cmd.Flags().Set(name, val) } - rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, identity.AsUser) return rctx, stdoutBuf, reg } diff --git a/shortcuts/apps/apps_release_create_test.go b/shortcuts/apps/apps_release_create_test.go index 9e62f5007e..da0f5764e8 100644 --- a/shortcuts/apps/apps_release_create_test.go +++ b/shortcuts/apps/apps_release_create_test.go @@ -10,9 +10,11 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -47,10 +49,10 @@ func TestAppsReleaseCreateMeta(t *testing.T) { // via the returned setter helper. func newReleaseCreateRuntimeContext(t *testing.T, appID, branch string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { t.Helper() - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) @@ -64,7 +66,7 @@ func newReleaseCreateRuntimeContext(t *testing.T, appID, branch string) (*common _ = cmd.Flags().Set("branch", branch) } - rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, identity.AsUser) return rctx, stdoutBuf, reg } diff --git a/shortcuts/apps/apps_release_get_test.go b/shortcuts/apps/apps_release_get_test.go index 9cd46cb38f..f5192f1607 100644 --- a/shortcuts/apps/apps_release_get_test.go +++ b/shortcuts/apps/apps_release_get_test.go @@ -10,9 +10,11 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -37,10 +39,10 @@ func TestAppsReleaseGetMeta(t *testing.T) { // newStatusRuntimeContext builds a RuntimeContext for AppsReleaseGet.Execute tests. func newStatusRuntimeContext(t *testing.T, appID, releaseID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { t.Helper() - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) @@ -52,7 +54,7 @@ func newStatusRuntimeContext(t *testing.T, appID, releaseID string) (*common.Run _ = cmd.Flags().Set("app-id", appID) _ = cmd.Flags().Set("release-id", releaseID) - rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, identity.AsUser) return rctx, stdoutBuf, reg } diff --git a/shortcuts/apps/apps_release_list_test.go b/shortcuts/apps/apps_release_list_test.go index d8c7c56f7f..6345fd71ce 100644 --- a/shortcuts/apps/apps_release_list_test.go +++ b/shortcuts/apps/apps_release_list_test.go @@ -10,9 +10,11 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -47,10 +49,10 @@ func TestBuildReleaseListQuery(t *testing.T) { // newReleaseListRuntimeContext builds a RuntimeContext for AppsReleaseList.Execute tests. func newReleaseListRuntimeContext(t *testing.T, appID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { t.Helper() - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) @@ -63,7 +65,7 @@ func newReleaseListRuntimeContext(t *testing.T, appID string) (*common.RuntimeCo cmd.Flags().String("page-token", "", "") _ = cmd.Flags().Set("app-id", appID) - rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, identity.AsUser) return rctx, stdoutBuf, reg } diff --git a/shortcuts/apps/apps_role_common_test.go b/shortcuts/apps/apps_role_common_test.go index 247450faad..a82a133a60 100644 --- a/shortcuts/apps/apps_role_common_test.go +++ b/shortcuts/apps/apps_role_common_test.go @@ -10,21 +10,23 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { t.Helper() - cfg := &core.CliConfig{ + cfg := &configpkg.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: "ou_test", } factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) @@ -47,7 +49,7 @@ func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]stri t.Fatalf("set flag %q = %q: %v", name, val, err) } } - rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, identity.AsUser) return rctx, stdoutBuf, reg } diff --git a/shortcuts/apps/git_credential.go b/shortcuts/apps/git_credential.go index 7a1804beba..3b6d2160a5 100644 --- a/shortcuts/apps/git_credential.go +++ b/shortcuts/apps/git_credential.go @@ -10,8 +10,6 @@ import ( "io" "net/http" "net/url" - "path/filepath" - "sort" "strconv" "strings" "text/tabwriter" @@ -22,11 +20,11 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/errclass" - "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/identity" + "github.com/larksuite/cli/internal/sparkstore" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/apps/gitcred" "github.com/larksuite/cli/shortcuts/common" @@ -70,7 +68,7 @@ var AppsGitCredentialInit = common.Shortcut{ Set("mode", "api-plus-local-setup"). Set("action", "initialize_local_git_credential"). Set("app_id", appID). - Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). + Set("metadata_file", sparkstore.Path(appID, gitcred.MetadataFilename)). Set("local_effects", []string{ "save the issued PAT in the local system credential store", "write app-scoped git credential metadata", @@ -81,7 +79,7 @@ var AppsGitCredentialInit = common.Shortcut{ }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) - manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx}) + manager := gitcred.NewAppManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx}) result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID) if err != nil { return gitCredentialLocalError("Initialize local app Git credential", err) @@ -155,7 +153,7 @@ var AppsGitCredentialRemove = common.Shortcut{ Set("mode", "local-cleanup-only"). Set("action", "remove_local_git_credential"). Set("app_id", appID). - Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). + Set("metadata_file", sparkstore.Path(appID, gitcred.MetadataFilename)). Set("effects", []string{ "read app-scoped git credential metadata", "remove the saved PAT from the local system credential store", @@ -165,7 +163,7 @@ var AppsGitCredentialRemove = common.Shortcut{ }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) - manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil) + manager := gitcred.NewAppManager(appID, rctx.Factory.Keychain, nil) result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID) if err != nil { return gitCredentialLocalError("Remove local app Git credential", err) @@ -215,14 +213,14 @@ var AppsGitCredentialList = common.Shortcut{ Desc("Preview local Git credential listing (no API call, read-only local state)."). Set("mode", "local-read-only"). Set("action", "list_local_git_credentials"). - Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)). + Set("storage_root", sparkstore.Root()). Set("reads", []string{ "scan app-scoped git credential metadata under the CLI config directory", "derive per-app repository URLs and local credential status from local metadata", }) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now) + records, err := gitcred.ListCredentialRecords(rctx.Factory.Keychain, time.Now) if err != nil { return gitCredentialLocalError("List local app Git credentials", err) } @@ -316,11 +314,11 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred. if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil { opts = append(opts, optFn) } - resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...) + resp, err := ac.DoSDKRequest(ctx, req, identity.AsUser, opts...) data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{ Brand: string(cfg.Brand), AppID: cfg.AppID, - Identity: string(core.AsUser), + Identity: string(identity.AsUser), }) if err != nil { return nil, err @@ -343,7 +341,7 @@ func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, acti fmt.Fprintln(f.IOStreams.ErrOut, "Git credential unavailable: missing app_id; rerun lark-cli apps +git-credential-init --app-id ") return nil } - manager := newGitCredentialManager(appID, f.Keychain, factoryIssuer{f: f}) + manager := gitcred.NewAppManager(appID, f.Keychain, factoryIssuer{f: f}) switch action { case "get": input, err := gitcred.ParseCredentialInput(f.IOStreams.In) @@ -367,36 +365,6 @@ func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, acti } } -func newGitCredentialManager(appID string, kc keychain.KeychainAccess, issuer gitcred.Issuer) *gitcred.Manager { - storage := gitCredentialAppStorage{} - return gitcred.NewManager(gitcred.NewAppStore(appID, storage), gitcred.NewSecretStore(kc), gitcred.GlobalGitConfig{}, issuer) -} - -func listGitCredentialRecords(kc keychain.KeychainAccess, now func() time.Time) ([]gitcred.ListRecord, error) { - storage := gitCredentialAppStorage{} - appIDs, err := storage.ListAppIDs() - if err != nil { - return nil, err - } - records := make([]gitcred.ListRecord, 0, len(appIDs)) - for _, appID := range appIDs { - manager := newGitCredentialManager(appID, kc, nil) - manager.Now = now - result, err := manager.List() - if err != nil { - return nil, err - } - records = append(records, result.Records...) - } - sort.Slice(records, func(i, j int) bool { - if records[i].AppID == records[j].AppID { - return records[i].GitHTTPURL < records[j].GitHTTPURL - } - return records[i].AppID < records[j].AppID - }) - return records, nil -} - func gitCredentialListPayload(records []gitcred.ListRecord) []map[string]interface{} { out := make([]map[string]interface{}, 0, len(records)) for _, record := range records { @@ -416,7 +384,7 @@ func gitCredentialDisplayStatus(status string) string { return status } -func profileFromConfig(cfg *core.CliConfig) gitcred.ProfileContext { +func profileFromConfig(cfg *configpkg.CliConfig) gitcred.ProfileContext { if cfg == nil { return gitcred.ProfileContext{} } @@ -497,7 +465,7 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC // handled locally. func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) { if err != nil { - return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err)) + return nil, redactGitCredentialIssueError(common.WrapDoAPIError(err)) } detail := logIDDetail(resp) if resp == nil || len(resp.RawBody) == 0 { diff --git a/shortcuts/apps/git_credential_storage.go b/shortcuts/apps/git_credential_storage.go deleted file mode 100644 index d76355c4b2..0000000000 --- a/shortcuts/apps/git_credential_storage.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "errors" - "net/url" - "os" - "path/filepath" - - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/vfs" //nolint:depguard // Git credential list scans CLI config-dir state; it is not user file I/O. -) - -type gitCredentialAppStorage struct{} - -func (gitCredentialAppStorage) Read(appID, key string) ([]byte, error) { - return Read(appID, key) -} - -func (gitCredentialAppStorage) Write(appID, key string, data []byte) error { - return Write(appID, key, data) -} - -func (gitCredentialAppStorage) Delete(appID, key string) error { - return Delete(appID, key) -} - -func (gitCredentialAppStorage) ListAppIDs() ([]string, error) { - root := filepath.Join(core.GetConfigDir(), storageRoot) - entries, err := vfs.ReadDir(root) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []string{}, nil - } - return nil, appsStorageError(err, "apps storage: read root: %v", err) - } - appIDs := make([]string, 0, len(entries)) - for _, e := range entries { - if !e.IsDir() { - continue - } - appID, err := url.PathUnescape(e.Name()) - if err != nil { - continue - } - if err := checkSeg(appID, "appID"); err != nil { - continue - } - appIDs = append(appIDs, appID) - } - return appIDs, nil -} diff --git a/shortcuts/apps/git_credential_test.go b/shortcuts/apps/git_credential_test.go index 7aa0d58bed..49d5f165ca 100644 --- a/shortcuts/apps/git_credential_test.go +++ b/shortcuts/apps/git_credential_test.go @@ -21,11 +21,14 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/sparkstore" + "github.com/larksuite/cli/internal/workspace" "github.com/larksuite/cli/shortcuts/apps/gitcred" "github.com/larksuite/cli/shortcuts/common" ) @@ -283,7 +286,7 @@ func TestAppsGitCredentialInitExecutesAndRefreshes(t *testing.T) { if got := stdout.String(); !strings.Contains(got, `"status": "initialized"`) || !strings.Contains(got, `"repository_url": "https://example.com/git/u/app.git"`) { t.Fatalf("init stdout = %s", got) } - meta, err := Read("app_xxx", gitcred.MetadataFilename) + meta, err := sparkstore.Read("app_xxx", gitcred.MetadataFilename) if err != nil { t.Fatalf("read app-scoped metadata: %v", err) } @@ -525,13 +528,13 @@ func TestAppsGitCredentialListEmpty(t *testing.T) { func TestGitCredentialAppStorageListAppIDsSkipsNonCredentialAppDirs(t *testing.T) { newAppsExecuteFactory(t) - if err := Write("app/a", gitcred.MetadataFilename, []byte("{}")); err != nil { + if err := sparkstore.Write("app/a", gitcred.MetadataFilename, []byte("{}")); err != nil { t.Fatalf("Write escaped app metadata: %v", err) } - if err := Write("app_b", gitcred.MetadataFilename, []byte("{}")); err != nil { + if err := sparkstore.Write("app_b", gitcred.MetadataFilename, []byte("{}")); err != nil { t.Fatalf("Write app_b metadata: %v", err) } - root := filepath.Join(core.GetConfigDir(), "spark") + root := filepath.Join(workspace.GetConfigDir(), "spark") if err := os.WriteFile(filepath.Join(root, "not-an-app-dir"), []byte("x"), 0600); err != nil { t.Fatalf("write non-dir: %v", err) } @@ -541,7 +544,7 @@ func TestGitCredentialAppStorageListAppIDsSkipsNonCredentialAppDirs(t *testing.T } } - appIDs, err := gitCredentialAppStorage{}.ListAppIDs() + appIDs, err := (sparkstore.AppStorage{}).ListAppIDs() if err != nil { t.Fatalf("ListAppIDs: %v", err) } @@ -557,7 +560,7 @@ func TestGitCredentialAppStorageListAppIDsSkipsNonCredentialAppDirs(t *testing.T func TestAppsGitCredentialListReturnsScanErrors(t *testing.T) { t.Run("storage root error", func(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - root := filepath.Join(core.GetConfigDir(), "spark") + root := filepath.Join(workspace.GetConfigDir(), "spark") if err := os.WriteFile(root, []byte("not a dir"), 0600); err != nil { t.Fatalf("write storage root blocker: %v", err) } @@ -569,10 +572,10 @@ func TestAppsGitCredentialListReturnsScanErrors(t *testing.T) { t.Run("record error", func(t *testing.T) { factory, _, _ := newAppsExecuteFactory(t) - if err := Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil { + if err := sparkstore.Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil { t.Fatalf("write invalid metadata: %v", err) } - _, err := listGitCredentialRecords(factory.Keychain, time.Now) + _, err := gitcred.ListCredentialRecords(factory.Keychain, time.Now) if err == nil || !strings.Contains(err.Error(), "invalid git.json") { t.Fatalf("listGitCredentialRecords record error = %v", err) } @@ -584,7 +587,7 @@ func TestListGitCredentialRecordsSortsDuplicateDecodedAppIDs(t *testing.T) { kc := newAppsTestKeychain() factory.Keychain = kc now := time.Unix(1780000000, 0) - manager := newGitCredentialManager("app_x", kc, nil) + manager := gitcred.NewAppManager("app_x", kc, nil) manager.Now = func() time.Time { return now } record := gitcred.CredentialRecord{ AppID: "app_x", @@ -598,11 +601,11 @@ func TestListGitCredentialRecordsSortsDuplicateDecodedAppIDs(t *testing.T) { if err := manager.Store.Upsert(record); err != nil { t.Fatalf("Upsert returned error: %v", err) } - if err := os.Mkdir(filepath.Join(core.GetConfigDir(), "spark", "app%5Fx"), 0700); err != nil { + if err := os.Mkdir(filepath.Join(workspace.GetConfigDir(), "spark", "app%5Fx"), 0700); err != nil { t.Fatalf("mkdir duplicate encoded app dir: %v", err) } - records, err := listGitCredentialRecords(kc, func() time.Time { return now }) + records, err := gitcred.ListCredentialRecords(kc, func() time.Time { return now }) if err != nil { t.Fatalf("listGitCredentialRecords returned error: %v", err) } @@ -682,7 +685,7 @@ func TestAppsGitCredentialRemoveRequiresAppID(t *testing.T) { func TestAppsGitCredentialRemoveReturnsStoreError(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - if err := Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil { + if err := sparkstore.Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil { t.Fatalf("write invalid metadata: %v", err) } err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_xxx", "--as", "user"}, factory, stdout) @@ -738,7 +741,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) kc := newAppsTestKeychain() factory.Keychain = kc - storage := gitCredentialAppStorage{} + storage := sparkstore.AppStorage{} manager := gitcred.NewManager(gitcred.NewAppStore("app_xxx", storage), gitcred.NewSecretStore(kc), nil, testAppsIssuer{next: &gitcred.IssuedCredential{ GitHTTPURL: "https://example.com/git/u/app.git", Username: "x-access-token", @@ -788,7 +791,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) { t.Fatalf("stderr = %q", stderr.String()) } stderr.Reset() - factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") } + factory.Config = func() (*configpkg.CliConfig, error) { return nil, errors.New("config failed") } factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n") if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil { t.Fatalf("helper config error returned error: %v", err) @@ -796,8 +799,8 @@ func TestRunGitCredentialHelperActions(t *testing.T) { if !strings.Contains(stderr.String(), "config failed") { t.Fatalf("stderr = %q", stderr.String()) } - cfg = &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu, UserOpenId: "ou_test"} - factory.Config = func() (*core.CliConfig, error) { return cfg, nil } + cfg = &configpkg.CliConfig{AppID: "cli", AppSecret: "secret", Brand: brand.Feishu, UserOpenId: "ou_test"} + factory.Config = func() (*configpkg.CliConfig, error) { return cfg, nil } stderr.Reset() if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "unknown"); err != nil { t.Fatalf("helper unknown returned error: %v", err) @@ -858,19 +861,19 @@ func TestFactoryIssuerBranches(t *testing.T) { t.Fatalf("%s header missing", cmdutil.HeaderExecutionId) } - factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") } + factory.Config = func() (*configpkg.CliConfig, error) { return nil, errors.New("config failed") } if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { t.Fatal("factory issuer config error returned nil") } - factory.Config = func() (*core.CliConfig, error) { - return &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu}, nil + factory.Config = func() (*configpkg.CliConfig, error) { + return &configpkg.CliConfig{AppID: "cli", AppSecret: "secret", Brand: brand.Feishu}, nil } if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { t.Fatal("factory issuer without login returned nil") } - factory.Config = func() (*core.CliConfig, error) { - return &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu, UserOpenId: "ou_test"}, nil + factory.Config = func() (*configpkg.CliConfig, error) { + return &configpkg.CliConfig{AppID: "cli", AppSecret: "secret", Brand: brand.Feishu, UserOpenId: "ou_test"}, nil } factory.LarkClient = func() (*lark.Client, error) { return nil, errors.New("sdk failed") } if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { diff --git a/shortcuts/apps/gitcred/app_manager.go b/shortcuts/apps/gitcred/app_manager.go new file mode 100644 index 0000000000..df53bd4c66 --- /dev/null +++ b/shortcuts/apps/gitcred/app_manager.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "sort" + "time" + + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/sparkstore" +) + +// NewAppManager builds a Git credential manager backed by Spark app storage. +func NewAppManager(appID string, kc keychain.KeychainAccess, issuer Issuer) *Manager { + storage := sparkstore.AppStorage{} + return NewManager(NewAppStore(appID, storage), NewSecretStore(kc), GlobalGitConfig{}, issuer) +} + +// ListCredentialRecords returns local Git credential records for all stored apps. +func ListCredentialRecords(kc keychain.KeychainAccess, now func() time.Time) ([]ListRecord, error) { + storage := sparkstore.AppStorage{} + appIDs, err := storage.ListAppIDs() + if err != nil { + return nil, err + } + records := make([]ListRecord, 0, len(appIDs)) + for _, appID := range appIDs { + manager := NewAppManager(appID, kc, nil) + manager.Now = now + result, err := manager.List() + if err != nil { + return nil, err + } + records = append(records, result.Records...) + } + sort.Slice(records, func(i, j int) bool { + if records[i].AppID == records[j].AppID { + return records[i].GitHTTPURL < records[j].GitHTTPURL + } + return records[i].AppID < records[j].AppID + }) + return records, nil +} diff --git a/shortcuts/apps/gitcred/lock.go b/shortcuts/apps/gitcred/lock.go index 8723e82d5e..85f4c5a189 100644 --- a/shortcuts/apps/gitcred/lock.go +++ b/shortcuts/apps/gitcred/lock.go @@ -29,9 +29,9 @@ import ( "time" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential locks live under CLI config dir and are not user file I/O. + "github.com/larksuite/cli/internal/workspace" ) var urlLocks sync.Map @@ -57,7 +57,7 @@ func lockURL(url string) func() { // Lock ordering: when both lockApp and lockURL are needed, lockApp must be // taken FIRST. See package comment for the full convention. func lockApp(appID string) (func(), error) { - dir := filepath.Join(core.GetConfigDir(), "locks") + dir := filepath.Join(workspace.GetConfigDir(), "locks") if err := vfs.MkdirAll(dir, 0700); err != nil { return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err) } diff --git a/shortcuts/apps/gitcred/store.go b/shortcuts/apps/gitcred/store.go index 6b1988a9b9..215437cc1f 100644 --- a/shortcuts/apps/gitcred/store.go +++ b/shortcuts/apps/gitcred/store.go @@ -11,9 +11,9 @@ import ( "path/filepath" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential metadata is CLI config-dir state, not user file I/O. + "github.com/larksuite/cli/internal/workspace" ) type AppStorage interface { @@ -29,7 +29,7 @@ type Store struct { } func NewStore() *Store { - return &Store{path: filepath.Join(core.GetConfigDir(), MetadataFilename)} + return &Store{path: filepath.Join(workspace.GetConfigDir(), MetadataFilename)} } func NewAppStore(appID string, storage AppStorage) *Store { diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 5faa3739ef..e77c6aa5e7 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -18,9 +18,10 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" @@ -33,10 +34,10 @@ func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { t.Helper() - config := &core.CliConfig{ + config := &configpkg.CliConfig{ AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"), AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: userOpenID, } factory, stdout, _, reg := cmdutil.TestFactory(t, config) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 78c1e1118f..41231a3f0b 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -18,7 +18,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) @@ -62,7 +62,7 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag for name, value := range intFlags { _ = cmd.Flags().Set(name, strconv.Itoa(value)) } - return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}} + return &common.RuntimeContext{Cmd: cmd, Config: &configpkg.CliConfig{UserOpenId: "ou_test"}} } func assertBasePaginationValidation(t *testing.T, err error, param string) { diff --git a/shortcuts/base/record_markdown_test.go b/shortcuts/base/record_markdown_test.go index 09775b2f14..f54e6fc394 100644 --- a/shortcuts/base/record_markdown_test.go +++ b/shortcuts/base/record_markdown_test.go @@ -14,10 +14,11 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcs "github.com/larksuite/cli/extension/contentsafety" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -39,7 +40,7 @@ func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeC parentCmd.AddCommand(baseCmd) baseCmd.AddCommand(cmd) return &common.RuntimeContext{ - Config: &core.CliConfig{Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{Brand: brand.Feishu}, Cmd: cmd, Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}, } diff --git a/shortcuts/calendar/calendar_meeting_test.go b/shortcuts/calendar/calendar_meeting_test.go index 653c1a5c8d..ca9adbaa8d 100644 --- a/shortcuts/calendar/calendar_meeting_test.go +++ b/shortcuts/calendar/calendar_meeting_test.go @@ -15,8 +15,9 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" @@ -54,9 +55,9 @@ func calWarmTokenCache(t *testing.T) { }) } -func calDefaultConfig() *core.CliConfig { - return &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +func calDefaultConfig() *configpkg.CliConfig { + return &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, UserOpenId: "ou_testuser", } } diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index 392116a4c1..fc0fb79ed9 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -13,9 +13,10 @@ import ( "sync" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" @@ -76,22 +77,22 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact return parent.Execute() } -func defaultConfig() *core.CliConfig { - return &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +func defaultConfig() *configpkg.CliConfig { + return &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, UserOpenId: "ou_testuser", } } -func noLoginConfig() *core.CliConfig { - return &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +func noLoginConfig() *configpkg.CliConfig { + return &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, } } -func noLoginBotDefaultConfig() *core.CliConfig { - return &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +func noLoginBotDefaultConfig() *configpkg.CliConfig { + return &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, DefaultAs: "bot", } } diff --git a/shortcuts/calendar/description_rich_images.go b/shortcuts/calendar/description_rich_images.go index 719e24ea2d..d3da586d32 100644 --- a/shortcuts/calendar/description_rich_images.go +++ b/shortcuts/calendar/description_rich_images.go @@ -17,8 +17,8 @@ import ( "regexp" "strings" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -156,9 +156,9 @@ func localImagePath(src string) string { return s } -func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string { +func buildCalendarImagePreviewURL(brand brandpkg.Brand, fileToken string, width, height int, size int64) string { host := "internal-api-drive-stream.feishu.cn" - if brand == core.BrandLark { + if brand == brandpkg.Lark { host = "internal-api-drive-stream.larksuite.com" } u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken) diff --git a/shortcuts/calendar/description_rich_images_test.go b/shortcuts/calendar/description_rich_images_test.go index e41c24b4be..b894a2bac4 100644 --- a/shortcuts/calendar/description_rich_images_test.go +++ b/shortcuts/calendar/description_rich_images_test.go @@ -15,9 +15,9 @@ import ( "strings" "testing" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" ) @@ -65,11 +65,11 @@ func TestLocalImagePath(t *testing.T) { // segment is exactly the uploaded file token. func TestBuildCalendarImagePreviewURL(t *testing.T) { for _, tc := range []struct { - brand core.LarkBrand + brand brandpkg.Brand hostFrag string }{ - {core.BrandFeishu, "feishu.cn"}, - {core.BrandLark, "larksuite"}, + {brandpkg.Feishu, "feishu.cn"}, + {brandpkg.Lark, "larksuite"}, } { raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568) u, err := url.Parse(raw) @@ -90,7 +90,7 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) { } // With unknown dimensions the helper params are omitted entirely. - raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0) + raw := buildCalendarImagePreviewURL(brandpkg.Feishu, "boxcnTOKEN123", 0, 0, 0) if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") { t.Errorf("expected no dimension params for unknown size, got %q", raw) } diff --git a/shortcuts/common/call_api_typed_test.go b/shortcuts/common/call_api_typed_test.go index c8b7d3e1cd..df1b0c2a53 100644 --- a/shortcuts/common/call_api_typed_test.go +++ b/shortcuts/common/call_api_typed_test.go @@ -11,18 +11,20 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" ) func newCallAPITypedRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) { t.Helper() - cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + cfg := &configpkg.CliConfig{Brand: brand.Feishu, AppID: "cli_x"} f, _, _, reg := cmdutil.TestFactory(t, cfg) - rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, identity.AsUser) return rt, reg } @@ -102,8 +104,8 @@ func TestCallAPITyped_Success(t *testing.T) { // runtime: Brand / AppID from config, Identity from the resolved caller, and // LarkCmd from the running command path. func TestAPIClassifyContext(t *testing.T) { - cfg := &core.CliConfig{Brand: core.BrandLark, AppID: "cli_x"} - rt := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+upload"}, cfg, core.AsUser) + cfg := &configpkg.CliConfig{Brand: brand.Lark, AppID: "cli_x"} + rt := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+upload"}, cfg, identity.AsUser) cc := rt.APIClassifyContext() if cc.Brand != "lark" { @@ -119,7 +121,7 @@ func TestAPIClassifyContext(t *testing.T) { t.Errorf("LarkCmd = %q, want +upload", cc.LarkCmd) } - bot := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+push"}, &core.CliConfig{Brand: core.BrandFeishu, AppID: "y"}, core.AsBot) + bot := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+push"}, &configpkg.CliConfig{Brand: brand.Feishu, AppID: "y"}, identity.AsBot) if got := bot.APIClassifyContext().Identity; got != "bot" { t.Errorf("bot Identity = %q, want bot", got) } @@ -254,7 +256,7 @@ func TestDoAPIJSONTyped_Success(t *testing.T) { } func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) { - rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &core.CliConfig{}, nil, core.AsUser) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &configpkg.CliConfig{}, nil, identity.AsUser) rt.apiClientFunc = func() (*client.APIClient, error) { return nil, errors.New("raw client construction error") } diff --git a/shortcuts/common/drive_media_upload_test.go b/shortcuts/common/drive_media_upload_test.go index e3abf0c84c..4b0315bb32 100644 --- a/shortcuts/common/drive_media_upload_test.go +++ b/shortcuts/common/drive_media_upload_test.go @@ -15,10 +15,12 @@ import ( "sync/atomic" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" ) var commonDriveMediaUploadTestSeq atomic.Int64 @@ -304,15 +306,15 @@ func newDriveMediaUploadTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Re t.Helper() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cfg := &core.CliConfig{ - AppID: fmt.Sprintf("common-drive-media-test-%d", commonDriveMediaUploadTestSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu, + cfg := &configpkg.CliConfig{ + AppID: fmt.Sprintf("common-drive-media-test-%d", commonDriveMediaUploadTestSeq.Add(1)), AppSecret: "test-secret", Brand: brand.Feishu, } f, _, _, reg := cmdutil.TestFactory(t, cfg) runtime := &RuntimeContext{ ctx: context.Background(), Config: cfg, Factory: f, - resolvedAs: core.AsBot, + resolvedAs: identity.AsBot, } return runtime, reg } diff --git a/shortcuts/common/drive_meta_test.go b/shortcuts/common/drive_meta_test.go index 582c9b2ac4..43d9ef2888 100644 --- a/shortcuts/common/drive_meta_test.go +++ b/shortcuts/common/drive_meta_test.go @@ -10,10 +10,12 @@ import ( "sync/atomic" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" ) var driveMetaTestSeq atomic.Int64 @@ -156,15 +158,15 @@ func newDriveMetaTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) t.Helper() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cfg := &core.CliConfig{ - AppID: fmt.Sprintf("drive-meta-test-%d", driveMetaTestSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu, + cfg := &configpkg.CliConfig{ + AppID: fmt.Sprintf("drive-meta-test-%d", driveMetaTestSeq.Add(1)), AppSecret: "test-secret", Brand: brand.Feishu, } f, _, _, reg := cmdutil.TestFactory(t, cfg) runtime := &RuntimeContext{ ctx: context.Background(), Config: cfg, Factory: f, - resolvedAs: core.AsBot, + resolvedAs: identity.AsBot, } return runtime, reg } diff --git a/shortcuts/common/mcp_client.go b/shortcuts/common/mcp_client.go index 895aa2a4c2..28e66da85a 100644 --- a/shortcuts/common/mcp_client.go +++ b/shortcuts/common/mcp_client.go @@ -14,16 +14,16 @@ import ( "github.com/google/uuid" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/util" ) const mcpErrorBodyLimit = 4000 -func MCPEndpoint(brand core.LarkBrand) string { - return core.ResolveEndpoints(brand).MCP + "/mcp" +func MCPEndpoint(brand brandpkg.Brand) string { + return brandpkg.ResolveEndpoints(brand).MCP + "/mcp" } // CallMCPTool calls an MCP tool via JSON-RPC 2.0 and returns the parsed result. diff --git a/shortcuts/common/output_dir.go b/shortcuts/common/output_dir.go new file mode 100644 index 0000000000..8cae75b37c --- /dev/null +++ b/shortcuts/common/output_dir.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "github.com/larksuite/cli/internal/outputdir" + +// EnsureOutputDir creates an output directory with owner-only permissions. +// Relative paths are validated and resolved within the working directory. +// Absolute paths are accepted for callers that already resolved them through +// SafeOutputPath or RuntimeContext.ResolveSavePath. +func EnsureOutputDir(path string) error { + return outputdir.Ensure(path) +} diff --git a/shortcuts/common/permission_grant_test.go b/shortcuts/common/permission_grant_test.go index abd966b652..01692c000a 100644 --- a/shortcuts/common/permission_grant_test.go +++ b/shortcuts/common/permission_grant_test.go @@ -9,10 +9,12 @@ import ( "strings" "testing" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" ) // apiErrWithScopes builds the typed error errclass.BuildAPIError produces for a @@ -40,10 +42,10 @@ func TestPermissionGrantPermMessageUsesAPINameOnly(t *testing.T) { } func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) { - config := &core.CliConfig{ + config := &configpkg.CliConfig{ AppID: "perm-grant-test-skip", AppSecret: "perm-grant-test-secret-skip", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, } f, _, stderr, _ := cmdutil.TestFactory(t, config) @@ -52,7 +54,7 @@ func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) { ctx: ctx, Config: config, Factory: f, - resolvedAs: core.AsBot, + resolvedAs: identity.AsBot, } result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx") @@ -74,10 +76,10 @@ func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) { } func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) { - config := &core.CliConfig{ + config := &configpkg.CliConfig{ AppID: "perm-grant-test-fail", AppSecret: "perm-grant-test-secret-fail", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, UserOpenId: "ou_test_user", } f, _, stderr, reg := cmdutil.TestFactory(t, config) @@ -97,7 +99,7 @@ func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) { ctx: ctx, Config: config, Factory: f, - resolvedAs: core.AsBot, + resolvedAs: identity.AsBot, } result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx") @@ -123,9 +125,9 @@ func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) { // ── annotateGrantPermissionError unit tests ──────────────────────────────── -func newAnnotateRuntime(brand core.LarkBrand, appID string) *RuntimeContext { +func newAnnotateRuntime(brand brandpkg.Brand, appID string) *RuntimeContext { return &RuntimeContext{ - Config: &core.CliConfig{ + Config: &configpkg.CliConfig{ AppID: appID, Brand: brand, }, @@ -136,7 +138,7 @@ func newAnnotateRuntime(brand core.LarkBrand, appID string) *RuntimeContext { // console_url must be brand-specific. The hint should be overridden to point // at the developer console. func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) { - rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo") + rt := newAnnotateRuntime(brandpkg.Feishu, "cli_demo") result := map[string]interface{}{ "hint": "generic fallback hint", } @@ -168,7 +170,7 @@ func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) { } func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) { - rt := newAnnotateRuntime(core.BrandLark, "cli_demo") + rt := newAnnotateRuntime(brandpkg.Lark, "cli_demo") result := map[string]interface{}{} err := apiErrWithScopes(99991679, "Permission denied [99991679]", "docs:permission.member:create") @@ -182,7 +184,7 @@ func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) { // Non-permission errors (network, validation, plain errors) must not be // annotated — keep the existing generic hint untouched. func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) { - rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo") + rt := newAnnotateRuntime(brandpkg.Feishu, "cli_demo") cases := []error{ errors.New("plain error"), @@ -209,7 +211,7 @@ func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) { // permission_violations missing → only lark_code is annotated; no console_url // and the existing hint stays as-is (caller's generic fallback wins). func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) { - rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo") + rt := newAnnotateRuntime(brandpkg.Feishu, "cli_demo") result := map[string]interface{}{ "hint": "untouched fallback", } @@ -230,7 +232,7 @@ func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) { // AppID empty → no console_url even when violations exist. func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) { - rt := newAnnotateRuntime(core.BrandFeishu, "") + rt := newAnnotateRuntime(brandpkg.Feishu, "") result := map[string]interface{}{} err := apiErrWithScopes(99991672, "Permission denied", "docs:doc") @@ -245,7 +247,7 @@ func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) { // Defensive: nil/empty arguments must be safe no-ops. func TestAnnotateGrantPermissionError_NilArgsSafe(t *testing.T) { - rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo") + rt := newAnnotateRuntime(brandpkg.Feishu, "cli_demo") annotateGrantPermissionError(nil, map[string]interface{}{}, nil) annotateGrantPermissionError(rt, nil, nil) @@ -257,10 +259,10 @@ func TestAnnotateGrantPermissionError_NilArgsSafe(t *testing.T) { // with a mocked 99991672 response — verifies the annotated fields show up // in the JSON result that callers downstream consume. func TestAutoGrantStderrWarning_GrantFailed_AppScopeNotEnabled_Annotated(t *testing.T) { - config := &core.CliConfig{ + config := &configpkg.CliConfig{ AppID: "cli_app_demo", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brandpkg.Feishu, UserOpenId: "ou_test_user", } f, _, _, reg := cmdutil.TestFactory(t, config) @@ -287,7 +289,7 @@ func TestAutoGrantStderrWarning_GrantFailed_AppScopeNotEnabled_Annotated(t *test ctx: ctx, Config: config, Factory: f, - resolvedAs: core.AsBot, + resolvedAs: identity.AsBot, } result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx") diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 29ec31c10e..46e60686e8 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -7,7 +7,7 @@ import ( "net/url" "strings" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" ) // BuildResourceURL returns a brand-standard, user-facing URL for a freshly @@ -22,14 +22,14 @@ import ( // Returns "" when token is empty or kind is unrecognized — callers should // only set the field when the result is non-empty so that "" never overrides // a real URL the backend already returned. -func BuildResourceURL(brand core.LarkBrand, kind, token string) string { +func BuildResourceURL(brand brandpkg.Brand, kind, token string) string { token = strings.TrimSpace(token) if token == "" { return "" } host := "https://www.feishu.cn" - if brand == core.BrandLark { + if brand == brandpkg.Lark { host = "https://www.larksuite.com" } diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go index c0109fe9c4..9bfce6576f 100644 --- a/shortcuts/common/resource_url_test.go +++ b/shortcuts/common/resource_url_test.go @@ -6,7 +6,7 @@ package common import ( "testing" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" ) func TestParseResourceURL(t *testing.T) { @@ -90,7 +90,7 @@ func TestParseResourceURL_RoundTrip(t *testing.T) { for _, kind := range types { t.Run(kind, func(t *testing.T) { - built := BuildResourceURL(core.BrandFeishu, kind, token) + built := BuildResourceURL(brandpkg.Feishu, kind, token) if built == "" { t.Fatalf("BuildResourceURL returned empty for kind %q", kind) } @@ -113,29 +113,29 @@ func TestBuildResourceURL(t *testing.T) { tests := []struct { name string - brand core.LarkBrand + brand brandpkg.Brand kind string token string want string }{ - {"feishu docx", core.BrandFeishu, "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, - {"feishu doc legacy", core.BrandFeishu, "doc", "doccnABC", "https://www.feishu.cn/doc/doccnABC"}, - {"feishu sheet", core.BrandFeishu, "sheet", "shtcnABC", "https://www.feishu.cn/sheets/shtcnABC"}, - {"feishu bitable", core.BrandFeishu, "bitable", "bascnABC", "https://www.feishu.cn/base/bascnABC"}, - {"feishu wiki", core.BrandFeishu, "wiki", "wikcnABC", "https://www.feishu.cn/wiki/wikcnABC"}, - {"feishu file", core.BrandFeishu, "file", "boxcnABC", "https://www.feishu.cn/file/boxcnABC"}, - {"feishu folder", core.BrandFeishu, "folder", "fldcnABC", "https://www.feishu.cn/drive/folder/fldcnABC"}, - {"feishu mindnote", core.BrandFeishu, "mindnote", "mncnABC", "https://www.feishu.cn/mindnote/mncnABC"}, - {"feishu slides", core.BrandFeishu, "slides", "slkcnABC", "https://www.feishu.cn/slides/slkcnABC"}, - {"lark docx", core.BrandLark, "docx", "doxcnABC", "https://www.larksuite.com/docx/doxcnABC"}, - {"lark wiki", core.BrandLark, "wiki", "wikcnABC", "https://www.larksuite.com/wiki/wikcnABC"}, - {"empty brand defaults to feishu", core.LarkBrand(""), "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, - {"kind case-insensitive", core.BrandFeishu, "DOCX", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, - {"token whitespace trimmed", core.BrandFeishu, "docx", " doxcnABC ", "https://www.feishu.cn/docx/doxcnABC"}, - {"empty token", core.BrandFeishu, "docx", "", ""}, - {"whitespace-only token", core.BrandFeishu, "docx", " ", ""}, - {"unknown kind", core.BrandFeishu, "calendar", "calABC", ""}, - {"empty kind", core.BrandFeishu, "", "doxcnABC", ""}, + {"feishu docx", brandpkg.Feishu, "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, + {"feishu doc legacy", brandpkg.Feishu, "doc", "doccnABC", "https://www.feishu.cn/doc/doccnABC"}, + {"feishu sheet", brandpkg.Feishu, "sheet", "shtcnABC", "https://www.feishu.cn/sheets/shtcnABC"}, + {"feishu bitable", brandpkg.Feishu, "bitable", "bascnABC", "https://www.feishu.cn/base/bascnABC"}, + {"feishu wiki", brandpkg.Feishu, "wiki", "wikcnABC", "https://www.feishu.cn/wiki/wikcnABC"}, + {"feishu file", brandpkg.Feishu, "file", "boxcnABC", "https://www.feishu.cn/file/boxcnABC"}, + {"feishu folder", brandpkg.Feishu, "folder", "fldcnABC", "https://www.feishu.cn/drive/folder/fldcnABC"}, + {"feishu mindnote", brandpkg.Feishu, "mindnote", "mncnABC", "https://www.feishu.cn/mindnote/mncnABC"}, + {"feishu slides", brandpkg.Feishu, "slides", "slkcnABC", "https://www.feishu.cn/slides/slkcnABC"}, + {"lark docx", brandpkg.Lark, "docx", "doxcnABC", "https://www.larksuite.com/docx/doxcnABC"}, + {"lark wiki", brandpkg.Lark, "wiki", "wikcnABC", "https://www.larksuite.com/wiki/wikcnABC"}, + {"empty brand defaults to feishu", brandpkg.Brand(""), "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, + {"kind case-insensitive", brandpkg.Feishu, "DOCX", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, + {"token whitespace trimmed", brandpkg.Feishu, "docx", " doxcnABC ", "https://www.feishu.cn/docx/doxcnABC"}, + {"empty token", brandpkg.Feishu, "docx", "", ""}, + {"whitespace-only token", brandpkg.Feishu, "docx", " ", ""}, + {"unknown kind", brandpkg.Feishu, "calendar", "calABC", ""}, + {"empty kind", brandpkg.Feishu, "", "doxcnABC", ""}, } for _, tt := range tests { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index dc1f058df7..3456289edb 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -14,6 +14,7 @@ import ( "slices" "strings" "sync" + "time" "github.com/google/uuid" lark "github.com/larksuite/oapi-sdk-go/v3" @@ -25,10 +26,11 @@ import ( "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/i18n" + identitypkg "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -37,14 +39,14 @@ import ( // RuntimeContext provides helpers for shortcut execution. type RuntimeContext struct { ctx context.Context // from cmd.Context(), propagated through the call chain - Config *core.CliConfig + Config *configpkg.CliConfig Cmd *cobra.Command Format string JqExpr string // --jq expression; empty = no filter outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat() outputErr error // deferred error from jq filtering; written at most once botOnly bool // set by framework for bot-only shortcuts - resolvedAs core.Identity // effective identity resolved by framework + resolvedAs identitypkg.Identity // effective identity resolved by framework Factory *cmdutil.Factory // injected by framework apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info @@ -54,20 +56,20 @@ type RuntimeContext struct { // ── Identity ── -// As returns the current identity. +// As returns the current identitypkg. // For bot-only shortcuts, always returns AsBot. // For dual-auth shortcuts, uses the resolved identity (respects default-as config). -func (ctx *RuntimeContext) As() core.Identity { +func (ctx *RuntimeContext) As() identitypkg.Identity { if ctx.botOnly { - return core.AsBot + return identitypkg.AsBot } if ctx.resolvedAs.IsBot() { - return core.AsBot + return identitypkg.AsBot } if ctx.resolvedAs != "" { return ctx.resolvedAs } - return core.AsUser + return identitypkg.AsUser } // IsBot returns true if current identity is bot. @@ -77,7 +79,7 @@ func (ctx *RuntimeContext) IsBot() bool { // Command returns the shortcut command name as cobra knows it (e.g. // "+pivot-create"). Used by per-service helpers (e.g. sheets schema -// validation) that key off the shortcut identity. +// validation) that key off the shortcut identitypkg. func (ctx *RuntimeContext) Command() string { if ctx.Cmd == nil { return "" @@ -161,7 +163,7 @@ func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) { return ctx.Factory.NewAPIClientWithConfig(ctx.Config) } -// AccessToken returns a valid access token for the current identity. +// AccessToken returns a valid access token for the current identitypkg. // For user: returns user access token (with auto-refresh). // For bot: returns tenant access token. func (ctx *RuntimeContext) AccessToken() (string, error) { @@ -198,6 +200,32 @@ func (ctx *RuntimeContext) EnsureScopes(scopes []string) error { return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes) } +// ResolveTokenScopes resolves the current credential and returns its scope metadata. +func (ctx *RuntimeContext) ResolveTokenScopes(callCtx context.Context) (string, bool, error) { + result, err := ctx.Factory.Credential.ResolveToken(callCtx, credential.NewTokenSpec(ctx.As(), ctx.Config.AppID)) + if err != nil { + return "", false, err + } + if result == nil || result.Scopes == "" { + return "", false, nil + } + return result.Scopes, true, nil +} + +// StoredTokenScopes returns scope metadata from the locally stored user token. +func (ctx *RuntimeContext) StoredTokenScopes() (string, bool) { + stored := auth.GetStoredToken(ctx.Config.AppID, ctx.UserOpenId()) + if stored == nil { + return "", false + } + return stored.Scope, true +} + +// MissingScopes returns required scopes absent from the stored scope string. +func MissingScopes(storedScope string, required []string) []string { + return auth.MissingScopes(storedScope, required) +} + // ── Flag accessors ── // Str returns a string flag value. @@ -254,6 +282,29 @@ func (ctx *RuntimeContext) Changed(name string) bool { // ── API helpers ── +// Option configures streaming API request behavior. +type Option = client.Option + +// WithHeaders adds HTTP headers to a streaming API request. +func WithHeaders(headers http.Header) Option { + return client.WithHeaders(headers) +} + +// WithTimeout sets a timeout for a streaming API request. +func WithTimeout(timeout time.Duration) Option { + return client.WithTimeout(timeout) +} + +// WrapDoAPIError classifies SDK request failures as typed command errors. +func WrapDoAPIError(err error) error { + return client.WrapDoAPIError(err) +} + +// ResolveFilename derives a download filename from an API response. +func ResolveFilename(resp *larkcore.ApiResp) string { + return client.ResolveFilename(resp) +} + // CallAPITyped calls the Lark API using the current identity (ctx.As()) via // the SDK request path (buildRequest → APIClient.DoAPI → DoSDKRequest) and // returns the "data" object, classifying failures into typed errs.* errors via @@ -374,7 +425,7 @@ func typedOrInternal(err error) error { } // APIClassifyContext builds the errclass.ClassifyContext for the running command -// from the runtime config and resolved identity. +// from the runtime config and resolved identitypkg. func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext { larkCmd := "" if ctx.Cmd != nil { @@ -471,7 +522,7 @@ func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.Req if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil { opts = append(opts, optFn) } - return ac.DoSDKRequest(ctx.ctx, req, core.AsBot, opts...) + return ac.DoSDKRequest(ctx.ctx, req, identitypkg.AsBot, opts...) } // DoAPIStream executes a streaming HTTP request via APIClient.DoStream. @@ -774,7 +825,7 @@ func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, pre // checkScopePrereqs performs a fast local check: does the token // contain all scopes declared by the shortcut? Returns the missing ones. // If scope data is unavailable, returns nil (let the API call handle it). -func checkScopePrereqs(f *cmdutil.Factory, ctx context.Context, appID string, identity core.Identity, required []string) ([]string, error) { +func checkScopePrereqs(f *cmdutil.Factory, ctx context.Context, appID string, identity identitypkg.Identity, required []string) ([]string, error) { result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identity, appID)) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { @@ -951,23 +1002,23 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo return rctx.outputErr } -func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) { +func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (identitypkg.Identity, error) { // Step 1: determine identity (--as > default-as > auto-detect). asFlag, _ := cmd.Flags().GetString("as") - as := f.ResolveAs(cmd.Context(), cmd, core.Identity(asFlag)) + as := f.ResolveAs(cmd.Context(), cmd, identitypkg.Identity(asFlag)) if err := f.CheckStrictMode(cmd.Context(), as); err != nil { return "", err } - // Step 2: check if this shortcut supports the resolved identity. + // Step 2: check if this shortcut supports the resolved identitypkg. if err := f.CheckIdentity(as, s.AuthTypes); err != nil { return "", err } return as, nil } -func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identity, config *core.CliConfig, scopes []string) error { +func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as identitypkg.Identity, config *configpkg.CliConfig, scopes []string) error { if len(scopes) == 0 { return nil } @@ -985,7 +1036,7 @@ func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identi WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")) } -func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) { +func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *configpkg.CliConfig, as identitypkg.Identity, botOnly bool) (*RuntimeContext, error) { ctx := cmd.Context() ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String()) rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f} diff --git a/shortcuts/common/runner_botinfo_test.go b/shortcuts/common/runner_botinfo_test.go index f8d77f747d..91f1143c67 100644 --- a/shortcuts/common/runner_botinfo_test.go +++ b/shortcuts/common/runner_botinfo_test.go @@ -10,18 +10,19 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" ) // botInfoTestConfig returns a CliConfig suitable for bot info tests. -func botInfoTestConfig(t *testing.T) *core.CliConfig { +func botInfoTestConfig(t *testing.T) *configpkg.CliConfig { t.Helper() - return &core.CliConfig{ + return &configpkg.CliConfig{ AppID: "test-app", AppSecret: "test-secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, } } @@ -290,7 +291,7 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) { func TestBotInfo_NilFunc(t *testing.T) { cmd := &cobra.Command{Use: "test"} - rctx := TestNewRuntimeContext(cmd, &core.CliConfig{}) + rctx := TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) _, err := rctx.BotInfo() if err == nil { t.Fatal("expected error for nil botInfoFunc") diff --git a/shortcuts/common/runner_contentsafety_test.go b/shortcuts/common/runner_contentsafety_test.go index 262663aa48..863d12c5ee 100644 --- a/shortcuts/common/runner_contentsafety_test.go +++ b/shortcuts/common/runner_contentsafety_test.go @@ -12,10 +12,12 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" extcs "github.com/larksuite/cli/extension/contentsafety" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) @@ -37,9 +39,9 @@ func newCSTestContext(t *testing.T) (*RuntimeContext, *bytes.Buffer, *bytes.Buff parentCmd.AddCommand(cmd) rctx := &RuntimeContext{ ctx: context.Background(), - Config: &core.CliConfig{Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{Brand: brand.Feishu}, Cmd: cmd, - resolvedAs: core.AsBot, + resolvedAs: identity.AsBot, Factory: &cmdutil.Factory{ IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}, }, diff --git a/shortcuts/common/runner_identity_flag_test.go b/shortcuts/common/runner_identity_flag_test.go index a6ed1020b3..fef6e4b481 100644 --- a/shortcuts/common/runner_identity_flag_test.go +++ b/shortcuts/common/runner_identity_flag_test.go @@ -7,14 +7,15 @@ import ( "context" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/spf13/cobra" ) func TestShortcutMount_StrictModeHidesAsFlag(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, + f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2, }) parent := &cobra.Command{Use: "root"} shortcut := Shortcut{ diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index 3f20fcbb27..6e240afbb1 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -15,10 +15,12 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) @@ -40,11 +42,11 @@ func newJqTestContext(jqExpr, format string) (*RuntimeContext, *bytes.Buffer, *b rctx := &RuntimeContext{ ctx: context.Background(), - Config: &core.CliConfig{Brand: core.BrandFeishu}, + Config: &configpkg.CliConfig{Brand: brand.Feishu}, Cmd: cmd, Format: format, JqExpr: jqExpr, - resolvedAs: core.AsBot, + resolvedAs: identity.AsBot, Factory: &cmdutil.Factory{ IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}, }, @@ -223,9 +225,9 @@ func newTestShortcutCmd(s *Shortcut, f *cmdutil.Factory) *cobra.Command { func newTestFactory() *cmdutil.Factory { return &cmdutil.Factory{ - Config: func() (*core.CliConfig, error) { - return &core.CliConfig{ - AppID: "test", AppSecret: "test", Brand: core.BrandFeishu, + Config: func() (*configpkg.CliConfig, error) { + return &configpkg.CliConfig{ + AppID: "test", AppSecret: "test", Brand: brand.Feishu, }, nil }, LarkClient: func() (*lark.Client, error) { diff --git a/shortcuts/common/runner_lang_test.go b/shortcuts/common/runner_lang_test.go index 9efd0eb6b3..db25c0a13c 100644 --- a/shortcuts/common/runner_lang_test.go +++ b/shortcuts/common/runner_lang_test.go @@ -6,7 +6,7 @@ package common import ( "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/i18n" ) @@ -24,7 +24,7 @@ func TestRuntimeContext_Lang(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := &RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}} + ctx := &RuntimeContext{Config: &configpkg.CliConfig{Lang: tt.stored}} if got := ctx.Lang(); got != tt.want { t.Errorf("Lang() with stored %q = %q, want %q", tt.stored, got, tt.want) } diff --git a/shortcuts/common/runner_partial_failure_test.go b/shortcuts/common/runner_partial_failure_test.go index 3147abbe32..32296974e3 100644 --- a/shortcuts/common/runner_partial_failure_test.go +++ b/shortcuts/common/runner_partial_failure_test.go @@ -11,8 +11,10 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" ) @@ -21,9 +23,9 @@ import ( // returned error is the typed partial-failure exit signal (ExitAPI), distinct // from ErrBare (the silent-exit signal). func TestOutPartialFailure(t *testing.T) { - cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + cfg := &configpkg.CliConfig{Brand: brand.Feishu, AppID: "cli_x"} f, stdout, _, _ := cmdutil.TestFactory(t, cfg) - rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, core.AsUser) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, identity.AsUser) payload := map[string]interface{}{ "summary": map[string]interface{}{"uploaded": 1, "failed": 1}, diff --git a/shortcuts/common/runner_scope_test.go b/shortcuts/common/runner_scope_test.go index c3313eaf43..e238bca2a0 100644 --- a/shortcuts/common/runner_scope_test.go +++ b/shortcuts/common/runner_scope_test.go @@ -12,8 +12,9 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/identity" ) type scopeCheckTokenResolver struct { @@ -109,7 +110,7 @@ func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) { Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: context.Canceled}, nil), } - err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"}) + err := checkShortcutScopes(f, context.Background(), identity.AsUser, &configpkg.CliConfig{AppID: "app-1"}, []string{"im:message:read"}) if !errors.Is(err, context.Canceled) { t.Fatalf("checkShortcutScopes() error = %v, want context.Canceled", err) } @@ -130,7 +131,7 @@ func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) { } required := []string{"im:message:read", "drive:drive:read", "docx:document:read"} - err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, required) + err := checkShortcutScopes(f, context.Background(), identity.AsUser, &configpkg.CliConfig{AppID: "app-1"}, required) if err == nil { t.Fatal("expected error when token is missing required scopes, got nil") } @@ -145,8 +146,8 @@ func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) { if permErr.Subtype != errs.SubtypeMissingScope { t.Errorf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) } - if permErr.Identity != string(core.AsUser) { - t.Errorf("Identity = %q, want %q", permErr.Identity, string(core.AsUser)) + if permErr.Identity != string(identity.AsUser) { + t.Errorf("Identity = %q, want %q", permErr.Identity, string(identity.AsUser)) } wantMissing := map[string]bool{"drive:drive:read": true, "docx:document:read": true} for _, m := range permErr.MissingScopes { @@ -171,7 +172,7 @@ func TestCheckShortcutScopes_IgnoresNonContextTokenErrors(t *testing.T) { Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}, nil), } - err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"}) + err := checkShortcutScopes(f, context.Background(), identity.AsUser, &configpkg.CliConfig{AppID: "app-1"}, []string{"im:message:read"}) if err != nil { t.Fatalf("checkShortcutScopes() error = %v, want nil", err) } diff --git a/shortcuts/common/testing.go b/shortcuts/common/testing.go index 345078f9e1..91db29e239 100644 --- a/shortcuts/common/testing.go +++ b/shortcuts/common/testing.go @@ -10,28 +10,29 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" ) // TestNewRuntimeContext creates a RuntimeContext for testing purposes. // Only Cmd and Config are set; other fields (Factory, larkSDK, etc.) are nil. -func TestNewRuntimeContext(cmd *cobra.Command, cfg *core.CliConfig) *RuntimeContext { +func TestNewRuntimeContext(cmd *cobra.Command, cfg *configpkg.CliConfig) *RuntimeContext { return &RuntimeContext{Cmd: cmd, Config: cfg} } // TestNewRuntimeContextWithCtx creates a RuntimeContext with an explicit context // for tests that invoke functions which call Ctx() (e.g. HTTP request helpers). -func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *core.CliConfig) *RuntimeContext { +func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *configpkg.CliConfig) *RuntimeContext { return &RuntimeContext{ctx: ctx, Cmd: cmd, Config: cfg} } // TestNewRuntimeContextWithIdentity creates a RuntimeContext with a specific identity for testing. -func TestNewRuntimeContextWithIdentity(cmd *cobra.Command, cfg *core.CliConfig, as core.Identity) *RuntimeContext { +func TestNewRuntimeContextWithIdentity(cmd *cobra.Command, cfg *configpkg.CliConfig, as identity.Identity) *RuntimeContext { return &RuntimeContext{Cmd: cmd, Config: cfg, resolvedAs: as} } // TestNewRuntimeContextWithBotInfo creates a RuntimeContext with a pre-set BotInfo for testing. -func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, info *BotInfo) *RuntimeContext { +func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *configpkg.CliConfig, info *BotInfo) *RuntimeContext { rctx := &RuntimeContext{Cmd: cmd, Config: cfg} rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) { return info, nil @@ -44,11 +45,11 @@ func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, i // can invoke DoAPI / CallAPI directly without wiring through a cobra parent // command. // -// Pass core.AsBot or core.AsUser explicitly — exposing the identity as a +// Pass identity.AsBot or identity.AsUser explicitly — exposing the identity as a // parameter keeps the helper reusable for tests that need to exercise the // user-identity code path (token store, auth login, etc.) without forking // into a second near-identical helper. -func TestNewRuntimeContextForAPI(ctx context.Context, cmd *cobra.Command, cfg *core.CliConfig, f *cmdutil.Factory, as core.Identity) *RuntimeContext { +func TestNewRuntimeContextForAPI(ctx context.Context, cmd *cobra.Command, cfg *configpkg.CliConfig, f *cmdutil.Factory, as identity.Identity) *RuntimeContext { return &RuntimeContext{ ctx: ctx, Cmd: cmd, diff --git a/shortcuts/common/testing_test.go b/shortcuts/common/testing_test.go index e2c7650611..3bb3158edd 100644 --- a/shortcuts/common/testing_test.go +++ b/shortcuts/common/testing_test.go @@ -9,18 +9,20 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" + "github.com/larksuite/cli/internal/identity" ) func TestTestNewRuntimeContextForAPIWiresFields(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cfg := &core.CliConfig{AppID: "self-test-app", AppSecret: "secret", Brand: core.BrandFeishu} + cfg := &configpkg.CliConfig{AppID: "self-test-app", AppSecret: "secret", Brand: brand.Feishu} f, _, _, _ := cmdutil.TestFactory(t, cfg) cmd := &cobra.Command{Use: "testing-helper"} ctx := context.Background() - rctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsBot) + rctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, identity.AsBot) if rctx == nil { t.Fatal("TestNewRuntimeContextForAPI returned nil") } @@ -43,8 +45,8 @@ func TestTestNewRuntimeContextForAPIWiresFields(t *testing.T) { // User identity should also be accepted — the whole reason for making // the parameter explicit is to let user-identity code paths use this // helper instead of forking a second one. - userRctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsUser) - if userRctx.resolvedAs != core.AsUser { + userRctx := TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, identity.AsUser) + if userRctx.resolvedAs != identity.AsUser { t.Errorf("resolvedAs AsUser not preserved, got %q", userRctx.resolvedAs) } } diff --git a/shortcuts/common/userids_test.go b/shortcuts/common/userids_test.go index 7d5e2f76a9..82f2d30cae 100644 --- a/shortcuts/common/userids_test.go +++ b/shortcuts/common/userids_test.go @@ -7,13 +7,13 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/spf13/cobra" ) func resolveOpenIDsTestRuntime(userOpenID string) *RuntimeContext { cmd := &cobra.Command{Use: "test"} - cfg := &core.CliConfig{UserOpenId: userOpenID} + cfg := &configpkg.CliConfig{UserOpenId: userOpenID} return TestNewRuntimeContext(cmd, cfg) } diff --git a/shortcuts/contact/contact_search_user.go b/shortcuts/contact/contact_search_user.go index b1a43b841a..f2aaa30dca 100644 --- a/shortcuts/contact/contact_search_user.go +++ b/shortcuts/contact/contact_search_user.go @@ -15,8 +15,8 @@ import ( "strings" "unicode/utf8" + brandpkg "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -260,7 +260,7 @@ func isHumanReadableFormat(format string) bool { // We deliberately do not surface a numeric rank: the API returns no relevance // score, and a derived ordinal would tempt agents to over-trust it. -func projectUsers(data *searchUserAPIData, lang string, brand core.LarkBrand) ([]searchUser, bool) { +func projectUsers(data *searchUserAPIData, lang string, brand brandpkg.Brand) ([]searchUser, bool) { if data == nil { return []searchUser{}, false } @@ -317,13 +317,13 @@ func prettyUserRows(users []searchUser) []map[string]interface{} { // lark→en_us first) → fixedLocaleFallback → dictionary order → openID. // Does NOT fall back to display_info, which may contain phone/email instead // of a name. -func pickName(i18n map[string]string, lang string, brand core.LarkBrand, openID string) string { +func pickName(i18n map[string]string, lang string, brand brandpkg.Brand, openID string) string { primary := make([]string, 0, 3) if lang != "" { primary = append(primary, strings.ReplaceAll(strings.ToLower(lang), "-", "_")) } switch brand { - case core.BrandLark: + case brandpkg.Lark: primary = append(primary, "en_us", "zh_cn") default: primary = append(primary, "zh_cn", "en_us") @@ -356,7 +356,7 @@ func pickName(i18n map[string]string, lang string, brand core.LarkBrand, openID // Cross-tenant users may have empty email / department; pass through as empty // string so consumers can distinguish "unknown" from "confirmed absent". -func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) searchUser { +func rowFromItem(item *searchUserAPIItem, lang string, brand brandpkg.Brand) searchUser { meta := &item.MetaData i18n := meta.I18nNames if i18n == nil { diff --git a/shortcuts/contact/contact_search_user_test.go b/shortcuts/contact/contact_search_user_test.go index b02356df18..9569211aa1 100644 --- a/shortcuts/contact/contact_search_user_test.go +++ b/shortcuts/contact/contact_search_user_test.go @@ -15,10 +15,12 @@ import ( "time" "unicode/utf8" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -36,16 +38,16 @@ func newSearchUserTestCommand() *cobra.Command { return cmd } -func searchUserDefaultConfig() *core.CliConfig { - return &core.CliConfig{ - AppID: "test", AppSecret: "test", Brand: core.BrandFeishu, +func searchUserDefaultConfig() *configpkg.CliConfig { + return &configpkg.CliConfig{ + AppID: "test", AppSecret: "test", Brand: brand.Feishu, UserOpenId: "ou_self", } } func TestPickName_ExplicitLang_Hit(t *testing.T) { i18n := map[string]string{"zh_cn": "张三", "en_us": "Zhangsan"} - got := pickName(i18n, "en-US", core.BrandFeishu, "ou_x") + got := pickName(i18n, "en-US", brand.Feishu, "ou_x") if got != "Zhangsan" { t.Errorf("got %q, want Zhangsan", got) } @@ -53,7 +55,7 @@ func TestPickName_ExplicitLang_Hit(t *testing.T) { func TestPickName_ExplicitLang_MissFallsToBrand(t *testing.T) { i18n := map[string]string{"zh_cn": "张三"} - got := pickName(i18n, "ja-JP", core.BrandFeishu, "ou_x") + got := pickName(i18n, "ja-JP", brand.Feishu, "ou_x") if got != "张三" { t.Errorf("got %q, want 张三 (brand fallback)", got) } @@ -61,7 +63,7 @@ func TestPickName_ExplicitLang_MissFallsToBrand(t *testing.T) { func TestPickName_BrandFeishu_PicksZh(t *testing.T) { i18n := map[string]string{"zh_cn": "张三", "en_us": "Zhangsan"} - got := pickName(i18n, "", core.BrandFeishu, "ou_x") + got := pickName(i18n, "", brand.Feishu, "ou_x") if got != "张三" { t.Errorf("got %q, want 张三", got) } @@ -69,7 +71,7 @@ func TestPickName_BrandFeishu_PicksZh(t *testing.T) { func TestPickName_BrandLark_PicksEn(t *testing.T) { i18n := map[string]string{"zh_cn": "张三", "en_us": "Zhangsan"} - got := pickName(i18n, "", core.BrandLark, "ou_x") + got := pickName(i18n, "", brand.Lark, "ou_x") if got != "Zhangsan" { t.Errorf("got %q, want Zhangsan", got) } @@ -77,7 +79,7 @@ func TestPickName_BrandLark_PicksEn(t *testing.T) { func TestPickName_FixedLocaleList_HitJaJp(t *testing.T) { i18n := map[string]string{"ja_jp": "Yamada"} - got := pickName(i18n, "", core.BrandFeishu, "ou_x") + got := pickName(i18n, "", brand.Feishu, "ou_x") if got != "Yamada" { t.Errorf("got %q, want Yamada (fixed locale list fallback)", got) } @@ -85,14 +87,14 @@ func TestPickName_FixedLocaleList_HitJaJp(t *testing.T) { func TestPickName_DictOrderFallback(t *testing.T) { i18n := map[string]string{"xx_yy": "Foo", "aa_bb": "Bar"} - got := pickName(i18n, "", core.BrandFeishu, "ou_x") + got := pickName(i18n, "", brand.Feishu, "ou_x") if got != "Bar" { t.Errorf("got %q, want Bar (alphabetical tie-break, first non-empty is 'aa_bb')", got) } } func TestPickName_AllEmpty_FallsToOpenID(t *testing.T) { - got := pickName(map[string]string{}, "", core.BrandFeishu, "ou_x") + got := pickName(map[string]string{}, "", brand.Feishu, "ou_x") if got != "ou_x" { t.Errorf("got %q, want ou_x", got) } @@ -100,9 +102,9 @@ func TestPickName_AllEmpty_FallsToOpenID(t *testing.T) { func TestPickName_Determinism(t *testing.T) { i18n := map[string]string{"xx_yy": "Foo", "aa_bb": "Bar", "mm_nn": "Baz"} - first := pickName(i18n, "", core.BrandFeishu, "ou_x") + first := pickName(i18n, "", brand.Feishu, "ou_x") for i := 0; i < 50; i++ { - got := pickName(i18n, "", core.BrandFeishu, "ou_x") + got := pickName(i18n, "", brand.Feishu, "ou_x") if got != first { t.Fatalf("non-deterministic: iter %d got %q, expected %q (map iteration leaked)", i, got, first) } @@ -186,7 +188,7 @@ func TestRowFromItem_FullMapping(t *testing.T) { Description: "Coffee fanatic ☕", }, } - got := rowFromItem(item, "", core.BrandFeishu) + got := rowFromItem(item, "", brand.Feishu) if got.OpenID != "ou_a" { t.Errorf("OpenID: got %q, want ou_a", got.OpenID) @@ -228,7 +230,7 @@ func TestRowFromItem_FullMapping(t *testing.T) { func TestRowFromItem_HasChattedFalseWhenChatIDEmpty(t *testing.T) { item := &searchUserAPIItem{ID: "ou_a"} - got := rowFromItem(item, "", core.BrandFeishu) + got := rowFromItem(item, "", brand.Feishu) if got.HasChatted { t.Errorf("HasChatted: got true, want false") } @@ -244,7 +246,7 @@ func TestRowFromItem_CrossTenantEmptyEmailNoPanic(t *testing.T) { IsCrossTenant: true, }, } - got := rowFromItem(item, "", core.BrandFeishu) + got := rowFromItem(item, "", brand.Feishu) if got.Email != "" { t.Errorf("Email: expected empty, got %q", got.Email) } @@ -254,7 +256,7 @@ func TestRowFromItem_CrossTenantEmptyEmailNoPanic(t *testing.T) { } func TestProjectUsers_NilData(t *testing.T) { - users, hasMore := projectUsers(nil, "", core.BrandFeishu) + users, hasMore := projectUsers(nil, "", brand.Feishu) if users == nil { t.Fatalf("users should be an empty slice, not nil") } @@ -1023,7 +1025,7 @@ func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registr t.Helper() f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) cmd := newSearchUserTestCommand() - rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser) + rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, identity.AsUser) return rt, reg } diff --git a/shortcuts/doc/doc_media_test.go b/shortcuts/doc/doc_media_test.go index b7495dd832..e4a43f2385 100644 --- a/shortcuts/doc/doc_media_test.go +++ b/shortcuts/doc/doc_media_test.go @@ -16,16 +16,18 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) -func docsTestConfigWithAppID(appID string) *core.CliConfig { - return &core.CliConfig{ - AppID: appID, AppSecret: "test-secret", Brand: core.BrandFeishu, +func docsTestConfigWithAppID(appID string) *configpkg.CliConfig { + return &configpkg.CliConfig{ + AppID: appID, AppSecret: "test-secret", Brand: brand.Feishu, } } @@ -267,7 +269,7 @@ func TestUploadDocMediaFileWithContentUsesSinglePartUpload(t *testing.T) { &cobra.Command{Use: "docs +media-upload"}, docsTestConfigWithAppID("docs-upload-content-app"), f, - core.AsBot, + identity.AsBot, ) payload := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a} // PNG magic bytes @@ -328,7 +330,7 @@ func TestUploadDocMediaFileWithContentUsesMultipart(t *testing.T) { &cobra.Command{Use: "docs +media-upload"}, docsTestConfigWithAppID("docs-upload-content-multi"), f, - core.AsBot, + identity.AsBot, ) size := common.MaxDriveMediaUploadSinglePartSize + 1 diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go index f5002ebb13..c2f40f98d6 100644 --- a/shortcuts/doc/docs_create_test.go +++ b/shortcuts/doc/docs_create_test.go @@ -9,8 +9,9 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) @@ -301,15 +302,15 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) { // ── Helpers ── -func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { +func docsCreateTestConfig(t *testing.T, userOpenID string) *configpkg.CliConfig { t.Helper() replacer := strings.NewReplacer("/", "-", " ", "-") suffix := replacer.Replace(strings.ToLower(t.Name())) - return &core.CliConfig{ + return &configpkg.CliConfig{ AppID: "test-docs-create-" + suffix, AppSecret: "secret-docs-create-" + suffix, - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: userOpenID, } } diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 68d2750ce7..2b1bafdb92 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -14,7 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" @@ -102,7 +102,7 @@ func TestBuildFetchBodyUsesRuntimeConfigLang(t *testing.T) { t.Parallel() runtime := newFetchBodyTestRuntime(context.Background()) - runtime.Config = &core.CliConfig{Lang: "zh_cn"} + runtime.Config = &configpkg.CliConfig{Lang: "zh_cn"} body := buildFetchBody(runtime) if got := body["lang"]; got != "zh_cn" { @@ -114,7 +114,7 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) { t.Parallel() runtime := newFetchBodyTestRuntime(context.Background()) - runtime.Config = &core.CliConfig{Lang: "zh_cn"} + runtime.Config = &configpkg.CliConfig{Lang: "zh_cn"} if err := runtime.Cmd.Flags().Set("lang", ""); err != nil { t.Fatalf("set lang: %v", err) } diff --git a/shortcuts/drive/drive_create_folder_test.go b/shortcuts/drive/drive_create_folder_test.go index 974f2c573a..cd9134c0a0 100644 --- a/shortcuts/drive/drive_create_folder_test.go +++ b/shortcuts/drive/drive_create_folder_test.go @@ -12,8 +12,8 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" ) @@ -75,7 +75,7 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) { t.Fatalf("set --folder-token: %v", err) } - runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot) + runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, identity.AsBot) dry := DriveCreateFolder.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index 894d2f8643..4b2354c8bf 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -17,7 +17,6 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -608,7 +607,7 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext if fileName == "" { // Fall back to the server-provided download name when the caller did not // request an explicit local file name. - fileName = client.ResolveFilename(apiResp) + fileName = common.ResolveFilename(apiResp) } savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, apiResp.RawBody, overwrite) if err != nil { diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go index 4e71607936..361e39a168 100644 --- a/shortcuts/drive/drive_import_test.go +++ b/shortcuts/drive/drive_import_test.go @@ -12,9 +12,11 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" _ "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" ) @@ -95,7 +97,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) { t.Fatalf("set --folder-token: %v", err) } - runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot) + runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, identity.AsBot) dry := DriveImport.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") @@ -571,14 +573,14 @@ func driveImportMockEnv(t *testing.T, reg *httpmock.Registry, ticket string, pol } // driveImportTestConfig builds a CliConfig for the import fallback tests. -// The brand defaults to BrandFeishu when omitted; pass core.BrandLark to +// The brand defaults to BrandFeishu when omitted; pass brand.Lark to // exercise the larksuite.com branch of BuildResourceURL. -func driveImportTestConfig(suffix string, brands ...core.LarkBrand) *core.CliConfig { - brand := core.BrandFeishu +func driveImportTestConfig(suffix string, brands ...brand.Brand) *configpkg.CliConfig { + brand := brand.Feishu if len(brands) > 0 { brand = brands[0] } - return &core.CliConfig{ + return &configpkg.CliConfig{ AppID: "drive-import-fallback-" + suffix, AppSecret: "test-secret", Brand: brand, @@ -680,7 +682,7 @@ func TestDriveImportFallbackURLWhenServerURLIsWhitespace(t *testing.T) { } func TestDriveImportFallbackURLForLarkBrand(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("lark-brand", core.BrandLark)) + f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("lark-brand", brand.Lark)) driveImportMockEnv(t, reg, "ticket_lark", map[string]interface{}{ "token": "doxcn_imported", "type": "docx", diff --git a/shortcuts/drive/drive_inspect_test.go b/shortcuts/drive/drive_inspect_test.go index 1147a55bbf..46b8b6d838 100644 --- a/shortcuts/drive/drive_inspect_test.go +++ b/shortcuts/drive/drive_inspect_test.go @@ -14,7 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) @@ -26,7 +26,7 @@ func TestDriveInspectValidate_EmptyURL(t *testing.T) { cmd.Flags().String("url", "", "") cmd.Flags().String("type", "", "") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err == nil { t.Fatal("expected error for empty --url, got nil") @@ -39,7 +39,7 @@ func TestDriveInspectValidate_UnsupportedURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://google.com/some/page") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err == nil { t.Fatal("expected error for unsupported URL, got nil") @@ -52,7 +52,7 @@ func TestDriveInspectValidate_NonLarkHostWithLarkPath(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://google.com/docx/doxcnLooksValid") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err != nil { t.Fatalf("expected no error for non-Lark host with Lark-like path (host validation removed), got %v", err) @@ -65,7 +65,7 @@ func TestDriveInspectValidate_BareTokenWithoutType(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "doxcnBareToken") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err == nil { t.Fatal("expected error for bare token without --type, got nil") @@ -79,7 +79,7 @@ func TestDriveInspectValidate_BareTokenWithType(t *testing.T) { _ = cmd.Flags().Set("url", "doxcnBareToken") _ = cmd.Flags().Set("type", "docx") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -93,7 +93,7 @@ func TestDriveInspectValidate_URLTypeConflict(t *testing.T) { _ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnBareToken") _ = cmd.Flags().Set("type", "sheet") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err == nil { t.Fatal("expected error for conflicting --type, got nil") @@ -107,7 +107,7 @@ func TestDriveInspectValidate_BareTokenWithPathFragment(t *testing.T) { _ = cmd.Flags().Set("url", "doxcnBareToken/extra") _ = cmd.Flags().Set("type", "docx") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err == nil { t.Fatal("expected error for bare token with path fragment, got nil") @@ -120,7 +120,7 @@ func TestDriveInspectValidate_ValidDocxURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -133,7 +133,7 @@ func TestDriveInspectValidate_ValidWikiURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -146,7 +146,7 @@ func TestDriveInspectValidate_ValidDoubaoDriveFileURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -159,7 +159,7 @@ func TestDriveInspectValidate_ValidDoubaoChatDriveFolderURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://feishu.doubao.com/chat/drive/fldcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -172,7 +172,7 @@ func TestDriveInspectValidate_ValidDoubaoDriveShareFolderURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) err := DriveInspect.Validate(context.Background(), runtime) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -187,7 +187,7 @@ func TestDriveInspectDryRun_DocxURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) dry := DriveInspect.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") @@ -234,7 +234,7 @@ func TestDriveInspectDryRun_WikiURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) dry := DriveInspect.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") @@ -281,7 +281,7 @@ func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) { _ = cmd.Flags().Set("url", "doxcnBareToken") _ = cmd.Flags().Set("type", "docx") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) dry := DriveInspect.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") @@ -311,7 +311,7 @@ func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) dry := DriveInspect.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") @@ -349,7 +349,7 @@ func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) { cmd.Flags().String("type", "", "") _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC") - runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + runtime := common.TestNewRuntimeContext(cmd, &configpkg.CliConfig{}) dry := DriveInspect.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go index 39a1a929f8..517f2d7e7d 100644 --- a/shortcuts/drive/drive_io_test.go +++ b/shortcuts/drive/drive_io_test.go @@ -18,18 +18,20 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" ) var driveTaskCheckPollMu sync.Mutex -func driveTestConfig() *core.CliConfig { - return &core.CliConfig{ - AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +func driveTestConfig() *configpkg.CliConfig { + return &configpkg.CliConfig{ + AppID: "drive-test-app", AppSecret: "test-secret", Brand: brand.Feishu, } } @@ -76,8 +78,8 @@ func withDriveWorkingDir(t *testing.T, dir string) { func TestDriveUploadLargeFileUsesMultipart(t *testing.T) { // Use a distinct AppID to avoid Lark SDK global token cache collision with other tests. - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-test-app", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -154,8 +156,8 @@ func TestDriveUploadLargeFileUsesMultipart(t *testing.T) { } func TestDriveUploadLargeFileToWikiUsesMultipart(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-large-wiki-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-large-wiki-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -231,8 +233,8 @@ func TestDriveUploadLargeFileToWikiUsesMultipart(t *testing.T) { } func TestDriveUploadLargeFileOverwriteUsesMultipart(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-large-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-large-overwrite-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -301,8 +303,8 @@ func TestDriveUploadLargeFileOverwriteUsesMultipart(t *testing.T) { } func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinish(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-large-overwrite-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-large-overwrite-version-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -366,8 +368,8 @@ func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinish(t *testing. } func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinishAlias(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-large-overwrite-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-large-overwrite-data-version-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -431,8 +433,8 @@ func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinishAlias(t *tes } func TestDriveUploadSmallFile(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -470,8 +472,8 @@ func TestDriveUploadSmallFile(t *testing.T) { } func TestDriveUploadSmallFileOverwriteUsesFileToken(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-small-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-small-overwrite-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -516,8 +518,8 @@ func TestDriveUploadSmallFileOverwriteUsesFileToken(t *testing.T) { } func TestDriveUploadReturnsVersionFromDataVersionAlias(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-small-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-small-data-version-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -557,8 +559,8 @@ func TestDriveUploadReturnsVersionFromDataVersionAlias(t *testing.T) { } func TestDriveUploadSmallFileToWiki(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-small-wiki-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-small-wiki-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -607,8 +609,8 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) { } func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-explorer-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-explorer-meta-url", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -658,8 +660,8 @@ func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) { } func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-wiki-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-wiki-meta-url", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -706,8 +708,8 @@ func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) { } func TestDriveUploadSmallFileAPIError(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -742,8 +744,8 @@ func TestDriveUploadSmallFileAPIError(t *testing.T) { } func TestDriveUploadSmallFileNoToken(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-small-notoken", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-small-notoken", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -779,8 +781,8 @@ func TestDriveUploadSmallFileNoToken(t *testing.T) { } func TestDriveUploadSmallFileInvalidJSON(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-small-json", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-small-json", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -813,8 +815,8 @@ func TestDriveUploadSmallFileInvalidJSON(t *testing.T) { } func TestDriveUploadPrepareInvalidResponse(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-prepare-bad", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-prepare-bad", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -859,8 +861,8 @@ func TestDriveUploadPrepareInvalidResponse(t *testing.T) { } func TestDriveUploadPartAPIError(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-part-err", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-part-err", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -921,8 +923,8 @@ func TestDriveUploadPartAPIError(t *testing.T) { } func TestDriveUploadPartInvalidJSON(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-part-json", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-part-json", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -973,8 +975,8 @@ func TestDriveUploadPartInvalidJSON(t *testing.T) { } func TestDriveUploadFinishNoToken(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-finish-notoken", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-finish-notoken", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -1034,8 +1036,8 @@ func TestDriveUploadFinishNoToken(t *testing.T) { } func TestDriveUploadWithCustomName(t *testing.T) { - uploadTestConfig := &core.CliConfig{ - AppID: "drive-upload-name-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + uploadTestConfig := &configpkg.CliConfig{ + AppID: "drive-upload-name-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) @@ -1088,7 +1090,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) { t.Fatalf("set --wiki-token: %v", err) } - runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot) + runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, identity.AsBot) dry := DriveUpload.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") diff --git a/shortcuts/drive/drive_permission_grant_test.go b/shortcuts/drive/drive_permission_grant_test.go index 122b5aa109..0fdbd7f7b3 100644 --- a/shortcuts/drive/drive_permission_grant_test.go +++ b/shortcuts/drive/drive_permission_grant_test.go @@ -10,8 +10,9 @@ import ( "strings" "testing" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) @@ -244,15 +245,15 @@ func TestDriveUploadUserSkipsPermissionGrantAugmentation(t *testing.T) { } } -func drivePermissionGrantTestConfig(t *testing.T, userOpenID string) *core.CliConfig { +func drivePermissionGrantTestConfig(t *testing.T, userOpenID string) *configpkg.CliConfig { t.Helper() replacer := strings.NewReplacer("/", "-", " ", "-") suffix := replacer.Replace(strings.ToLower(t.Name())) - return &core.CliConfig{ + return &configpkg.CliConfig{ AppID: "drive-permission-test-" + suffix, AppSecret: "drive-permission-secret-" + suffix, - Brand: core.BrandFeishu, + Brand: brand.Feishu, UserOpenId: userOpenID, } } diff --git a/shortcuts/drive/drive_pull_test.go b/shortcuts/drive/drive_pull_test.go index 5502291b00..77a5d5f51c 100644 --- a/shortcuts/drive/drive_pull_test.go +++ b/shortcuts/drive/drive_pull_test.go @@ -16,8 +16,8 @@ import ( "time" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" @@ -363,7 +363,7 @@ func TestDrivePullShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing t.Fatalf("Chtimes: %v", err) } - runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, identity.AsBot) for _, tt := range []struct { name string @@ -399,7 +399,7 @@ func TestDrivePullShouldSkipSmartFallsBackWhenPathCannotBeResolved(t *testing.T) tmpDir := t.TempDir() withDriveWorkingDir(t, tmpDir) - runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, identity.AsBot) if got := drivePullShouldSkipSmart("../escape.txt", drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got { t.Fatal("drivePullShouldSkipSmart() = true, want false when ResolvePath rejects the target") @@ -414,7 +414,7 @@ func TestDrivePullShouldSkipSmartFallsBackWhenLocalFileDisappeared(t *testing.T) if err := os.MkdirAll("local", 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } - runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, identity.AsBot) if got := drivePullShouldSkipSmart(filepath.Join("local", "missing.txt"), drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got { t.Fatal("drivePullShouldSkipSmart() = true, want false when os.Stat cannot find the local file") diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go index 21c57e6781..f44c760808 100644 --- a/shortcuts/drive/drive_push_test.go +++ b/shortcuts/drive/drive_push_test.go @@ -16,10 +16,11 @@ import ( "testing" "time" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" @@ -1463,7 +1464,7 @@ func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) { t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String()) } - problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &core.CliConfig{}), drivePushLocalFile{ + problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &configpkg.CliConfig{}), drivePushLocalFile{ RelPath: "missing.txt", OpenPath: filepath.Join("local", "missing.txt"), FileName: "missing.txt", @@ -2099,8 +2100,8 @@ func TestDrivePushMirrorsEmptyDirectories(t *testing.T) { // tests (mirrors the existing TestDriveUploadLargeFileUsesMultipart // pattern). func TestDrivePushUploadsLargeFileViaMultipart(t *testing.T) { - pushTestConfig := &core.CliConfig{ - AppID: "drive-push-multipart-test", AppSecret: "test-secret", Brand: core.BrandFeishu, + pushTestConfig := &configpkg.CliConfig{ + AppID: "drive-push-multipart-test", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, pushTestConfig) diff --git a/shortcuts/drive/drive_sync_test.go b/shortcuts/drive/drive_sync_test.go index 70104a2f37..1080514b90 100644 --- a/shortcuts/drive/drive_sync_test.go +++ b/shortcuts/drive/drive_sync_test.go @@ -18,10 +18,11 @@ import ( "testing" "time" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" @@ -191,8 +192,8 @@ func (r *driveSyncReadThenError) Read(p []byte) (int, error) { // and modified files use --on-conflict=remote-wins (the default) to pull the // remote version. func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-remote-wins", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-remote-wins", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -312,8 +313,8 @@ func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) { } func TestDriveSyncAbortsAfterNewRemoteDownloadForbidden(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-forbidden", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-forbidden", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -379,8 +380,8 @@ func TestDriveSyncAbortsAfterNewRemoteDownloadForbidden(t *testing.T) { // TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins // pushes the local version over the remote file. func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-local-wins", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-local-wins", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -454,8 +455,8 @@ func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) { // --on-conflict=keep-both renames the local file with a hash suffix // and then downloads the remote version to the original path. func TestDriveSyncKeepBothRenamesLocalAndPullsRemote(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-keep-both", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-keep-both", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -556,8 +557,8 @@ func TestDriveSyncKeepBothRenamesLocalAndPullsRemote(t *testing.T) { // restores the original local path if the remote download fails after the // local file has been renamed. func TestDriveSyncKeepBothRollsBackRenameOnPullFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-keep-both-rollback", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-keep-both-rollback", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -626,8 +627,8 @@ func TestDriveSyncKeepBothRollsBackRenameOnPullFailure(t *testing.T) { // --on-conflict=ask fails before any sync writes start when stdin is not // available and the diff contains modified entries. func TestDriveSyncAskConflictFailsBeforeWritesWithoutStdin(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-ask-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-ask-eof", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -720,8 +721,8 @@ func TestDriveSyncFailsOnDuplicateRemoteFiles(t *testing.T) { // TestDriveSyncUsesResolvedDuplicateTargetForDiff verifies that +sync computes // the diff against the same duplicate-remote selection used during execution. func TestDriveSyncUsesResolvedDuplicateTargetForDiff(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-duplicate-resolution", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-duplicate-resolution", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -783,8 +784,8 @@ func TestDriveSyncUsesResolvedDuplicateTargetForDiff(t *testing.T) { // TestDriveSyncLocalWinsNestedFileUsesParentFolderToken verifies that local-wins // overwrites on nested files keep parent_node aligned with the file's parent. func TestDriveSyncLocalWinsNestedFileUsesParentFolderToken(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-local-wins-nested", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-local-wins-nested", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -870,8 +871,8 @@ func TestDriveSyncLocalWinsNestedFileUsesParentFolderToken(t *testing.T) { // during diff but removed before the push phase are surfaced as skipped items // instead of being silently dropped. func TestDriveSyncNewLocalDisappearanceIsReported(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-new-local-disappeared", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-new-local-disappeared", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -923,8 +924,8 @@ func TestDriveSyncNewLocalDisappearanceIsReported(t *testing.T) { // TestDriveSyncQuickModeUsesModifiedTime verifies that --quick mode // classifies files by modified_time instead of SHA-256 hash. func TestDriveSyncQuickModeUsesModifiedTime(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-quick", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-quick", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -1010,8 +1011,8 @@ func TestDriveSyncQuickModeUsesModifiedTime(t *testing.T) { // nature of --quick: a timestamp mismatch alone is enough to drive a real sync // action even when the file bytes are already identical. func TestDriveSyncQuickModeMTimeMismatchStillTriggersWrites(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-quick-mismatch", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-quick-mismatch", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -1073,8 +1074,8 @@ func TestDriveSyncQuickModeMTimeMismatchStillTriggersWrites(t *testing.T) { // TestDriveSyncNoChangesReportsEmptyItems verifies that when local and remote // are identical, +sync reports zero pulled/pushed items. func TestDriveSyncNoChangesReportsEmptyItems(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-no-changes", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-no-changes", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -1454,8 +1455,8 @@ func TestDriveSyncRollbackRenamedLocalSurfacesStatFailure(t *testing.T) { } func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-ask-exec-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-ask-exec-eof", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) f.IOStreams.In = strings.NewReader("") @@ -1520,8 +1521,8 @@ func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) { } func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-ask-plan-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-ask-plan-eof", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) f.IOStreams.In = strings.NewReader("") @@ -1618,8 +1619,8 @@ func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) { } func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig) f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil) @@ -1668,8 +1669,8 @@ func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) { } func TestDriveSyncAskConflictSkipReportsSkippedItem(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-ask-skip", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-ask-skip", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) f.IOStreams.In = strings.NewReader("skip\n") @@ -1724,8 +1725,8 @@ func TestDriveSyncAskConflictSkipReportsSkippedItem(t *testing.T) { } func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-new-remote-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-new-remote-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "d.txt"), err: fmt.Errorf("save failed")} @@ -1775,8 +1776,8 @@ func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) { } func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-new-local-ensure-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-new-local-ensure-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -1823,8 +1824,8 @@ func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) { } func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-new-local-upload-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-new-local-upload-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -1871,8 +1872,8 @@ func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) { } func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-local-wins-upload-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-local-wins-upload-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -1932,8 +1933,8 @@ func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) { } func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-keep-both-rename-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-keep-both-rename-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -2262,8 +2263,8 @@ func TestDriveSyncExecuteUnknownConflictStrategySkipsModifiedFile(t *testing.T) } func TestDriveSyncModifiedFileDisappearingBeforeExecuteIsSkipped(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-modified-disappears", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-modified-disappears", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) f.FileIOProvider = &deleteOnCloseProvider{ @@ -2322,8 +2323,8 @@ func TestDriveSyncModifiedFileDisappearingBeforeExecuteIsSkipped(t *testing.T) { } func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-remote-wins-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-remote-wins-pull-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "a.txt"), err: fmt.Errorf("save failed")} @@ -2377,8 +2378,8 @@ func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) { } func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-keep-both-rollback-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-keep-both-rollback-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -2467,8 +2468,8 @@ func TestDriveSyncStatusRemoteFilesUsesStableTokens(t *testing.T) { } func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-local-wins-parent-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-local-wins-parent-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -2531,8 +2532,8 @@ func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) { // whose rel_path is not in pullRemoteFiles (non-file types like docx, // shortcuts) are silently skipped rather than causing a panic or error. func TestDriveSyncSkipsNonFileRemoteEntries(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-skip-nonfile", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-skip-nonfile", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -2698,8 +2699,8 @@ func TestDriveSyncKeepBothReportsSuffixError(t *testing.T) { // file has been renamed, the rollback restores the original file and // the failure is reported via the partial-failure signal. func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "a.txt"), err: fmt.Errorf("save failed")} @@ -2774,8 +2775,8 @@ func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) { // when remoteFile.FileToken is empty in the local-wins branch, the code // falls back to remoteEntriesForPush to find the existing token. func TestDriveSyncLocalWinsFallbackToRemoteEntriesForPush(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-local-wins-fallback", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-local-wins-fallback", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -2851,8 +2852,8 @@ func TestDriveSyncLocalWinsFallbackToRemoteEntriesForPush(t *testing.T) { // TestDriveSyncCreatesEmptyLocalDirectoriesOnDrive verifies that empty local // directories are created on Drive during +sync, mirroring +push behavior. func TestDriveSyncCreatesEmptyLocalDirectoriesOnDrive(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-empty-dirs", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-empty-dirs", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -2909,8 +2910,8 @@ func TestDriveSyncCreatesEmptyLocalDirectoriesOnDrive(t *testing.T) { // file_token returned alongside error), the reported item uses the // freshly returned token rather than the stale existingToken. func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-local-wins-partial-token", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-local-wins-partial-token", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -2983,8 +2984,8 @@ func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) { // docx, shortcut, etc.) instead of silently attempting to upload and leaving // the remote in a broken mixed-type state. func TestDriveSyncRejectsPathTypeConflict(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-type-conflict", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-type-conflict", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) @@ -3034,8 +3035,8 @@ func TestDriveSyncRejectsPathTypeConflict(t *testing.T) { // which would otherwise attempt create_folder and leave the remote in a // broken mixed-type state. func TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict(t *testing.T) { - syncTestConfig := &core.CliConfig{ - AppID: "drive-sync-dir-vs-file-conflict", AppSecret: "test-secret", Brand: core.BrandFeishu, + syncTestConfig := &configpkg.CliConfig{ + AppID: "drive-sync-dir-vs-file-conflict", AppSecret: "test-secret", Brand: brand.Feishu, } f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) diff --git a/shortcuts/drive/drive_task_result.go b/shortcuts/drive/drive_task_result.go index 2ca70f3cd0..7537bb42f5 100644 --- a/shortcuts/drive/drive_task_result.go +++ b/shortcuts/drive/drive_task_result.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -242,7 +241,7 @@ func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]i } func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeContext, scenario string) error { - result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID)) + scopes, ok, err := runtime.ResolveTokenScopes(ctx) if err != nil { // Propagate cancellation/timeout so callers stop instead of falling through // to the API call. Other token errors are non-fatal here: the API call will @@ -252,7 +251,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC } return nil } - if result == nil || result.Scopes == "" { + if !ok { return nil } @@ -264,7 +263,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC required = []string{"wiki:space:read"} } - return requireDriveScopes(result.Scopes, required) + return requireDriveScopes(scopes, required) } func requireDriveScopes(storedScopes string, required []string) error { diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go index 99a4ef7c2c..16a6b7e8ea 100644 --- a/shortcuts/drive/drive_task_result_test.go +++ b/shortcuts/drive/drive_task_result_test.go @@ -15,9 +15,9 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -324,7 +324,7 @@ func (m *mockDriveTaskResultTokenResolver) ResolveToken(ctx context.Context, req return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil } -func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) *common.RuntimeContext { +func newDriveTaskResultRuntimeWithScopes(t *testing.T, as identity.Identity, scopes string) *common.RuntimeContext { t.Helper() cfg := driveTestConfig() @@ -603,7 +603,7 @@ func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T for _, scenario := range []string{"wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node"} { t.Run(scenario+"/rejects missing scope", func(t *testing.T) { t.Parallel() - runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly") + runtime := newDriveTaskResultRuntimeWithScopes(t, identity.AsUser, "drive:drive.metadata:readonly") err := validateDriveTaskResultScopes(context.Background(), runtime, scenario) if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") { t.Fatalf("expected missing wiki scope error, got %v", err) @@ -621,7 +621,7 @@ func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T }) t.Run(scenario+"/accepts wiki scope", func(t *testing.T) { t.Parallel() - runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read") + runtime := newDriveTaskResultRuntimeWithScopes(t, identity.AsUser, "wiki:space:read") err := validateDriveTaskResultScopes(context.Background(), runtime, scenario) if err != nil { t.Fatalf("validateDriveTaskResultScopes() error = %v", err) @@ -810,7 +810,7 @@ func TestDriveTaskResultRejectsUnknownScenarioListsWikiDeleteNode(t *testing.T) func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) { t.Parallel() - runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read") + runtime := newDriveTaskResultRuntimeWithScopes(t, identity.AsUser, "wiki:space:read") err := validateDriveTaskResultScopes(context.Background(), runtime, "import") if err == nil || !strings.Contains(err.Error(), "missing required scope(s): drive:drive.metadata:readonly") { t.Fatalf("expected missing drive scope error, got %v", err) @@ -921,7 +921,7 @@ func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T factory, _, _, _ := cmdutil.TestFactory(t, cfg) factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil) - runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser) + runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, identity.AsUser) runtime.Factory = factory err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move") diff --git a/shortcuts/event/pipeline.go b/shortcuts/event/pipeline.go index 0b9b4caf51..4d960d740c 100644 --- a/shortcuts/event/pipeline.go +++ b/shortcuts/event/pipeline.go @@ -17,7 +17,7 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/shortcuts/common" larkevent "github.com/larksuite/oapi-sdk-go/v3/event" ) @@ -62,13 +62,13 @@ func NewEventPipeline( // EnsureDirs creates all configured output directories once at startup. func (p *EventPipeline) EnsureDirs() error { if p.config.OutputDir != "" { - if err := vfs.MkdirAll(p.config.OutputDir, 0700); err != nil { + if err := common.EnsureOutputDir(p.config.OutputDir); err != nil { return eventFileIOError(err, "create output dir") } } if p.config.Router != nil { for _, route := range p.config.Router.routes { - if err := vfs.MkdirAll(route.dir, 0700); err != nil { + if err := common.EnsureOutputDir(route.dir); err != nil { return eventFileIOError(err, "create route dir %s", route.dir) } } diff --git a/shortcuts/event/processor_test.go b/shortcuts/event/processor_test.go index 8ec95a6f8c..2f62511390 100644 --- a/shortcuts/event/processor_test.go +++ b/shortcuts/event/processor_test.go @@ -16,9 +16,10 @@ import ( "testing" "time" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/shortcuts/common" larkevent "github.com/larksuite/oapi-sdk-go/v3/event" @@ -121,10 +122,10 @@ func newSubscribeTestRuntime(t *testing.T) *common.RuntimeContext { return &common.RuntimeContext{ Cmd: cmd, - Config: &core.CliConfig{ + Config: &configpkg.CliConfig{ AppID: "cli_event_test", AppSecret: "secret", - Brand: core.BrandFeishu, + Brand: brand.Feishu, }, Factory: &cmdutil.Factory{ IOStreams: cmdutil.NewIOStreams(strings.NewReader(""), &out, &errOut), diff --git a/shortcuts/event/subscribe.go b/shortcuts/event/subscribe.go index 8a36da2b8d..a122fbd549 100644 --- a/shortcuts/event/subscribe.go +++ b/shortcuts/event/subscribe.go @@ -14,8 +14,8 @@ import ( "strings" "syscall" + "github.com/larksuite/cli/brand" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -246,7 +246,7 @@ var EventSubscribe = common.Shortcut{ } // --- WebSocket --- - domain := core.ResolveEndpoints(runtime.Config.Brand).Open + domain := brand.ResolveEndpoints(runtime.Config.Brand).Open info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset)) if eventTypeFilter != nil { diff --git a/shortcuts/event/subscribe_test.go b/shortcuts/event/subscribe_test.go index cf8f0dce21..ce0867a549 100644 --- a/shortcuts/event/subscribe_test.go +++ b/shortcuts/event/subscribe_test.go @@ -6,17 +6,17 @@ package event import ( "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" lark "github.com/larksuite/oapi-sdk-go/v3" ) // The resolver's Open host must equal the SDK's per-brand WS base URL; // fails if the SDK constants ever drift from the resolver. func TestWSDomainMatchesResolver(t *testing.T) { - if got, want := core.ResolveEndpoints(core.BrandFeishu).Open, lark.FeishuBaseUrl; got != want { + if got, want := brand.ResolveEndpoints(brand.Feishu).Open, lark.FeishuBaseUrl; got != want { t.Errorf("feishu WS domain = %q, want SDK %q", got, want) } - if got, want := core.ResolveEndpoints(core.BrandLark).Open, lark.LarkBaseUrl; got != want { + if got, want := brand.ResolveEndpoints(brand.Lark).Open, lark.LarkBaseUrl; got != want { t.Errorf("lark WS domain = %q, want SDK %q", got, want) } } diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index 8f15ede214..97820be105 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identity" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -646,7 +646,7 @@ func TestShortcutValidateBranches(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "user-id": "ou_123", }, nil) - setRuntimeField(t, runtime, "resolvedAs", core.AsBot) + setRuntimeField(t, runtime, "resolvedAs", identity.AsBot) err := ImChatMessageList.Validate(context.Background(), runtime) if err == nil || !strings.Contains(err.Error(), "requires user identity") { t.Fatalf("ImChatMessageList.Validate() error = %v, want requires user identity", err) diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 1292b7d33c..37c7431414 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -5,22 +5,17 @@ package convertlib import ( "encoding/json" - "fmt" "math" "net/url" "reflect" "strconv" "strings" - "github.com/larksuite/cli/internal/core" + brandpkg "github.com/larksuite/cli/brand" + "github.com/larksuite/cli/internal/imcontent" "github.com/larksuite/cli/shortcuts/common" ) -// ContentConverter defines the interface for converting a message type's raw content to human-readable text. -type ContentConverter interface { - Convert(ctx *ConvertContext) string -} - // ConvertContext holds all context needed for content conversion. type ConvertContext struct { RawContent string @@ -45,45 +40,12 @@ type ConvertContext struct { MergeForwardSubItems map[string][]map[string]interface{} } -// converters maps message types to their ContentConverter implementations. -var converters map[string]ContentConverter - -func init() { - converters = map[string]ContentConverter{ - "text": textConverter{}, - "post": postConverter{}, - "image": imageConverter{}, - "file": fileConverter{}, - "audio": audioMsgConverter{}, - "video": videoMsgConverter{}, - "media": videoMsgConverter{}, - "sticker": stickerConverter{}, - "interactive": interactiveConverter{}, - "share_chat": shareChatConverter{}, - "share_user": shareUserConverter{}, - "location": locationConverter{}, - "merge_forward": mergeForwardConverter{}, - "folder": folderConverter{}, - "share_calendar_event": calendarEventConverter{}, - "calendar": calendarInviteConverter{}, - "general_calendar": generalCalendarConverter{}, - "video_chat": videoChatConverter{}, - "system": systemConverter{}, - "todo": todoConverter{}, - "vote": voteConverter{}, - "hongbao": hongbaoConverter{}, - } -} - // ConvertBodyContent converts body.content (a raw JSON string) to human-readable text. func ConvertBodyContent(msgType string, ctx *ConvertContext) string { - if ctx.RawContent == "" { - return "" - } - if c, ok := converters[msgType]; ok { - return c.Convert(ctx) + if msgType == "merge_forward" { + return (mergeForwardConverter{}).Convert(ctx) } - return fmt.Sprintf("[%s]", msgType) + return imcontent.ConvertBodyContent(msgType, pureConvertContext(ctx)) } // FormatEventMessage converts an event-pushed message to a human-readable map. @@ -260,7 +222,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, return msg } -func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) string { +func assembleMessageAppLink(m map[string]interface{}, brand brandpkg.Brand) string { domain := resolveAppLinkDomain(brand) if domain == "" { return "" @@ -395,8 +357,8 @@ func normalizeMessagePosition(v interface{}) (string, bool) { } } -func resolveAppLinkDomain(brand core.LarkBrand) string { - appLink := core.ResolveEndpoints(brand).AppLink +func resolveAppLinkDomain(brand brandpkg.Brand) string { + appLink := brandpkg.ResolveEndpoints(brand).AppLink u, err := url.Parse(appLink) if err != nil { return "" diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go index b6b2be25db..2660ddf7f8 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -9,7 +9,8 @@ import ( "net/url" "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/brand" + configpkg "github.com/larksuite/cli/internal/config" "github.com/larksuite/cli/shortcuts/common" ) @@ -151,19 +152,19 @@ func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) { } func TestResolveAppLinkDomain(t *testing.T) { - if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" { + if got := resolveAppLinkDomain(brand.Feishu); got != "applink.feishu.cn" { t.Fatalf("resolveAppLinkDomain(feishu) = %q", got) } - if got := resolveAppLinkDomain(core.BrandLark); got != "applink.larksuite.com" { + if got := resolveAppLinkDomain(brand.Lark); got != "applink.larksuite.com" { t.Fatalf("resolveAppLinkDomain(lark) = %q", got) } - if got := resolveAppLinkDomain(core.LarkBrand("other")); got != "applink.feishu.cn" { + if got := resolveAppLinkDomain(brand.Brand("other")); got != "applink.feishu.cn" { t.Fatalf("resolveAppLinkDomain(other) = %q, want feishu", got) } } func TestFormatMessageItem_MessageAppLink_PassThrough(t *testing.T) { - runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + runtime := &common.RuntimeContext{Config: &configpkg.CliConfig{Brand: brand.Feishu}} raw := map[string]interface{}{ "msg_type": "text", "message_id": "om_123", @@ -181,7 +182,7 @@ func TestFormatMessageItem_MessageAppLink_PassThrough(t *testing.T) { } func TestFormatMessageItem_MessageAppLink_AssembleChat(t *testing.T) { - runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + runtime := &common.RuntimeContext{Config: &configpkg.CliConfig{Brand: brand.Feishu}} raw := map[string]interface{}{ "msg_type": "text", "message_id": "om_123", @@ -199,7 +200,7 @@ func TestFormatMessageItem_MessageAppLink_AssembleChat(t *testing.T) { } func TestFormatMessageItem_MessageAppLink_AssembleThread(t *testing.T) { - runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandLark}} + runtime := &common.RuntimeContext{Config: &configpkg.CliConfig{Brand: brand.Lark}} raw := map[string]interface{}{ "msg_type": "text", "message_id": "om_123", @@ -222,7 +223,7 @@ func TestFormatMessageItem_MessageAppLink_AssembleThread(t *testing.T) { } func TestFormatMessageItem_MessageAppLink_FallbackToChatWhenThreadPositionInvalid(t *testing.T) { - runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + runtime := &common.RuntimeContext{Config: &configpkg.CliConfig{Brand: brand.Feishu}} raw := map[string]interface{}{ "msg_type": "text", "message_id": "om_123", @@ -242,7 +243,7 @@ func TestFormatMessageItem_MessageAppLink_FallbackToChatWhenThreadPositionInvali } func TestFormatMessageItem_MessageAppLink_BrandUnknownDefaultsToFeishu(t *testing.T) { - runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.LarkBrand("other")}} + runtime := &common.RuntimeContext{Config: &configpkg.CliConfig{Brand: brand.Brand("other")}} raw := map[string]interface{}{ "msg_type": "text", "message_id": "om_123", @@ -356,7 +357,7 @@ func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { "chat_id": "oc_1+2/3", "message_position": 12, } - gotChat := assembleMessageAppLink(chat, core.BrandFeishu) + gotChat := assembleMessageAppLink(chat, brand.Feishu) assertURLHasQuery(t, gotChat, "applink.feishu.cn", "/client/chat/open", map[string]string{ "openChatId": "oc_1+2/3", "position": "12", @@ -368,7 +369,7 @@ func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { "thread_id": "omt_1+2/3", "thread_message_position": -1, } - gotThread := assembleMessageAppLink(thread, core.BrandFeishu) + gotThread := assembleMessageAppLink(thread, brand.Feishu) assertURLHasQuery(t, gotThread, "applink.feishu.cn", "/client/thread/open", map[string]string{ "open_thread_id": "omt_1+2/3", "open_chat_id": "oc_1+2/3", @@ -379,7 +380,7 @@ func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) { } func TestFormatMessageItem_MessageAppLink_NonStringDoesNotLeakNull(t *testing.T) { - runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + runtime := &common.RuntimeContext{Config: &configpkg.CliConfig{Brand: brand.Feishu}} raw := map[string]interface{}{ "msg_type": "text", "message_id": "om_123", @@ -415,7 +416,7 @@ func TestFormatMessageItem_MessageAppLink_RuntimeNilNoAssemble(t *testing.T) { } func TestFormatMessageItem_MessageAppLink_MissingFieldsNoPanic(t *testing.T) { - runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}} + runtime := &common.RuntimeContext{Config: &configpkg.CliConfig{Brand: brand.Feishu}} raw := map[string]interface{}{ "msg_type": "text", "message_id": "om_123", @@ -464,26 +465,26 @@ func TestExtractMentionOpenIdAndTruncateContent(t *testing.T) { } func TestMediaConverters(t *testing.T) { - if got := (imageConverter{}).Convert(&ConvertContext{RawContent: `{"image_key":"img_1"}`}); got != "[Image: img_1]" { - t.Fatalf("imageConverter.Convert() = %q", got) + if got := convertPureForTest("image", `{"image_key":"img_1"}`); got != "[Image: img_1]" { + t.Fatalf("ConvertBodyContent(image) = %q", got) } - if got := (imageConverter{}).Convert(&ConvertContext{RawContent: `{invalid`}); got != "[Invalid image JSON]" { - t.Fatalf("imageConverter.Convert(invalid) = %q", got) + if got := convertPureForTest("image", `{invalid`); got != "[Invalid image JSON]" { + t.Fatalf("ConvertBodyContent(image invalid) = %q", got) } - if got := (fileConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_1","file_name":"demo.pdf"}`}); got != `` { - t.Fatalf("fileConverter.Convert() = %q", got) + if got := convertPureForTest("file", `{"file_key":"file_1","file_name":"demo.pdf"}`); got != `` { + t.Fatalf("ConvertBodyContent(file) = %q", got) } - if got := (fileConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_\"1","file_name":"demo\\\".pdf"}`}); got != `` { - t.Fatalf("fileConverter.Convert(escaped) = %q", got) + if got := convertPureForTest("file", `{"file_key":"file_\"1","file_name":"demo\\\".pdf"}`); got != `` { + t.Fatalf("ConvertBodyContent(file escaped) = %q", got) } - if got := (audioMsgConverter{}).Convert(&ConvertContext{RawContent: `{"duration":3500}`}); got != "[Voice: 4s]" { - t.Fatalf("audioMsgConverter.Convert() = %q", got) + if got := convertPureForTest("audio", `{"duration":3500}`); got != "[Voice: 4s]" { + t.Fatalf("ConvertBodyContent(audio) = %q", got) } - if got := (videoMsgConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_2","file_name":"clip.mp4","duration":5000,"image_key":"img_cover"}`}); got != `