Compare commits

..

1 Commits

Author SHA1 Message Date
liangshuo-1
b1205b68d2 chore: release v1.0.68 (#1842) 2026-07-09 21:43:39 +08:00
24 changed files with 98 additions and 395 deletions

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file.
## [v1.0.68] - 2026-07-09
### Features
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
- **slides**: add slides chart demo reference
### Bug Fixes
- register and consume --json shorthand for custom-format shortcuts (#1737)
- **drive**: abort push on parent sibling limit (#1813)
### Documentation
- require native charts in slide planning
- register knowledge organize workflow (#1828)
## [v1.0.67] - 2026-07-08
### Features
@@ -1421,6 +1438,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65

View File

@@ -916,6 +916,25 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
}
}
func TestNormalizeBrand(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "feishu"},
{"feishu", "feishu"},
{"lark", "lark"},
{"LARK", "lark"},
{" lark ", "lark"},
{"Lark", "lark"},
}
for _, tt := range tests {
if got := normalizeBrand(tt.input); got != tt.want {
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
custom := filepath.Join(t.TempDir(), "custom.json")

View File

@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.ParseBrand(selected.Brand),
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
}, nil
}
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
}, nil
}
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
}, nil
}
@@ -350,6 +350,16 @@ func sourceDisplayName(source string) string {
}
}
// normalizeBrand applies .strip().lower() and defaults to "feishu".
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
func normalizeBrand(raw string) string {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
return "feishu"
}
return s
}
// resolveHermesEnvPath returns the path to Hermes's .env file.
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
//

View File

@@ -146,20 +146,6 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil
}
// retryBrand decides whether app-registration polling must be retried on a
// different brand. When the secret was not issued and the tenant's real brand
// differs from the brand we polled, it returns the tenant's actual brand and true.
func retryBrand(polled core.LarkBrand, tenantBrand string) (core.LarkBrand, bool) {
if tenantBrand == "" {
return polled, false
}
actual := core.ParseBrand(tenantBrand)
if actual == polled {
return polled, false
}
return actual, true
}
// 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) {
@@ -222,19 +208,18 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
}
result, err := larkauth.PollAppRegistration(ctx, httpClient, larkBrand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
}
// Step 4: Retry on the tenant's actual brand if the secret was not issued on
// the polled brand (e.g. configured feishu but the tenant is a lark tenant).
if result.ClientSecret == "" && result.UserInfo != nil {
if rb, ok := retryBrand(larkBrand, result.UserInfo.TenantBrand); ok {
result, err = larkauth.PollAppRegistration(ctx, httpClient, rb, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "brand endpoint retry failed: %v", err).WithCause(err)
}
// Step 4: Handle Lark brand special case
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
}
}
@@ -242,12 +227,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
// Determine the final brand from the tenant's reported brand, normalized the
// same way as the retry decision (core.ParseBrand) so the saved brand always
// matches the endpoint the credentials were issued on.
// Determine final brand from response
finalBrand := larkBrand
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" {
finalBrand = core.ParseBrand(result.UserInfo.TenantBrand)
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
finalBrand = core.BrandLark
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
finalBrand = core.BrandFeishu
}
fmt.Fprintln(f.IOStreams.ErrOut)

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestRetryBrand(t *testing.T) {
cases := []struct {
polled core.LarkBrand
tenant string
wantBrand core.LarkBrand
wantRetry bool
}{
{core.BrandFeishu, "lark", core.BrandLark, true},
{core.BrandFeishu, "feishu", core.BrandFeishu, false},
{core.BrandFeishu, "", core.BrandFeishu, false},
{core.BrandLark, "feishu", core.BrandFeishu, true},
{core.BrandLark, "lark", core.BrandLark, false},
}
for _, c := range cases {
gotB, gotR := retryBrand(c.polled, c.tenant)
if gotR != c.wantRetry || gotB != c.wantBrand {
t.Errorf("retryBrand(%q,%q) = (%q,%v), want (%q,%v)", c.polled, c.tenant, gotB, gotR, c.wantBrand, c.wantRetry)
}
}
}

View File

@@ -13,7 +13,6 @@ import (
"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/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
@@ -126,10 +125,6 @@ func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
updater.Brand = core.BrandFeishu
if cfg, err := opts.Factory.Config(); err == nil && cfg != nil {
updater.Brand = cfg.Brand
}
if !opts.Check {
updater.CleanupStaleFiles()

View File

@@ -9,7 +9,6 @@ import (
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -42,7 +41,10 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {

View File

@@ -16,7 +16,6 @@ import (
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
@@ -59,7 +58,10 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,

View File

@@ -39,13 +39,6 @@ type AppRegUserInfo struct {
TenantBrand string // "feishu" or "lark"
}
// appRegistrationEndpoint returns the accounts-domain registration endpoint for
// the given brand. Both the begin and poll actions target the same brand so the
// device_code issuer and poller domains always match.
func appRegistrationEndpoint(brand core.LarkBrand) string {
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
}
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
@@ -53,7 +46,8 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
}
ep := core.ResolveEndpoints(brand)
endpoint := appRegistrationEndpoint(brand)
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + PathAppRegistration
form := url.Values{}
form.Set("action", "begin")
@@ -135,7 +129,8 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
const maxPollInterval = 60
const maxPollAttempts = 200
endpoint := appRegistrationEndpoint(brand)
ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + PathAppRegistration
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0

View File

@@ -6,7 +6,6 @@ package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/smartystreets/goconvey/convey"
)
@@ -32,18 +31,3 @@ func Test_BuildVerificationURL(t *testing.T) {
})
})
}
func TestAppRegistrationEndpoint(t *testing.T) {
cases := []struct {
brand core.LarkBrand
want string
}{
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
}
for _, c := range cases {
if got := appRegistrationEndpoint(c.brand); got != c.want {
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
}
}
}

View File

@@ -3,11 +3,9 @@
package core
import "strings"
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// Any unrecognized value is normalized to BrandFeishu (the default brand).
// Any other string is treated as a custom base URL.
type LarkBrand string
const (
@@ -16,10 +14,9 @@ const (
)
// ParseBrand normalizes a brand string to a LarkBrand constant.
// Matching is case-insensitive and whitespace-tolerant; any value other than
// "lark" (after trim + lowercase) normalizes to BrandFeishu.
// Unrecognized values default to BrandFeishu.
func ParseBrand(value string) LarkBrand {
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
if value == "lark" {
return BrandLark
}
return BrandFeishu

View File

@@ -57,23 +57,3 @@ func TestResolveOpenBaseURL(t *testing.T) {
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
}
}
func TestParseBrand(t *testing.T) {
cases := []struct {
in string
want LarkBrand
}{
{"", BrandFeishu},
{"feishu", BrandFeishu},
{"lark", BrandLark},
{"LARK", BrandLark},
{" lark ", BrandLark},
{"Lark", BrandLark},
{"xyz", BrandFeishu},
}
for _, c := range cases {
if got := ParseBrand(c.in); got != c.want {
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
}
}
}

View File

@@ -75,7 +75,13 @@ func remoteMetaURL(version string) string {
if testMetaURL != "" {
return testMetaURL
}
base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition"
var base string
switch configuredBrand {
case core.BrandLark:
base = "https://open.larksuite.com/api/tools/open/api_definition"
default:
base = "https://open.feishu.cn/api/tools/open/api_definition"
}
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
if version != "" {
q += "&data_version=" + url.QueryEscape(version)

View File

@@ -16,7 +16,6 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -50,10 +49,7 @@ const (
var (
skillsIndexFetchTimeout = 10 * time.Second
// officialSkillsIndexURL, when non-empty, overrides the brand-derived skills
// index URL. It is empty in production (the URL is resolved from the brand
// via core.ResolveEndpoints) and set by tests to a local server.
officialSkillsIndexURL = ""
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
)
// DetectResult holds installation detection results.
@@ -105,10 +101,6 @@ func (r *NpmResult) CombinedOutput() string {
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
// / RestoreAvailableOverride for testing.
type Updater struct {
// Brand selects the endpoint set for skills index/source resolution.
// Zero value resolves to the feishu default via core.ResolveEndpoints.
Brand core.LarkBrand
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
@@ -137,21 +129,6 @@ type Updater struct {
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// skillsIndexURL returns the well-known skills index URL for the Updater's
// brand. A non-empty officialSkillsIndexURL (tests only) takes precedence.
func (u *Updater) skillsIndexURL() string {
if officialSkillsIndexURL != "" {
return officialSkillsIndexURL
}
return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json"
}
// skillsSource returns the skills repository source host for the Updater's
// brand, used as the `npx skills add <source>` argument.
func (u *Updater) skillsSource() string {
return core.ResolveEndpoints(u.Brand).Open
}
// DetectInstallMethod determines how the CLI was installed and whether the
// owning package manager is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
@@ -281,7 +258,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
@@ -320,7 +297,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial(u.skillsSource())
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
@@ -336,7 +313,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall(u.skillsSource(), nameList)
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
@@ -344,7 +321,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
}
func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd(u.skillsSource())
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}

View File

@@ -17,7 +17,6 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
)
@@ -516,23 +515,3 @@ func TestDetectInstallMethod_Caches(t *testing.T) {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}
func TestSkillsBrandHosts(t *testing.T) {
cases := []struct {
brand core.LarkBrand
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"},
}
for _, c := range cases {
u := &Updater{Brand: c.brand}
if got := u.skillsIndexURL(); got != c.wantIndex {
t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex)
}
if got := u.skillsSource(); got != c.wantSource {
t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource)
}
}
}

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -24,6 +23,7 @@ import (
)
const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour
fetchTimeout = 15 * time.Second
stateFile = "update-state.json"
@@ -202,25 +202,8 @@ type npmLatestResponse struct {
Version string `json:"version"`
}
// resolveRegistryURL builds the npm registry metadata URL for @larksuite/cli.
// It honors npm_config_registry when set to a valid https registry (npm
// ecosystem convention), otherwise falls back to the public npmjs default.
// This request carries no Lark credentials — it is package-distribution only.
func resolveRegistryURL() string {
const pkgPath = "/@larksuite/cli/latest"
const defaultBase = "https://registry.npmjs.org"
raw := strings.TrimSpace(os.Getenv("npm_config_registry"))
if raw != "" {
if u, err := url.Parse(raw); err == nil && u.Scheme == "https" && u.Host != "" {
base := strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/")
return base + pkgPath
}
}
return defaultBase + pkgPath
}
func fetchLatestVersion() (string, error) {
resp, err := httpClient().Get(resolveRegistryURL())
resp, err := httpClient().Get(registryURL)
if err != nil {
return "", err
}

View File

@@ -275,23 +275,3 @@ func TestIsCIEnv(t *testing.T) {
})
}
}
func TestResolveRegistryURL(t *testing.T) {
cases := []struct {
name, env, want string
}{
{"unset", "", "https://registry.npmjs.org/@larksuite/cli/latest"},
{"custom https", "https://corp.example.com/repository/npm/", "https://corp.example.com/repository/npm/@larksuite/cli/latest"},
{"trailing slashes", "https://corp.example.com///", "https://corp.example.com/@larksuite/cli/latest"},
{"non-https ignored", "http://internal.example.com/", "https://registry.npmjs.org/@larksuite/cli/latest"},
{"garbage ignored", "not a url", "https://registry.npmjs.org/@larksuite/cli/latest"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Setenv("npm_config_registry", c.env)
if got := resolveRegistryURL(); got != c.want {
t.Errorf("resolveRegistryURL() = %q, want %q", got, c.want)
}
})
}
}

View File

@@ -30,21 +30,8 @@ lint/
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_test.go
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
└── scan_test.go
```
## Endpoint domain contract (`domaincontract`)
Every outbound Lark domain must be resolved through `core.ResolveEndpoints`.
The `domaincontract` domain rejects string literals containing a resolver-owned
host FQDN (`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`) in any
production `.go` file. The only allowlisted files are the resolver definition
(`internal/core/types.go`) and this rule's own host list
(`lint/domaincontract/scan.go`). Comments and `_test.go` files are not scanned.
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
## Running
```bash

View File

@@ -1,97 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package domaincontract guards against hardcoded resolver-owned host FQDNs.
// Every outbound Lark domain must be resolved via core.ResolveEndpoints; the
// only place these host strings may appear as literals is the resolver itself.
package domaincontract
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/lint/lintapi"
)
// forbiddenHosts are the resolver-owned FQDNs. They may only appear as string
// literals in the allowlisted resolver source.
var forbiddenHosts = []string{
"open.feishu.cn", "accounts.feishu.cn", "mcp.feishu.cn", "applink.feishu.cn",
"open.larksuite.com", "accounts.larksuite.com", "mcp.larksuite.com", "applink.larksuite.com",
}
// allowlist maps repo-relative paths permitted to contain the literals: the
// resolver definition itself, and this rule's own forbidden-host list.
var allowlist = map[string]bool{
filepath.FromSlash("internal/core/types.go"): true,
filepath.FromSlash("lint/domaincontract/scan.go"): true,
}
func skipDir(name string) bool {
switch name {
case "vendor", "testdata", "node_modules", ".git", ".claude":
return true
}
return false
}
// ScanRepo walks production .go files under root and flags string literals
// containing a forbidden resolver host outside the allowlist. Comments and
// _test.go files are not scanned.
func ScanRepo(root string) ([]lintapi.Violation, error) {
var out []lintapi.Violation
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if skipDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr == nil && allowlist[rel] {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return nil // unparseable file: not our concern
}
display := path
if relErr == nil {
display = rel
}
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
for _, host := range forbiddenHosts {
if strings.Contains(lit.Value, host) {
pos := fset.Position(lit.Pos())
out = append(out, lintapi.Violation{
Rule: "no-hardcoded-endpoint",
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",
})
return true
}
}
return true
})
return nil
})
return out, err
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, root, rel, content string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestScanRepo(t *testing.T) {
root := t.TempDir()
// Negative: allowlisted resolver source may hold the literals.
writeFile(t, root, "internal/core/types.go", "package core\n\nvar x = \"https://open.feishu.cn\"\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.
writeFile(t, root, "internal/y/y_test.go", "package y\n\nvar w = \"https://open.larksuite.com\"\n")
// Positive: production literal outside the allowlist.
writeFile(t, root, "internal/z/z.go", "package z\n\nvar bad = \"https://accounts.larksuite.com/oauth\"\n")
vs, err := ScanRepo(root)
if err != nil {
t.Fatal(err)
}
if len(vs) != 1 {
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
}
if filepath.Base(vs[0].File) != "z.go" {
t.Errorf("violation in %q, want z.go", vs[0].File)
}
}

View File

@@ -30,7 +30,6 @@ import (
"fmt"
"os"
"github.com/larksuite/cli/lint/domaincontract"
"github.com/larksuite/cli/lint/errscontract"
"github.com/larksuite/cli/lint/lintapi"
)
@@ -44,9 +43,6 @@ type scanner struct {
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepo(root)
}},
}
func main() {

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.67",
"version": "1.0.68",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
@@ -246,7 +247,10 @@ var EventSubscribe = common.Shortcut{
}
// --- WebSocket ---
domain := core.ResolveEndpoints(runtime.Config.Brand).Open
domain := lark.FeishuBaseUrl
if runtime.Config.Brand == core.BrandLark {
domain = lark.LarkBaseUrl
}
info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset))
if eventTypeFilter != nil {

View File

@@ -1,24 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"testing"
"github.com/larksuite/cli/internal/core"
lark "github.com/larksuite/oapi-sdk-go/v3"
)
// TestWSDomainMatchesResolver guards the invariant that lets the event
// WebSocket domain be resolved via core.ResolveEndpoints instead of the SDK
// base-URL constants: the resolver's Open host must equal the SDK's per-brand
// base URL. If the SDK constants ever drift from the resolver, this fails.
func TestWSDomainMatchesResolver(t *testing.T) {
if got, want := core.ResolveEndpoints(core.BrandFeishu).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 {
t.Errorf("lark WS domain = %q, want SDK %q", got, want)
}
}