From ee27d0cdc2a4c185fd2e9ee7bb11b0ebcfca4bd1 Mon Sep 17 00:00:00 2001 From: shanglei Date: Tue, 28 Jul 2026 12:15:26 +0800 Subject: [PATCH] refactor(core): extract workspace paths --- cmd/auth/list_test.go | 8 +++---- cmd/auth/login_scope_cache.go | 4 ++-- cmd/config/bind.go | 23 ++++++++++--------- cmd/config/bind_test.go | 13 ++++++----- cmd/config/init.go | 7 +++--- cmd/config/show.go | 5 ++-- cmd/doctor/doctor.go | 3 ++- cmd/event/bus.go | 4 ++-- cmd/platform_bootstrap.go | 6 ++--- cmd/root_upgrade_test.go | 8 +++---- internal/auth/uat_client.go | 3 ++- internal/authlog/authlog.go | 2 +- internal/cmdutil/factory_default.go | 7 +++--- internal/core/config.go | 21 ++++------------- internal/core/notconfigured.go | 11 +++++---- internal/core/notconfigured_test.go | 21 +++++++++-------- internal/event/bus/bus.go | 4 ++-- internal/event/busdiscover/busdiscover.go | 4 ++-- internal/event/consume/startup.go | 8 +++---- internal/event/transport/transport_unix.go | 4 ++-- internal/lockfile/lockfile.go | 4 ++-- internal/registry/remote.go | 4 ++-- internal/security/contentsafety/provider.go | 4 ++-- internal/skillscheck/state.go | 6 ++--- internal/sparkstore/storage.go | 4 ++-- internal/transport/config.go | 6 ++--- internal/update/update.go | 6 ++--- internal/{core => workspace}/workspace.go | 15 +++++++++++- .../{core => workspace}/workspace_test.go | 2 +- shortcuts/apps/git_credential_test.go | 7 +++--- shortcuts/apps/gitcred/lock.go | 4 ++-- shortcuts/apps/gitcred/store.go | 4 ++-- 32 files changed, 121 insertions(+), 111 deletions(-) rename internal/{core => workspace}/workspace.go (92%) rename internal/{core => workspace}/workspace_test.go (99%) diff --git a/cmd/auth/list_test.go b/cmd/auth/list_test.go index 070e4fae1..3b6581db4 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_scope_cache.go b/cmd/auth/login_scope_cache.go index ad8036bda..234413c55 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/config/bind.go b/cmd/config/bind.go index 67916b17e..67be91939 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -20,6 +20,7 @@ import ( "github.com/larksuite/cli/internal/output" "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 +129,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 +187,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" } @@ -407,7 +408,7 @@ func priorLang(previousConfigBytes []byte) i18n.Lang { func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error { multi := &core.MultiAppConfig{Apps: []core.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, "", " ") @@ -503,13 +504,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 diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 7f0c01803..5120fac4c 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/workspace" ) // wantErrDetail is the normalized comparison shape for a typed error's wire @@ -81,8 +82,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) ── @@ -640,7 +641,7 @@ 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) + workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel) multi, err := core.LoadMultiAppConfig() if err != nil { t.Fatalf("load workspace config: %v", err) @@ -687,7 +688,7 @@ 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) + workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel) multi, err := core.LoadMultiAppConfig() if err != nil { t.Fatalf("load workspace config: %v", err) @@ -802,7 +803,7 @@ 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{{ @@ -828,7 +829,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}) diff --git a/cmd/config/init.go b/cmd/config/init.go index 5926ba2cb..e4250f521 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -21,6 +21,7 @@ import ( "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/workspace" ) // ConfigInitOptions holds all inputs for config init. @@ -122,7 +123,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 } @@ -346,7 +347,7 @@ func configInitRun(opts *ConfigInitOptions) error { 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 { @@ -521,7 +522,7 @@ func configInitRun(opts *ConfigInitOptions) error { 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/show.go b/cmd/config/show.go index 5526f0254..67f8e22c7 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/workspace" "github.com/spf13/cobra" ) @@ -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/doctor/doctor.go b/cmd/doctor/doctor.go index 7017fe9f2..a960ce939 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -23,6 +23,7 @@ import ( "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. @@ -240,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/event/bus.go b/cmd/event/bus.go index 61d2d3c0e..e5db061e5 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/platform_bootstrap.go b/cmd/platform_bootstrap.go index 9b1b57ac3..e8f3209be 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/root_upgrade_test.go b/cmd/root_upgrade_test.go index bc28b858f..a085706bc 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/internal/auth/uat_client.go b/internal/auth/uat_client.go index e7bff5a7e..d59f2a7ad 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -23,6 +23,7 @@ import ( "github.com/larksuite/cli/internal/core" "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._-]`) @@ -129,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 { diff --git a/internal/authlog/authlog.go b/internal/authlog/authlog.go index 04c42fa0e..d18d6d410 100644 --- a/internal/authlog/authlog.go +++ b/internal/authlog/authlog.go @@ -114,7 +114,7 @@ func Shared() *Logger { func defaultRuntimeDir() string { if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { - // Deliberately unvalidated, mirroring core.GetBaseConfigDir. Routing this + // 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 diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 04c318b8c..21aa8bec6 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -28,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. @@ -48,14 +49,14 @@ 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) // 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: core.GetRuntimeDir})) + authlog.SetShared(authlog.New(authlog.Options{RuntimeDir: workspace.GetRuntimeDir})) // Phase 0: FileIO provider (no dependency) f.FileIOProvider = fileio.GetProvider() diff --git a/internal/core/config.go b/internal/core/config.go index 2f1117e90..03858b7cc 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "path/filepath" "strings" "unicode/utf8" @@ -17,6 +16,7 @@ import ( "github.com/larksuite/cli/internal/keychain" "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. @@ -182,19 +182,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 @@ -203,7 +190,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 } @@ -220,7 +207,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 } @@ -228,7 +215,7 @@ func SaveMultiAppConfig(config *MultiAppConfig) error { if err != nil { return err } - return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600) + return validate.AtomicWrite(workspace.GetConfigPath(), append(data, '\n'), 0600) } // RequireConfig loads the single-app config using the default profile resolution. diff --git a/internal/core/notconfigured.go b/internal/core/notconfigured.go index 068ea86cf..f68d0cba4 100644 --- a/internal/core/notconfigured.go +++ b/internal/core/notconfigured.go @@ -8,6 +8,7 @@ import ( "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/core/notconfigured_test.go index d546fbe4a..d88074fc5 100644 --- a/internal/core/notconfigured_test.go +++ b/internal/core/notconfigured_test.go @@ -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/event/bus/bus.go b/internal/event/bus/bus.go index 849285694..1230baa6c 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 53e67cf93..5c5067572 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 890e1c772..35d11691f 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 86dec4f4b..470eded50 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/lockfile/lockfile.go b/internal/lockfile/lockfile.go index 6f64298d0..2e46f1132 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/registry/remote.go b/internal/registry/remote.go index f73d71ac1..948f2c20a 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -17,11 +17,11 @@ import ( "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 ( @@ -96,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/security/contentsafety/provider.go b/internal/security/contentsafety/provider.go index 0ec3d7699..29de179d2 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/skillscheck/state.go b/internal/skillscheck/state.go index eddab1cf3..9628ce04b 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/storage.go b/internal/sparkstore/storage.go index 4223ded95..73e7a2abd 100644 --- a/internal/sparkstore/storage.go +++ b/internal/sparkstore/storage.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" + "github.com/larksuite/cli/internal/workspace" ) // storageRoot is the per-domain local-storage directory name under the config dir. @@ -47,7 +47,7 @@ func appKeyPath(appID, key string) string { // Root returns the Spark application storage root under the CLI config directory. func Root() string { - return filepath.Join(core.GetConfigDir(), storageRoot) + return filepath.Join(workspace.GetConfigDir(), storageRoot) } // Path returns the storage path for one application key. diff --git a/internal/transport/config.go b/internal/transport/config.go index 4fe64ddcf..fcaeaf011 100644 --- a/internal/transport/config.go +++ b/internal/transport/config.go @@ -23,13 +23,13 @@ import ( "strings" "sync" - "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. diff --git a/internal/update/update.go b/internal/update/update.go index 7bdc61d9c..169430f5b 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 181a6bce1..f0b0b8f51 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 846fbb750..c1555ecb4 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/shortcuts/apps/git_credential_test.go b/shortcuts/apps/git_credential_test.go index 4fa1a210b..7f7559ba6 100644 --- a/shortcuts/apps/git_credential_test.go +++ b/shortcuts/apps/git_credential_test.go @@ -28,6 +28,7 @@ import ( "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" ) @@ -533,7 +534,7 @@ func TestGitCredentialAppStorageListAppIDsSkipsNonCredentialAppDirs(t *testing.T 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) } @@ -559,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) } @@ -600,7 +601,7 @@ 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) } diff --git a/shortcuts/apps/gitcred/lock.go b/shortcuts/apps/gitcred/lock.go index 8723e82d5..85f4c5a18 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 6b1988a9b..215437cc1 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 {