mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 14:38:53 +08:00
Compare commits
22 Commits
v1.0.33
...
feat/sec_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054ff9339b | ||
|
|
bdb0cd14d1 | ||
|
|
6c41d12792 | ||
|
|
2286937366 | ||
|
|
d793790807 | ||
|
|
13411d9a51 | ||
|
|
939b7b6fb6 | ||
|
|
a4c5ec99c8 | ||
|
|
7c54f9b023 | ||
|
|
e6bc292575 | ||
|
|
4aa61db8b2 | ||
|
|
28c66be199 | ||
|
|
0e70b056f8 | ||
|
|
95ffff4212 | ||
|
|
e511404065 | ||
|
|
b8469d2dc6 | ||
|
|
afa084e7a4 | ||
|
|
3354494579 | ||
|
|
2bb69d1942 | ||
|
|
c4fb7006d2 | ||
|
|
583349e572 | ||
|
|
315e0ab50c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
@@ -45,6 +45,7 @@ linters:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- bidichk
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.34] - 2026-05-19
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
|
||||
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
|
||||
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
|
||||
- **base**: Support Base attachment APIs (#887)
|
||||
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
|
||||
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identitydiag**: Harden verify path and tighten status semantics (#961)
|
||||
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
|
||||
- **auth**: Split bot and user identity diagnostics (#957)
|
||||
- **base**: Address Base attachment review follow-ups (#958)
|
||||
- **docs**: Clarify `replace_all` selection errors (#954)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify add comment constraints (#967)
|
||||
- **lark-im**: Clarify message activity search (#865)
|
||||
|
||||
### Tests
|
||||
|
||||
- Verify e2e resource cleanup (#949)
|
||||
- **lint**: Exclude `bidichk` from test files (#959)
|
||||
|
||||
## [v1.0.33] - 2026-05-18
|
||||
|
||||
### Features
|
||||
@@ -745,6 +774,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
|
||||
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
|
||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
|
||||
@@ -5,13 +5,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -60,73 +58,83 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
if config.UserOpenId == "" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
result["identity"] = "bot"
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
status := larkauth.TokenStatus(stored)
|
||||
if status == "expired" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
} else {
|
||||
result["identity"] = "user"
|
||||
}
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["tokenStatus"] = status
|
||||
result["scope"] = stored.Scope
|
||||
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
|
||||
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
|
||||
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
|
||||
|
||||
// --verify: call the server to confirm token is actually usable.
|
||||
if opts.Verify && status != "expired" {
|
||||
verified, verifyErr := verifyTokenOnServer(f, config)
|
||||
result["verified"] = verified
|
||||
if verifyErr != "" {
|
||||
result["verifyError"] = verifyErr
|
||||
}
|
||||
}
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addLegacyUserFields(result, diagnostics.User)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
|
||||
// and calls /authen/v1/user_info to confirm the server accepts it.
|
||||
// Returns (true, "") on success or (false, reason) on failure.
|
||||
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return false, "failed to create HTTP client: " + err.Error()
|
||||
}
|
||||
const (
|
||||
identityUser = "user"
|
||||
identityBot = "bot"
|
||||
identityNone = "none"
|
||||
)
|
||||
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return false, "token unusable: " + err.Error()
|
||||
func effectiveIdentity(d identitydiag.Result) string {
|
||||
switch {
|
||||
case d.User.Available:
|
||||
return identityUser
|
||||
case d.Bot.Available:
|
||||
return identityBot
|
||||
default:
|
||||
return identityNone
|
||||
}
|
||||
}
|
||||
|
||||
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||
if user.OpenID == "" {
|
||||
return
|
||||
}
|
||||
result["userName"] = user.UserName
|
||||
result["userOpenId"] = user.OpenID
|
||||
if user.TokenStatus != "" {
|
||||
result["tokenStatus"] = user.TokenStatus
|
||||
}
|
||||
if user.Scope != "" {
|
||||
result["scope"] = user.Scope
|
||||
}
|
||||
if user.ExpiresAt != "" {
|
||||
result["expiresAt"] = user.ExpiresAt
|
||||
}
|
||||
if user.RefreshExpiresAt != "" {
|
||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||
}
|
||||
if user.GrantedAt != "" {
|
||||
result["grantedAt"] = user.GrantedAt
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
if d.User.Verified != nil {
|
||||
result["verified"] = *d.User.Verified
|
||||
if !*d.User.Verified {
|
||||
result["verifyError"] = d.User.Message
|
||||
}
|
||||
}
|
||||
case identityBot:
|
||||
if d.Bot.Verified != nil {
|
||||
result["verified"] = *d.Bot.Verified
|
||||
if !*d.Bot.Verified {
|
||||
result["verifyError"] = d.Bot.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
|
||||
case d.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
|
||||
case !d.User.Available && !d.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
}
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return false, "failed to create SDK client: " + err.Error()
|
||||
}
|
||||
|
||||
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
|
||||
return false, "server rejected token: " + err.Error()
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
96
cmd/auth/status_test.go
Normal file
96
cmd/auth/status_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"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,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Verified == nil || !*got.Verified {
|
||||
t.Fatalf("verified = %v, want true", got.Verified)
|
||||
}
|
||||
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
|
||||
}
|
||||
if got.Identities.Bot.OpenID != "ou_bot" {
|
||||
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" {
|
||||
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
Identities struct {
|
||||
Bot statusIdentity `json:"bot"`
|
||||
User statusIdentity `json:"user"`
|
||||
} `json:"identities"`
|
||||
}
|
||||
|
||||
type statusIdentity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified"`
|
||||
OpenID string `json:"openId"`
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
cmdevent "github.com/larksuite/cli/cmd/event"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/sec"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
@@ -133,6 +134,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(sec.NewCmdSec(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
@@ -51,7 +51,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
// checkResult represents one diagnostic check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "pass", "fail", "skip"
|
||||
Status string `json:"status"` // "pass", "warn", "fail", "skip"
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
@@ -118,59 +118,31 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Token exists ──
|
||||
if cfg.UserOpenId == "" {
|
||||
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
|
||||
|
||||
// ── 4. Token local validity ──
|
||||
status := larkauth.TokenStatus(stored)
|
||||
switch status {
|
||||
case "valid":
|
||||
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
|
||||
case "needs_refresh":
|
||||
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
|
||||
default: // expired
|
||||
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
// ── 5. Token server verification ──
|
||||
if opts.Offline {
|
||||
checks = append(checks, skip("token_verified", "skipped (--offline)"))
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
httpClient := mustHTTPClient(f)
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
|
||||
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
|
||||
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 6 & 7. Endpoint reachability ──
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
if id.Available {
|
||||
return pass(name, id.Message)
|
||||
}
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -232,15 +204,6 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustHTTPClient returns f.HttpClient() or a default client.
|
||||
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
c, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
|
||||
@@ -95,3 +95,59 @@ 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{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "bot_identity", "pass")
|
||||
assertCheck(t, got.Checks, "user_identity", "warn")
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
}
|
||||
|
||||
251
cmd/sec/config_init.go
Normal file
251
cmd/sec/config_init.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdSecConfig is the parent for `lark-cli sec config <verb>`. Currently
|
||||
// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here.
|
||||
func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage lark-sec-cli daemon configuration",
|
||||
}
|
||||
cmd.AddCommand(NewCmdSecConfigInit(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ConfigInitOptions holds inputs for `lark-cli sec config init`.
|
||||
type ConfigInitOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand string
|
||||
Yes bool // skip the interactive form when all required values are provided
|
||||
}
|
||||
|
||||
// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and
|
||||
// registers them with the running lark-sec-cli daemon's admin endpoint. The
|
||||
// daemon stashes the secret in the OS keychain and switches into sidecar mode
|
||||
// for SEC_AUTH credential isolation.
|
||||
func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||
opts := &ConfigInitOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Register a Lark App with the running lark-sec-cli daemon",
|
||||
Long: `Register an App ID / App Secret with the lark-sec-cli daemon.
|
||||
|
||||
The daemon must already be running (start it with "lark-cli sec run"). The
|
||||
registration POSTs to /_sec/api/v1/register-app on the local proxy port,
|
||||
HMAC-signed with the daemon's proxy.key.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runConfigInit(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)")
|
||||
cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark")
|
||||
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json.
|
||||
// It's the single contract between lark-cli and lark-sec-cli at runtime —
|
||||
// we don't reach into lark-sec-cli internals, only what it chooses to publish.
|
||||
type secBridge struct {
|
||||
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
|
||||
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
|
||||
CA string `json:"LARKSUITE_CLI_SEC_CA"`
|
||||
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
|
||||
}
|
||||
|
||||
func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error {
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir())
|
||||
bridge, err := loadBridge()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing",
|
||||
fmt.Sprintf("daemon bridge file unreadable: %v", err),
|
||||
"Start the daemon first: `lark-cli sec run`.")
|
||||
}
|
||||
tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth)
|
||||
if !bridge.Enable || bridge.Proxy == "" {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_not_running",
|
||||
"lark-sec-cli is not advertising an active proxy",
|
||||
"Run `lark-cli sec run` to start it.")
|
||||
}
|
||||
|
||||
// The HMAC key sits next to the CA in the daemon's config dir. Deriving
|
||||
// from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's
|
||||
// install location — if the daemon ever moves, the bridge follows and we
|
||||
// follow with it.
|
||||
tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA)
|
||||
hmacKey, err := readHMACKey(bridge.CA)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err)
|
||||
}
|
||||
|
||||
if err := promptForMissing(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand)
|
||||
if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(errOut,
|
||||
fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand))
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir.
|
||||
func loadBridge() (*secBridge, error) {
|
||||
path := filepath.Join(core.GetConfigDir(), "sec_config.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b secBridge
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key
|
||||
// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw
|
||||
// 32-byte blob (older daemon variants), we use it as-is.
|
||||
func readHMACKey(caPath string) ([]byte, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key")
|
||||
}
|
||||
keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key")
|
||||
raw, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 64 {
|
||||
if decoded, err := hex.DecodeString(string(raw)); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't
|
||||
// provide via flags. --yes refuses to prompt; that's caller error if any are
|
||||
// still missing at that point.
|
||||
func promptForMissing(opts *ConfigInitOptions) error {
|
||||
if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" {
|
||||
return nil
|
||||
}
|
||||
if opts.Yes {
|
||||
return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand")
|
||||
}
|
||||
|
||||
groups := []*huh.Group{}
|
||||
if opts.AppID == "" {
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID),
|
||||
))
|
||||
}
|
||||
if opts.AppSecret == "" {
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret),
|
||||
))
|
||||
}
|
||||
if opts.Brand == "" {
|
||||
opts.Brand = "feishu"
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewSelect[string]().Title("Brand").Options(
|
||||
huh.NewOption("Feishu (cn)", "feishu"),
|
||||
huh.NewOption("Lark (intl)", "lark"),
|
||||
).Value(&opts.Brand),
|
||||
))
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu())
|
||||
if err := form.Run(); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme.
|
||||
// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per
|
||||
// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC.
|
||||
func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error {
|
||||
const path = "/_sec/api/v1/register-app"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
"brand": brand,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts
|
||||
mac := hmac.New(sha256.New, hmacKey)
|
||||
mac.Write([]byte(canonical))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Lark-Admin-Signature", sig)
|
||||
req.Header.Set("X-Lark-Admin-Timestamp", ts)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
33
cmd/sec/factory.go
Normal file
33
cmd/sec/factory.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// installer wires up an internal/sec.Installer using the Factory's HTTP client,
|
||||
// the default platform paths, and a lazy OAPI-client provider used to fetch
|
||||
// the install manifest. APIClientFunc is a method value, not an eager call —
|
||||
// commands that short-circuit (or that never install, like sec status / sec
|
||||
// stop) avoid decrypting credentials from the keychain. Every cmd/sec
|
||||
// subcommand starts here.
|
||||
func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) {
|
||||
paths, err := intsec.DefaultPaths()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve sec paths: %w", err)
|
||||
}
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve http client: %w", err)
|
||||
}
|
||||
return &intsec.Installer{
|
||||
Paths: paths,
|
||||
HTTPClient: httpClient,
|
||||
APIClientFunc: f.NewAPIClient,
|
||||
}, paths, nil
|
||||
}
|
||||
127
cmd/sec/run.go
Normal file
127
cmd/sec/run.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// RunOptions holds inputs for `lark-cli sec run`.
|
||||
type RunOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
ProxyPort int
|
||||
// AutoInstall runs `sec install` first when no binary is recorded.
|
||||
AutoInstall bool
|
||||
}
|
||||
|
||||
// NewCmdSecRun starts lark-sec-cli as a user-level system service so it
|
||||
// persists across logins and gets restarted by the OS supervisor if it
|
||||
// crashes. Under the hood it shells out to `lark-sec-cli service enable`,
|
||||
// which is the recommended startup path per the lark-sec-cli manual:
|
||||
//
|
||||
// - macOS → user-level launchd plist with KeepAlive=true
|
||||
// - Linux → user systemd unit with Restart=always
|
||||
// - Windows → registry autostart + a VBS watchdog loop
|
||||
//
|
||||
// Switching to this from a detached `exec.Command(... Setsid:true)` spawn
|
||||
// fixes two latent issues at once: (1) daemon logs survive past lark-cli
|
||||
// exit because the service supervisor — not our terminated pipes — owns
|
||||
// the daemon's stdout, and (2) the daemon's own self-upgrade module can
|
||||
// now fire (it gates on running-under-supervisor).
|
||||
func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
|
||||
opts := &RunOptions{Factory: f, AutoInstall: true}
|
||||
cmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)",
|
||||
Long: `Install lark-sec-cli as a user-level system service so the proxy
|
||||
daemon runs automatically, persists across logins, and is restarted by the
|
||||
OS if it exits. The daemon writes its own log file (default: under
|
||||
~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this
|
||||
command.
|
||||
|
||||
After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with
|
||||
the proxy port and CA path, so subsequent lark-cli runs route through the
|
||||
sidecar without any further action.
|
||||
|
||||
To stop and remove the service: lark-cli sec stop.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)")
|
||||
cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runRun(cmd *cobra.Command, opts *RunOptions) error {
|
||||
ctx := cmd.Context()
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec run", "constructing installer (lazy credentials)")
|
||||
inst, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
// Make sure we have a binary on disk before asking it to install itself
|
||||
// as a service.
|
||||
tracef(trace, "sec run", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall)
|
||||
if !opts.AutoInstall {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_not_installed",
|
||||
"lark-sec-cli is not installed",
|
||||
"Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.")
|
||||
}
|
||||
state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace})
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err)
|
||||
}
|
||||
} else {
|
||||
tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath)
|
||||
}
|
||||
|
||||
args := []string{"service", "enable"}
|
||||
if opts.ProxyPort > 0 {
|
||||
args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort))
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args)
|
||||
tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args)
|
||||
|
||||
c := exec.CommandContext(ctx, state.BinaryPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_service_enable",
|
||||
"`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len())
|
||||
|
||||
// Forward the installer's stdout to the user — it contains the launchd /
|
||||
// systemd unit name, the registered executable path, and a confirmation
|
||||
// that the supervisor will respawn the daemon on exit. Useful diagnostic
|
||||
// output that's better seen than swallowed.
|
||||
fmt.Fprint(errOut, stdout.String())
|
||||
output.PrintSuccess(errOut,
|
||||
"lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.")
|
||||
return nil
|
||||
}
|
||||
49
cmd/sec/sec.go
Normal file
49
cmd/sec/sec.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sec exposes the `lark-cli sec` command tree that bootstraps the
|
||||
// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`.
|
||||
// The internal/sec package owns the implementation; this package is a thin
|
||||
// Cobra wrapper that mirrors the conventions in cmd/auth.
|
||||
//
|
||||
// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle —
|
||||
// lark-cli is not in the update path, which is why there's no `sec update`
|
||||
// subcommand here.
|
||||
package sec
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdSec builds the parent `sec` command and registers all subcommands.
|
||||
//
|
||||
// The persistent --verbose / -v flag is inherited by every subcommand:
|
||||
// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to
|
||||
// stderr.
|
||||
//
|
||||
// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli
|
||||
// if no binary is on disk, so a separate install verb was redundant.
|
||||
func NewCmdSec(f *cmdutil.Factory) *cobra.Command {
|
||||
var verbose bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "sec",
|
||||
Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)",
|
||||
Long: `Manage the lark-sec-cli security sidecar.
|
||||
|
||||
lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic,
|
||||
injects BDMS risk-control signatures, and manages credentials via the OS
|
||||
keychain. These subcommands handle the runtime lifecycle from lark-cli's side:
|
||||
start the daemon (auto-installing on first run), inspect its state, register
|
||||
an app with it, and stop it. Updates after the first install are managed by
|
||||
lark-sec-cli itself.`,
|
||||
}
|
||||
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
|
||||
"print step-by-step pipeline output to stderr")
|
||||
cmd.AddCommand(NewCmdSecRun(f, nil))
|
||||
cmd.AddCommand(NewCmdSecStop(f, nil))
|
||||
cmd.AddCommand(NewCmdSecStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdSecConfig(f))
|
||||
return cmd
|
||||
}
|
||||
38
cmd/sec/sec_test.go
Normal file
38
cmd/sec/sec_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a
|
||||
// future refactor doesn't silently drop run/status/etc. The `update` verb
|
||||
// was intentionally removed when lark-sec-cli took over its own upgrade
|
||||
// lifecycle; if it ever needs to come back, add it here too. `install` was
|
||||
// removed because `sec run --auto-install` (default on) makes a standalone
|
||||
// install verb redundant.
|
||||
func TestNewCmdSec_HasAllSubcommands(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := NewCmdSec(f)
|
||||
|
||||
var got []string
|
||||
for _, c := range cmd.Commands() {
|
||||
got = append(got, c.Name())
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := []string{"config", "run", "status", "stop"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("subcommands = %v, want %v", got, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
cmd/sec/status.go
Normal file
115
cmd/sec/status.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// StatusOptions holds inputs for `lark-cli sec status`.
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
}
|
||||
|
||||
// NewCmdSecStatus shows install + runtime state. Implementation strategy:
|
||||
//
|
||||
// 1. Read lark-cli's local install record (state.json) — works even when the
|
||||
// daemon's not installed, and gives the user a version/buildId/path
|
||||
// fingerprint regardless of whether the service is up.
|
||||
// 2. If the install exists, shell out to `lark-sec-cli status` for the
|
||||
// live daemon view (service registration, pid liveness, proxy probe,
|
||||
// sec_config.json contents). The daemon's own status command does a
|
||||
// thorough check; we just pass it through.
|
||||
func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||
opts := &StatusOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show lark-sec-cli install and runtime state",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runStatus(cmd, opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, opts *StatusOptions) error {
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec status", "constructing installer (lazy credentials)")
|
||||
_, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
out := opts.Factory.IOStreams.Out
|
||||
tracef(trace, "sec status", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
fmt.Fprintln(out, "lark-sec-cli: not installed")
|
||||
fmt.Fprintln(out, " run: lark-cli sec run")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version)
|
||||
fmt.Fprintf(out, " binary: %s\n", state.BinaryPath)
|
||||
|
||||
// Daemon-side detail via `lark-sec-cli status`. The daemon's status
|
||||
// command already covers service registration + pid + proxy reachability
|
||||
// + bridge file — better than re-implementing those here.
|
||||
tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath)
|
||||
c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status")
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
runErr := c.Run()
|
||||
tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len())
|
||||
fmt.Fprintln(out, " --- lark-sec-cli status ---")
|
||||
if stdout.Len() > 0 {
|
||||
fmt.Fprint(out, indent(stdout.String(), " "))
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
fmt.Fprint(out, indent(stderr.String(), " "))
|
||||
}
|
||||
// `lark-sec-cli status` exits 1 when not running — that's diagnostic
|
||||
// data, not a failure of OUR command. Surface it for the user but don't
|
||||
// propagate the non-zero exit upward.
|
||||
_ = runErr
|
||||
return nil
|
||||
}
|
||||
|
||||
// indent prefixes every line of s with prefix. Cheap pass-through formatter
|
||||
// used to make the embedded `lark-sec-cli status` output read as a sub-block
|
||||
// under our own header.
|
||||
func indent(s, prefix string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
buf.WriteString(prefix)
|
||||
buf.WriteString(s[start : i+1])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
buf.WriteString(prefix)
|
||||
buf.WriteString(s[start:])
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
82
cmd/sec/stop.go
Normal file
82
cmd/sec/stop.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// StopOptions holds inputs for `lark-cli sec stop`.
|
||||
type StopOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
}
|
||||
|
||||
// NewCmdSecStop disables and removes the lark-sec-cli user system service.
|
||||
// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`,
|
||||
// which uninstalls the launchd / systemd / VBS-watchdog registration.
|
||||
//
|
||||
// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its
|
||||
// --disable-on-exit flag, default true), so subsequent lark-cli runs route
|
||||
// directly to the upstream API instead of dangling through a dead local proxy.
|
||||
func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command {
|
||||
opts := &StopOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Disable and remove the lark-sec-cli user system service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runStop(cmd, opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStop(cmd *cobra.Command, opts *StopOptions) error {
|
||||
out := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, out)
|
||||
|
||||
tracef(trace, "sec stop", "constructing installer (lazy credentials)")
|
||||
_, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
tracef(trace, "sec stop", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
// Nothing on disk to stop — no-op.
|
||||
tracef(trace, "sec stop", "no install on disk; nothing to stop")
|
||||
output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop")
|
||||
return nil
|
||||
}
|
||||
|
||||
args := []string{"service", "disable"}
|
||||
fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args)
|
||||
tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args)
|
||||
|
||||
c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_service_disable",
|
||||
"`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len())
|
||||
fmt.Fprint(out, stdout.String())
|
||||
output.PrintSuccess(out, "lark-sec-cli service disabled")
|
||||
return nil
|
||||
}
|
||||
32
cmd/sec/verbose.go
Normal file
32
cmd/sec/verbose.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// verboseOut returns the trace destination for a sec subcommand: the given
|
||||
// stderr writer when the inherited --verbose / -v flag is set, otherwise nil.
|
||||
// Pair with tracef — a nil destination silently drops traces, so callers can
|
||||
// emit unconditionally.
|
||||
func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer {
|
||||
if v, _ := cmd.Flags().GetBool("verbose"); v {
|
||||
return errOut
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tracef writes one trace line to w when w is non-nil. The prefix names the
|
||||
// emitting subcommand (e.g. "sec run") so layered output from the install
|
||||
// pipeline + the command itself stays distinguishable.
|
||||
func tracef(w io.Writer, prefix, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...)
|
||||
}
|
||||
227
extension/credential/secplugin/provider.go
Normal file
227
extension/credential/secplugin/provider.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package secplugin provides a placeholder credential provider for SEC_AUTH mode.
|
||||
//
|
||||
// When ~/.lark-cli/sec_config.json has:
|
||||
//
|
||||
// LARKSUITE_CLI_SEC_ENABLE=true
|
||||
// LARKSUITE_CLI_SEC_AUTH=true
|
||||
//
|
||||
// this provider returns a minimal Account and placeholder tokens. The proxy
|
||||
// is expected to replace the placeholder tokens with real ones.
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internalsec "github.com/larksuite/cli/internal/secplugin"
|
||||
)
|
||||
|
||||
// Provider supplies placeholder credentials when SEC_AUTH mode is enabled.
|
||||
type Provider struct{}
|
||||
|
||||
// Name returns the registered credential provider name.
|
||||
func (p *Provider) Name() string { return "secplugin" }
|
||||
|
||||
// Priority is higher than env (default 10) but lower than sidecar (0),
|
||||
// so authsidecar builds keep sidecar semantics when both are present.
|
||||
func (p *Provider) Priority() int { return 1 }
|
||||
|
||||
// loadSecConfig is replaceable in tests so provider behavior can be isolated
|
||||
// from on-disk SEC configuration state.
|
||||
var loadSecConfig = internalsec.Load
|
||||
|
||||
func validateDefaultAs(value string) error {
|
||||
switch id := credential.Identity(strings.TrimSpace(value)); id {
|
||||
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveAccount builds an account that advertises SEC_AUTH placeholder support.
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
cfg, err := loadSecConfig()
|
||||
if err != nil {
|
||||
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
|
||||
}
|
||||
if cfg == nil || !cfg.AuthEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
appID := strings.TrimSpace(os.Getenv(envvars.CliAppID))
|
||||
brand := credential.Brand(strings.TrimSpace(os.Getenv(envvars.CliBrand)))
|
||||
var defaultAs credential.Identity
|
||||
|
||||
// Prefer explicit env; if missing, allow sec_config.json to provide defaults.
|
||||
if appID == "" && strings.TrimSpace(cfg.AppID) != "" {
|
||||
appID = strings.TrimSpace(cfg.AppID)
|
||||
}
|
||||
if brand == "" && strings.TrimSpace(cfg.Brand) != "" {
|
||||
brand = credential.Brand(strings.TrimSpace(cfg.Brand))
|
||||
}
|
||||
if defaultAs == "" && strings.TrimSpace(cfg.DefaultAs) != "" {
|
||||
defaultAs = credential.Identity(strings.TrimSpace(cfg.DefaultAs))
|
||||
if err := validateDefaultAs(string(defaultAs)); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer explicit env for sandbox use; otherwise fall back to on-disk config
|
||||
// without resolving any secrets.
|
||||
if appID == "" || brand == "" {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil || multi == nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: "SEC_AUTH is enabled but no app config is available; run `lark-cli config init --new` (trusted env), or set " + envvars.CliAppID + " and " + envvars.CliBrand,
|
||||
}
|
||||
}
|
||||
app := multi.CurrentAppConfig("") // profile override not available in provider API
|
||||
if app == nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: "SEC_AUTH is enabled but no active profile is available in config.json",
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
appID = app.AppId
|
||||
}
|
||||
if brand == "" {
|
||||
brand = credential.Brand(app.Brand)
|
||||
}
|
||||
if defaultAs == "" {
|
||||
defaultAs = credential.Identity(app.DefaultAs)
|
||||
}
|
||||
|
||||
// Map strict mode to supported identities (0 = allow all).
|
||||
mode := multi.StrictMode
|
||||
if app.StrictMode != nil {
|
||||
mode = *app.StrictMode
|
||||
}
|
||||
switch mode {
|
||||
case core.StrictModeBot:
|
||||
// Keep sandbox locked down to bot.
|
||||
return &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: credential.SupportsBot,
|
||||
}, nil
|
||||
case core.StrictModeUser:
|
||||
return &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: credential.SupportsUser,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: "SEC_AUTH is enabled but " + envvars.CliAppID + " is missing",
|
||||
}
|
||||
}
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
if brand != credential.BrandFeishu && brand != credential.BrandLark {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: fmt.Sprintf("invalid %s %q (want feishu or lark)", envvars.CliBrand, brand),
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAs comes from env if present (optional).
|
||||
envDefaultAs := strings.TrimSpace(os.Getenv(envvars.CliDefaultAs))
|
||||
if err := validateDefaultAs(envDefaultAs); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
switch id := credential.Identity(envDefaultAs); id {
|
||||
case "", credential.IdentityAuto:
|
||||
// keep defaultAs from config/env; empty is allowed
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
defaultAs = id
|
||||
}
|
||||
|
||||
// If STRICT_MODE env is not set, allow sec_config.json to provide a default.
|
||||
strictModeRaw := strings.TrimSpace(os.Getenv(envvars.CliStrictMode))
|
||||
if strictModeRaw == "" && strings.TrimSpace(cfg.StrictMode) != "" {
|
||||
strictModeRaw = strings.TrimSpace(cfg.StrictMode)
|
||||
}
|
||||
|
||||
// SupportedIdentities from STRICT_MODE (optional). Default: allow both.
|
||||
support := credential.SupportsAll
|
||||
switch strictMode := strictModeRaw; strictMode {
|
||||
case "bot":
|
||||
support = credential.SupportsBot
|
||||
case "user":
|
||||
support = credential.SupportsUser
|
||||
case "off", "":
|
||||
// Keep the default: allow both identities.
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
return &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: support,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveToken returns placeholder tokens that a trusted proxy must replace.
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
cfg, err := internalsec.Load()
|
||||
if err != nil {
|
||||
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
|
||||
}
|
||||
if cfg == nil || !cfg.AuthEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
return &credential.Token{
|
||||
Value: internalsec.SentinelUAT,
|
||||
Scopes: "", // empty => skip scope pre-check
|
||||
Source: "secplugin",
|
||||
}, nil
|
||||
case credential.TokenTypeTAT:
|
||||
return &credential.Token{
|
||||
Value: internalsec.SentinelTAT,
|
||||
Scopes: "",
|
||||
Source: "secplugin",
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// init registers the SEC_AUTH placeholder credential provider.
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
486
extension/credential/secplugin/provider_test.go
Normal file
486
extension/credential/secplugin/provider_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internalsec "github.com/larksuite/cli/internal/secplugin"
|
||||
)
|
||||
|
||||
// TestProvider_Metadata verifies the registered provider metadata.
|
||||
func TestProvider_Metadata(t *testing.T) {
|
||||
p := &Provider{}
|
||||
if p.Name() != "secplugin" {
|
||||
t.Fatalf("Name() = %q, want secplugin", p.Name())
|
||||
}
|
||||
if p.Priority() != 1 {
|
||||
t.Fatalf("Priority() = %d, want 1", p.Priority())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_UsesSecConfigDefaults verifies that SEC config defaults populate
|
||||
// the placeholder account when env vars are absent.
|
||||
func TestProvider_UsesSecConfigDefaults(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
AppID: "cli_test_app",
|
||||
Brand: "lark",
|
||||
DefaultAs: "bot",
|
||||
StrictMode: "bot",
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.AppID != "cli_test_app" {
|
||||
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_test_app")
|
||||
}
|
||||
if string(acct.Brand) != "lark" {
|
||||
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
|
||||
}
|
||||
if string(acct.DefaultAs) != "bot" {
|
||||
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "bot")
|
||||
}
|
||||
// StrictMode=bot => SupportsBot only.
|
||||
if acct.SupportedIdentities != 2 {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_EnvOverridesSecConfigDefaults verifies that explicit environment
|
||||
// variables override SEC config defaults.
|
||||
func TestProvider_EnvOverridesSecConfigDefaults(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
AppID: "cli_test_app",
|
||||
Brand: "feishu",
|
||||
DefaultAs: "bot",
|
||||
StrictMode: "bot",
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv(envvars.CliAppID, "cli_env_app")
|
||||
t.Setenv(envvars.CliBrand, "lark")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
t.Setenv(envvars.CliStrictMode, "user")
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.AppID != "cli_env_app" {
|
||||
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_env_app")
|
||||
}
|
||||
if string(acct.Brand) != "lark" {
|
||||
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
|
||||
}
|
||||
if string(acct.DefaultAs) != "user" {
|
||||
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "user")
|
||||
}
|
||||
// StrictMode=user => SupportsUser only (bit 1).
|
||||
if acct.SupportedIdentities != 1 {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsUser)", acct.SupportedIdentities, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_ReturnsNilWhenDisabled verifies early nil returns
|
||||
// when SEC_AUTH mode is unavailable.
|
||||
func TestProvider_ResolveAccount_ReturnsNilWhenDisabled(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *internalsec.Config
|
||||
}{
|
||||
{name: "nil config", cfg: nil},
|
||||
{name: "auth disabled", cfg: &internalsec.Config{Enable: true, Auth: false}},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_LoadErrorBlocks verifies that SEC config load failures
|
||||
// stop provider resolution.
|
||||
func TestProvider_ResolveAccount_LoadErrorBlocks(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr, ok := err.(*credential.BlockError)
|
||||
if !ok {
|
||||
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
|
||||
}
|
||||
if blockErr.Provider != "secplugin" {
|
||||
t.Fatalf("blockErr.Provider = %q, want secplugin", blockErr.Provider)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, context.DeadlineExceeded.Error()) {
|
||||
t.Fatalf("blockErr.Reason = %q, want load error text", blockErr.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_DefaultsBrandAndSupport verifies fallback defaults
|
||||
// for brand and supported identities.
|
||||
func TestProvider_ResolveAccount_DefaultsBrandAndSupport(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
DefaultAs: core.AsBot,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.Brand != credential.BrandFeishu {
|
||||
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, credential.BrandFeishu)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, credential.SupportsAll)
|
||||
}
|
||||
if acct.DefaultAs != credential.Identity("bot") {
|
||||
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
|
||||
}
|
||||
if acct.AppID != "app_from_disk" {
|
||||
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_InvalidValuesBlock verifies validation failures for
|
||||
// brand and identity-related settings.
|
||||
func TestProvider_ResolveAccount_InvalidValuesBlock(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *internalsec.Config
|
||||
envKey string
|
||||
envValue string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid brand from config",
|
||||
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "bad-brand"},
|
||||
want: "invalid " + envvars.CliBrand,
|
||||
},
|
||||
{
|
||||
name: "invalid default as from config",
|
||||
cfg: &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
AppID: "cli_test_app",
|
||||
Brand: "lark",
|
||||
DefaultAs: "bad",
|
||||
},
|
||||
want: "invalid " + envvars.CliDefaultAs,
|
||||
},
|
||||
{
|
||||
name: "invalid default as from env",
|
||||
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
|
||||
envKey: envvars.CliDefaultAs,
|
||||
envValue: "bad",
|
||||
want: "invalid " + envvars.CliDefaultAs,
|
||||
},
|
||||
{
|
||||
name: "invalid strict mode from env",
|
||||
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
|
||||
envKey: envvars.CliStrictMode,
|
||||
envValue: "bad",
|
||||
want: "invalid " + envvars.CliStrictMode,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
if tt.envKey != "" {
|
||||
t.Setenv(tt.envKey, tt.envValue)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr, ok := err.(*credential.BlockError)
|
||||
if !ok {
|
||||
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.want) {
|
||||
t.Fatalf("blockErr.Reason = %q, want substring %q", blockErr.Reason, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_FallbackToDiskConfig verifies fallback behavior
|
||||
// when SEC config omits app identity fields.
|
||||
func TestProvider_ResolveAccount_FallbackToDiskConfig(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{Enable: true, Auth: true}, nil
|
||||
}
|
||||
|
||||
t.Run("missing config blocks", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr := err.(*credential.BlockError)
|
||||
if !strings.Contains(blockErr.Reason, "no app config is available") {
|
||||
t.Fatalf("blockErr.Reason = %q, want missing app config message", blockErr.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing active profile blocks", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.LarkBrand("lark"),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr := err.(*credential.BlockError)
|
||||
if !strings.Contains(blockErr.Reason, "no active profile") {
|
||||
t.Fatalf("blockErr.Reason = %q, want no active profile message", blockErr.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("strict mode from disk", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mode core.StrictMode
|
||||
wantIDs credential.IdentitySupport
|
||||
}{
|
||||
{name: "bot", mode: core.StrictModeBot, wantIDs: credential.SupportsBot},
|
||||
{name: "user", mode: core.StrictModeUser, wantIDs: credential.SupportsUser},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mode := tt.mode
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.LarkBrand("lark"),
|
||||
DefaultAs: core.AsBot,
|
||||
StrictMode: &mode,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.AppID != "app_from_disk" {
|
||||
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
|
||||
}
|
||||
if acct.Brand != credential.Brand("lark") {
|
||||
t.Fatalf("acct.Brand = %q, want lark", acct.Brand)
|
||||
}
|
||||
if acct.DefaultAs != credential.Identity("bot") {
|
||||
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
|
||||
}
|
||||
if acct.SupportedIdentities != tt.wantIDs {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.wantIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs verifies
|
||||
// cfg.DefaultAs is not overwritten by disk profile default in strict-mode path.
|
||||
func TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
Brand: "lark",
|
||||
DefaultAs: "user",
|
||||
StrictMode: "bot",
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.LarkBrand("lark"),
|
||||
DefaultAs: core.AsBot,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.DefaultAs != credential.IdentityUser {
|
||||
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, credential.IdentityUser)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsBot {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, credential.SupportsBot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveToken_ReturnsSentinels verifies placeholder token behavior
|
||||
// for SEC_AUTH mode.
|
||||
func TestProvider_ResolveToken_ReturnsSentinels(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliSecEnable, "true")
|
||||
t.Setenv(envvars.CliSecAuth, "true")
|
||||
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:3128")
|
||||
t.Setenv(envvars.CliSecCA, "")
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
uat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken(UAT) error = %v", err)
|
||||
}
|
||||
if uat == nil || uat.Value != internalsec.SentinelUAT || uat.Source != "secplugin" {
|
||||
t.Fatalf("ResolveToken(UAT) = %#v, want sentinel UAT token", uat)
|
||||
}
|
||||
|
||||
tat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken(TAT) error = %v", err)
|
||||
}
|
||||
if tat == nil || tat.Value != internalsec.SentinelTAT || tat.Source != "secplugin" {
|
||||
t.Fatalf("ResolveToken(TAT) = %#v, want sentinel TAT token", tat)
|
||||
}
|
||||
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenType("other")})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken(other) error = %v", err)
|
||||
}
|
||||
if tok != nil {
|
||||
t.Fatalf("ResolveToken(other) = %#v, want nil", tok)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -66,3 +67,49 @@ func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user"})
|
||||
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible")
|
||||
}
|
||||
wantUsage := "identity type: user"
|
||||
if flag.Usage != wantUsage {
|
||||
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
|
||||
}
|
||||
if strings.Contains(flag.Usage, "bot") {
|
||||
t.Errorf("Usage should not contain \"bot\" for user-only shortcut, got %q", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user", "bot"})
|
||||
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible")
|
||||
}
|
||||
if got := flag.DefValue; got != "" {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
wantUsage := "identity type: user | bot"
|
||||
if flag.Usage != wantUsage {
|
||||
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,45 @@
|
||||
package envvars
|
||||
|
||||
const (
|
||||
CliAppID = "LARKSUITE_CLI_APP_ID"
|
||||
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
||||
CliBrand = "LARKSUITE_CLI_BRAND"
|
||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||
// CliAppID is the app ID environment variable consumed by the CLI.
|
||||
CliAppID = "LARKSUITE_CLI_APP_ID"
|
||||
|
||||
// CliAppSecret is the app secret environment variable consumed by the CLI.
|
||||
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
||||
|
||||
// CliBrand selects the tenant brand environment variable consumed by the CLI.
|
||||
CliBrand = "LARKSUITE_CLI_BRAND"
|
||||
|
||||
// CliUserAccessToken is the user access token override environment variable.
|
||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||
|
||||
// CliTenantAccessToken is the tenant access token override environment variable.
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
// CliDefaultAs selects the default identity environment variable.
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
|
||||
// CliStrictMode selects the strict identity mode environment variable.
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// CliAuthProxy is the auth sidecar HTTP address environment variable.
|
||||
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
|
||||
// CliProxyKey is the shared HMAC signing key environment variable for the sidecar.
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
|
||||
// CliSecEnable enables sec plugin mode from the environment.
|
||||
CliSecEnable = "LARKSUITE_CLI_SEC_ENABLE"
|
||||
|
||||
// CliSecProxy sets the fixed sec plugin HTTP proxy address.
|
||||
CliSecProxy = "LARKSUITE_CLI_SEC_PROXY"
|
||||
|
||||
// CliSecCA points to an extra PEM bundle trusted by sec plugin mode.
|
||||
CliSecCA = "LARKSUITE_CLI_SEC_CA"
|
||||
|
||||
// CliSecAuth enables placeholder-token auth mode for sec plugin flows.
|
||||
CliSecAuth = "LARKSUITE_CLI_SEC_AUTH"
|
||||
|
||||
// CliContentSafetyMode selects the content safety scanning mode.
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
)
|
||||
|
||||
325
internal/identitydiag/diagnostics.go
Normal file
325
internal/identitydiag/diagnostics.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package identitydiag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusReady = "ready"
|
||||
StatusNotConfigured = "not_configured"
|
||||
StatusMissing = "missing"
|
||||
StatusNeedsRefresh = "needs_refresh"
|
||||
StatusVerifyFailed = "verify_failed"
|
||||
)
|
||||
|
||||
// verifyTimeout bounds each network call made during --verify so that a
|
||||
// hanging server cannot wedge `auth status --verify` or `doctor`. Mirrors
|
||||
// the 10s timeout used by the doctor endpoint probe.
|
||||
const verifyTimeout = 10 * time.Second
|
||||
|
||||
// Result describes the independently usable bot and user identities.
|
||||
type Result struct {
|
||||
Bot Identity `json:"bot"`
|
||||
User Identity `json:"user"`
|
||||
}
|
||||
|
||||
// Identity is a single identity diagnostic result.
|
||||
type Identity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
AppName string `json:"appName,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpiresAt string `json:"refreshExpiresAt,omitempty"`
|
||||
GrantedAt string `json:"grantedAt,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return Result{
|
||||
Bot: diagnoseBot(ctx, f, cfg, verify),
|
||||
User: diagnoseUser(ctx, f, cfg, verify),
|
||||
}
|
||||
}
|
||||
|
||||
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
if !cfg.CanBot() {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (bot identity is not available in current credential context)",
|
||||
Hint: "check strict mode or the active credential provider",
|
||||
}
|
||||
}
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app secret or bot token)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
Status: StatusReady,
|
||||
Available: true,
|
||||
Message: "Bot identity: ready",
|
||||
}
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
|
||||
token, err := resolveBotToken(ctx, f, cfg)
|
||||
if err != nil {
|
||||
status := StatusVerifyFailed
|
||||
var unavailable *credential.TokenUnavailableError
|
||||
if errors.As(err, &unavailable) {
|
||||
status = StatusNotConfigured
|
||||
}
|
||||
return Identity{
|
||||
Status: status,
|
||||
Verified: boolPtr(false),
|
||||
Message: "Bot identity: " + StatusMessage(status) + ": " + err.Error(),
|
||||
Hint: "check app credentials or the active credential provider",
|
||||
}
|
||||
}
|
||||
|
||||
info, err := fetchBotInfo(ctx, f, cfg, token)
|
||||
if err != nil {
|
||||
return Identity{
|
||||
Status: StatusVerifyFailed,
|
||||
Verified: boolPtr(false),
|
||||
Message: "Bot identity: verify failed: " + err.Error(),
|
||||
Hint: "check app credentials, scopes, network, or tenant access token configuration",
|
||||
}
|
||||
}
|
||||
|
||||
id.Verified = boolPtr(true)
|
||||
id.OpenID = info.OpenID
|
||||
id.AppName = info.AppName
|
||||
return id
|
||||
}
|
||||
|
||||
func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "User identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return Identity{
|
||||
Status: StatusMissing,
|
||||
Message: "User identity: missing (no user logged in)",
|
||||
Hint: "run: lark-cli auth login --help",
|
||||
}
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
UserName: cfg.UserName,
|
||||
OpenID: cfg.UserOpenId,
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
id.Status = StatusMissing
|
||||
id.Message = "User identity: missing (no token in keychain for " + cfg.UserOpenId + ")"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
}
|
||||
|
||||
fillTokenFields(&id, stored)
|
||||
switch larkauth.TokenStatus(stored) {
|
||||
case "valid":
|
||||
id.Status = StatusReady
|
||||
id.Available = true
|
||||
id.Message = "User identity: ready"
|
||||
case "needs_refresh":
|
||||
id.Status = StatusNeedsRefresh
|
||||
id.Available = true
|
||||
id.Message = "User identity: needs refresh (will auto-refresh on next user API call)"
|
||||
default:
|
||||
id.Status = StatusMissing
|
||||
id.Message = "User identity: missing (refresh token expired)"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
}
|
||||
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
|
||||
markVerifyFailed := func(reason, hint string) Identity {
|
||||
id.Status = StatusVerifyFailed
|
||||
id.Available = false
|
||||
id.Verified = boolPtr(false)
|
||||
id.Message = "User identity: verify failed: " + reason
|
||||
if hint != "" {
|
||||
id.Hint = hint
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return markVerifyFailed("create HTTP client: "+err.Error(), "")
|
||||
}
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help")
|
||||
}
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return markVerifyFailed("SDK init failed: "+err.Error(), "")
|
||||
}
|
||||
verifyCtx, cancel := context.WithTimeout(ctx, verifyTimeout)
|
||||
defer cancel()
|
||||
if err := larkauth.VerifyUserToken(verifyCtx, sdk, token); err != nil {
|
||||
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help")
|
||||
}
|
||||
|
||||
id.Verified = boolPtr(true)
|
||||
if id.Status == StatusReady {
|
||||
id.Message = "User identity: ready"
|
||||
} else {
|
||||
id.Message = "User identity: needs refresh (server verification succeeded after refresh)"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *core.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))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
type botInfo struct {
|
||||
OpenID string
|
||||
AppName string
|
||||
}
|
||||
|
||||
func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *core.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"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
||||
// payload is under "bot", not "data" as the newer Lark API convention.
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"bot"`
|
||||
}
|
||||
parseErr := json.Unmarshal(body, &envelope)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
// Lark error responses are usually `{code, msg}` envelopes even on
|
||||
// non-2xx — surface them when present so callers see why bot auth
|
||||
// was rejected, not just the bare HTTP code.
|
||||
if parseErr == nil && envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("HTTP %d: [%d] %s", resp.StatusCode, envelope.Code, envelope.Msg)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", parseErr)
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("[%d] %s", envelope.Code, envelope.Msg)
|
||||
}
|
||||
if envelope.Data.OpenID == "" {
|
||||
return nil, errors.New("open_id is empty")
|
||||
}
|
||||
return &botInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
|
||||
}
|
||||
|
||||
func fillTokenFields(id *Identity, token *larkauth.StoredUAToken) {
|
||||
id.TokenStatus = larkauth.TokenStatus(token)
|
||||
id.Scope = token.Scope
|
||||
id.ExpiresAt = formatMillis(token.ExpiresAt)
|
||||
id.RefreshExpiresAt = formatMillis(token.RefreshExpiresAt)
|
||||
id.GrantedAt = formatMillis(token.GrantedAt)
|
||||
}
|
||||
|
||||
func formatMillis(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func StatusMessage(status string) string {
|
||||
switch status {
|
||||
case StatusNotConfigured:
|
||||
return "not configured"
|
||||
case StatusVerifyFailed:
|
||||
return "verify failed"
|
||||
case StatusNeedsRefresh:
|
||||
return "needs refresh"
|
||||
case StatusMissing:
|
||||
return "missing"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
350
internal/identitydiag/diagnostics_test.go
Normal file
350
internal/identitydiag/diagnostics_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package identitydiag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"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}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusReady || !got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Bot)
|
||||
}
|
||||
if got.User.Status != StatusMissing || got.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_BotIdentityNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
stub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusReady || !got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Bot)
|
||||
}
|
||||
if got.Bot.Verified == nil || !*got.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Bot.Verified)
|
||||
}
|
||||
if got.Bot.OpenID != "ou_bot" || got.Bot.AppName != "diagnostic bot" {
|
||||
t.Fatalf("bot info = %#v, want open id and app name", got.Bot)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyUserIdentity(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-user",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Status != StatusReady || !got.User.Available {
|
||||
t.Fatalf("user = %#v, want ready and available", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || !*got.User.Verified {
|
||||
t.Fatalf("user verified = %v, want true", got.User.Verified)
|
||||
}
|
||||
if got.User.OpenID != "ou_user" || got.User.UserName != "tester" {
|
||||
t.Fatalf("user = %#v, want user identity details", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity_HTTPErrorSurfacesEnvelope(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: http.StatusUnauthorized,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663,
|
||||
"msg": "app ticket invalid",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
||||
}
|
||||
if got.Bot.Verified == nil || *got.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want false", got.Bot.Verified)
|
||||
}
|
||||
if !strings.Contains(got.Bot.Message, "401") || !strings.Contains(got.Bot.Message, "99991663") {
|
||||
t.Fatalf("bot message = %q, want both HTTP code and envelope code", got.Bot.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity_BusinessErrorCode(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10013,
|
||||
"msg": "scope not granted",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
||||
}
|
||||
if !strings.Contains(got.Bot.Message, "10013") || !strings.Contains(got.Bot.Message, "scope not granted") {
|
||||
t.Fatalf("bot message = %q, want envelope code/msg", got.Bot.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyUserIdentity_ServerRejects(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-reject",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"bot": map[string]interface{}{"open_id": "ou_bot", "app_name": "bot"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991661,
|
||||
"msg": "access token invalid",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Status != StatusVerifyFailed || got.User.Available {
|
||||
t.Fatalf("user = %#v, want verify_failed and unavailable", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || *got.User.Verified {
|
||||
t.Fatalf("user verified = %v, want false", got.User.Verified)
|
||||
}
|
||||
if !strings.Contains(got.User.Message, "server rejected token") {
|
||||
t.Fatalf("user message = %q, want 'server rejected token'", got.User.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityExpired(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-expired",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_expired",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(-time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(-time.Minute).UnixMilli(),
|
||||
GrantedAt: now.Add(-24 * time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusMissing || got.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.User)
|
||||
}
|
||||
if got.User.Hint == "" {
|
||||
t.Fatalf("user hint is empty, want re-login hint")
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
AppID: "test-app",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
SupportedIdentities: 1,
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityMissingAppConfig(t *testing.T) {
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusNotConfigured || got.User.Available {
|
||||
t.Fatalf("user = %#v, want not_configured and unavailable", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMessage(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
StatusReady: StatusReady,
|
||||
StatusNotConfigured: "not configured",
|
||||
StatusVerifyFailed: "verify failed",
|
||||
StatusNeedsRefresh: "needs refresh",
|
||||
StatusMissing: "missing",
|
||||
"unknown": "unknown",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := StatusMessage(in); got != want {
|
||||
t.Errorf("StatusMessage(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-needs-refresh",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_refresh",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Minute).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusNeedsRefresh || !got.User.Available {
|
||||
t.Fatalf("user = %#v, want needs_refresh and available", got.User)
|
||||
}
|
||||
if got.User.TokenStatus != "needs_refresh" {
|
||||
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
||||
}
|
||||
}
|
||||
138
internal/sec/archive.go
Normal file
138
internal/sec/archive.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxArchiveBytes is a sanity ceiling for total uncompressed size to prevent
|
||||
// a malicious or corrupt zip from filling the disk. The lark-sec-cli zip is a
|
||||
// single binary plus one shared library; 1 GiB is several orders of magnitude
|
||||
// over the real size and well under most users' free disk.
|
||||
const maxArchiveBytes = 1 << 30
|
||||
|
||||
// ExtractZip unpacks src into dst, refusing entries whose target paths would
|
||||
// escape dst (zip slip). Existing files inside dst are overwritten; dst must
|
||||
// already exist.
|
||||
//
|
||||
// Executable permission is preserved when the zip stores POSIX mode bits;
|
||||
// otherwise we apply 0o755 to suspected binaries (matching BinaryName() /
|
||||
// legacy names or anything *.dylib/*.so/*.dll) and 0o644 to everything else.
|
||||
func ExtractZip(src, dst string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
dstAbs, err := filepath.Abs(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalSize uint64
|
||||
for _, f := range r.File {
|
||||
totalSize += f.UncompressedSize64
|
||||
if totalSize > maxArchiveBytes {
|
||||
return fmt.Errorf("zip exceeds %d bytes; refusing", maxArchiveBytes)
|
||||
}
|
||||
if err := extractZipEntry(f, dstAbs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractZipEntry(f *zip.File, dstAbs string) error {
|
||||
// Reject absolute paths and any traversal segments. filepath.Clean
|
||||
// collapses redundant separators but does NOT resolve symlinks or strip
|
||||
// leading slashes — we have to do both explicitly.
|
||||
name := f.Name
|
||||
if strings.ContainsRune(name, 0) {
|
||||
return fmt.Errorf("zip entry name contains NUL: %q", name)
|
||||
}
|
||||
cleaned := filepath.Clean(name)
|
||||
if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") ||
|
||||
strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("zip entry escapes destination: %q", name)
|
||||
}
|
||||
|
||||
target := filepath.Join(dstAbs, cleaned)
|
||||
// Defense in depth: even if the checks above missed something, this rel
|
||||
// check guarantees target is under dstAbs.
|
||||
rel, err := filepath.Rel(dstAbs, target)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("zip entry escapes destination: %q", name)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Symlink support: zip entries can be symlinks (mode bit set). The
|
||||
// lark-sec-cli artifact doesn't currently use them, but if it grows to
|
||||
// (e.g. for shared library version aliases) we want graceful handling.
|
||||
if f.Mode()&os.ModeSymlink != 0 {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
linkBytes, readErr := io.ReadAll(io.LimitReader(rc, 1024))
|
||||
rc.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
os.Remove(target) // os.Symlink fails if target exists
|
||||
return os.Symlink(string(linkBytes), target)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
mode := f.Mode().Perm()
|
||||
if mode == 0 {
|
||||
mode = guessMode(cleaned)
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// guessMode supplies executable bits for entries the zip writer didn't tag
|
||||
// with POSIX mode info — typically the case for archives built on Windows.
|
||||
func guessMode(name string) os.FileMode {
|
||||
base := filepath.Base(name)
|
||||
if base == BinaryName() {
|
||||
return 0o755
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(base))
|
||||
switch ext {
|
||||
case ".dylib", ".so", ".dll":
|
||||
return 0o755
|
||||
}
|
||||
if runtime.GOOS != "windows" && !strings.ContainsRune(base, '.') {
|
||||
// Plausibly an extra unix binary shipped alongside the sec-cli binary.
|
||||
return 0o755
|
||||
}
|
||||
return 0o644
|
||||
}
|
||||
121
internal/sec/archive_test.go
Normal file
121
internal/sec/archive_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeZip builds an in-memory zip with the given entries, writes it to path,
|
||||
// and returns nothing — convenience for table-driven tests.
|
||||
type zipEntry struct {
|
||||
name string
|
||||
body string
|
||||
mode os.FileMode
|
||||
symlink string // when set, entry is a symlink with this target
|
||||
}
|
||||
|
||||
func makeZip(t *testing.T, path string, entries []zipEntry) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, e := range entries {
|
||||
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
|
||||
if e.mode != 0 {
|
||||
hdr.SetMode(e.mode)
|
||||
}
|
||||
if e.symlink != "" {
|
||||
hdr.SetMode(os.ModeSymlink | 0o777)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("zip header %q: %v", e.name, err)
|
||||
}
|
||||
body := e.body
|
||||
if e.symlink != "" {
|
||||
body = e.symlink
|
||||
}
|
||||
if _, err := io.WriteString(w, body); err != nil {
|
||||
t.Fatalf("zip write %q: %v", e.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("write zip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZip_HappyPath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "src.zip")
|
||||
makeZip(t, zipPath, []zipEntry{
|
||||
{name: "lark-sec-cli", body: "binary", mode: 0o755},
|
||||
{name: "ca.crt", body: "cert"},
|
||||
{name: "lib/libMetaSecML.dylib", body: "dylib", mode: 0o755},
|
||||
})
|
||||
dst := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ExtractZip(zipPath, dst); err != nil {
|
||||
t.Fatalf("ExtractZip: %v", err)
|
||||
}
|
||||
|
||||
for name, want := range map[string]string{
|
||||
"lark-sec-cli": "binary",
|
||||
"ca.crt": "cert",
|
||||
"lib/libMetaSecML.dylib": "dylib",
|
||||
} {
|
||||
got, err := os.ReadFile(filepath.Join(dst, name))
|
||||
if err != nil {
|
||||
t.Errorf("read %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("%s body = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dst, "lark-sec-cli")); err == nil {
|
||||
if info.Mode().Perm()&0o100 == 0 {
|
||||
t.Errorf("lark-sec-cli not executable: mode=%v", info.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZip_RejectsTraversal(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "evil.zip")
|
||||
makeZip(t, zipPath, []zipEntry{
|
||||
{name: "../../../etc/passwd", body: "pwned"},
|
||||
})
|
||||
dst := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ExtractZip(zipPath, dst); err == nil {
|
||||
t.Fatal("ExtractZip accepted zip-slip entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZip_RejectsAbsolutePath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "abs.zip")
|
||||
makeZip(t, zipPath, []zipEntry{
|
||||
{name: "/etc/passwd", body: "pwned"},
|
||||
})
|
||||
dst := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ExtractZip(zipPath, dst); err == nil {
|
||||
t.Fatal("ExtractZip accepted absolute-path entry")
|
||||
}
|
||||
}
|
||||
33
internal/sec/bootstrap.go
Normal file
33
internal/sec/bootstrap.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// bootstrapManifestJSON is the lark-sec-cli release manifest shipped with this
|
||||
// lark-cli build. It points directly at TOS so a fresh install does not depend
|
||||
// on any external release-tracking service — first install is fully self-contained.
|
||||
//
|
||||
// Updating this file pins a new default version of lark-sec-cli for users who
|
||||
// install via lark-cli. After install, lark-sec-cli is in charge of finding and
|
||||
// applying its own updates; lark-cli does not consult any release server.
|
||||
//
|
||||
//go:embed bootstrap.json
|
||||
var bootstrapManifestJSON []byte
|
||||
|
||||
// LoadBootstrap parses the embedded bootstrap manifest into a Manifest value.
|
||||
func LoadBootstrap() (*Manifest, error) {
|
||||
var entries []Entry
|
||||
if err := json.Unmarshal(bootstrapManifestJSON, &entries); err != nil {
|
||||
return nil, fmt.Errorf("decode embedded bootstrap manifest: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("embedded bootstrap manifest is empty")
|
||||
}
|
||||
return &Manifest{Entries: entries}, nil
|
||||
}
|
||||
59
internal/sec/bootstrap.json
Normal file
59
internal/sec/bootstrap.json
Normal file
@@ -0,0 +1,59 @@
|
||||
[
|
||||
{
|
||||
"key": 0,
|
||||
"buildPlatform": "linux",
|
||||
"urls": [
|
||||
{
|
||||
"urls": {
|
||||
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-amd64/linux-amd64-1.0.1-alpha.23.zip",
|
||||
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-arm64/linux-arm64-1.0.1-alpha.23.zip"
|
||||
},
|
||||
"region": "cn"
|
||||
}
|
||||
],
|
||||
"branch": "dev",
|
||||
"version": "1.0.1-alpha.23",
|
||||
"extra": {
|
||||
"pipeline_id": "367354993",
|
||||
"upload_date": 1778487420795
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": 1,
|
||||
"buildPlatform": "win32",
|
||||
"urls": [
|
||||
{
|
||||
"urls": {
|
||||
"x86": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-386/windows-386-1.0.1-alpha.23.zip",
|
||||
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-amd64/windows-amd64-1.0.1-alpha.23.zip"
|
||||
},
|
||||
"region": "cn"
|
||||
}
|
||||
],
|
||||
"branch": "dev",
|
||||
"version": "1.0.1-alpha.23",
|
||||
"extra": {
|
||||
"pipeline_id": "367354993",
|
||||
"upload_date": 1778487437393
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": 2,
|
||||
"buildPlatform": "darwin",
|
||||
"urls": [
|
||||
{
|
||||
"urls": {
|
||||
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-amd64/darwin-amd64-1.0.1-alpha.23.zip",
|
||||
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-arm64/darwin-arm64-1.0.1-alpha.23.zip"
|
||||
},
|
||||
"region": "cn"
|
||||
}
|
||||
],
|
||||
"branch": "dev",
|
||||
"version": "1.0.1-alpha.23",
|
||||
"extra": {
|
||||
"pipeline_id": "367354993",
|
||||
"upload_date": 1778487395152
|
||||
}
|
||||
}
|
||||
]
|
||||
58
internal/sec/bootstrap_test.go
Normal file
58
internal/sec/bootstrap_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadBootstrap_DecodesAllPlatforms guards against the embedded
|
||||
// manifest becoming malformed or losing an OS — both would break first
|
||||
// install on whatever GOOS lost its entry.
|
||||
func TestLoadBootstrap_DecodesAllPlatforms(t *testing.T) {
|
||||
manifest, err := LoadBootstrap()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBootstrap: %v", err)
|
||||
}
|
||||
platforms := map[string]bool{}
|
||||
for _, e := range manifest.Entries {
|
||||
platforms[e.BuildPlatform] = true
|
||||
if e.Version == "" {
|
||||
t.Errorf("entry %s missing version", e.BuildPlatform)
|
||||
}
|
||||
if e.Extra.PipelineID == "" {
|
||||
t.Errorf("entry %s missing extra.pipeline_id", e.BuildPlatform)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"darwin", "linux", "win32"} {
|
||||
if !platforms[want] {
|
||||
t.Errorf("bootstrap missing platform %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadBootstrap_PickArtifactForCurrentHost ensures the embedded manifest
|
||||
// resolves to a real URL for whatever platform the test runner is on, so a
|
||||
// developer fixing this code locally can still smoke-test their changes.
|
||||
func TestLoadBootstrap_PickArtifactForCurrentHost(t *testing.T) {
|
||||
manifest, err := LoadBootstrap()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBootstrap: %v", err)
|
||||
}
|
||||
art, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("PickArtifact for %s/%s: %v", runtime.GOOS, runtime.GOARCH, err)
|
||||
}
|
||||
if !strings.HasPrefix(art.URL, "https://") {
|
||||
t.Errorf("URL is not https: %q", art.URL)
|
||||
}
|
||||
if !strings.HasSuffix(art.URL, ".zip") {
|
||||
t.Errorf("URL is not a .zip: %q", art.URL)
|
||||
}
|
||||
if art.BuildID == "" {
|
||||
t.Error("BuildID is empty")
|
||||
}
|
||||
}
|
||||
156
internal/sec/download.go
Normal file
156
internal/sec/download.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// downloadMaxBytes caps the artifact size we'll accept. Comfortably over the
|
||||
// real lark-sec-cli zip (~tens of MB) and well under what a malicious mirror
|
||||
// could use to exhaust local disk before we noticed.
|
||||
const downloadMaxBytes = 512 * 1024 * 1024
|
||||
|
||||
// DownloadOptions controls Download.
|
||||
type DownloadOptions struct {
|
||||
URL string
|
||||
Destination string // full path to the .zip we'll create
|
||||
HTTPClient *http.Client
|
||||
|
||||
// ExpectedSHA256, if non-empty, is the hex SHA256 the artifact MUST
|
||||
// match — verified after the full body has been streamed. Use this when
|
||||
// the manifest publishes a hash for the artifact (e.g. bootstrap.json's
|
||||
// `extra.sha256`). Any mismatch fails the download with the .part file
|
||||
// removed.
|
||||
//
|
||||
// When empty (the manifest doesn't carry a hash), the only integrity
|
||||
// check left is the CDN's own `Content-MD5` response header, applied
|
||||
// opportunistically below.
|
||||
ExpectedSHA256 string
|
||||
}
|
||||
|
||||
// Download streams URL to Destination. Writes to a sibling .part file and
|
||||
// renames into place on success so a crashed or aborted run leaves no
|
||||
// half-written zip the next run might mistake for valid.
|
||||
//
|
||||
// Two layers of integrity check, both opt-in:
|
||||
//
|
||||
// 1. ExpectedSHA256 (strong, manifest-provided): cryptographic, fails the
|
||||
// download on mismatch. Use whenever the release manifest carries a hash.
|
||||
// 2. CDN `Content-MD5` header (opportunistic): non-cryptographic, catches
|
||||
// edge replacement or transit corruption when the upstream CDN populates
|
||||
// the header. Runs unconditionally — if the header is present we honour it.
|
||||
//
|
||||
// Neither check defends against a malicious upstream that controls both the
|
||||
// artifact AND the manifest. That class of risk has to be handled by signing
|
||||
// the release pipeline, which is out of scope for the client.
|
||||
func Download(ctx context.Context, opts DownloadOptions) error {
|
||||
if opts.HTTPClient == nil {
|
||||
return fmt.Errorf("Download: HTTPClient is required")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := opts.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s: %w", opts.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: status %d", opts.URL, resp.StatusCode)
|
||||
}
|
||||
|
||||
tmpPath := opts.Destination + ".part"
|
||||
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup := func() { out.Close(); os.Remove(tmpPath) }
|
||||
|
||||
// Hash both ways during the single read pass. Both hashers are cheap and
|
||||
// we don't know yet which check (or both) we'll actually need.
|
||||
sha := sha256.New()
|
||||
md := md5.New()
|
||||
writer := io.MultiWriter(out, sha, md)
|
||||
|
||||
n, err := io.Copy(writer, io.LimitReader(resp.Body, downloadMaxBytes+1))
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("download %s: %w", opts.URL, err)
|
||||
}
|
||||
if n > downloadMaxBytes {
|
||||
cleanup()
|
||||
return fmt.Errorf("download %s: exceeds %d bytes", opts.URL, downloadMaxBytes)
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := verifyChecksums(resp, opts.ExpectedSHA256, sha, md); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("download %s: %w", opts.URL, err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, opts.Destination); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyChecksums applies the two-layer integrity check after the body has
|
||||
// been fully streamed. Returns nil when both layers (whichever apply) agree.
|
||||
func verifyChecksums(resp *http.Response, expectedSHA256 string, sha, md hash.Hash) error {
|
||||
if expectedSHA256 != "" {
|
||||
got := hex.EncodeToString(sha.Sum(nil))
|
||||
if !equalFoldHex(got, expectedSHA256) {
|
||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, got)
|
||||
}
|
||||
}
|
||||
|
||||
if cdnMD5 := resp.Header.Get("Content-MD5"); cdnMD5 != "" {
|
||||
got := base64.StdEncoding.EncodeToString(md.Sum(nil))
|
||||
if got != cdnMD5 {
|
||||
return fmt.Errorf("content-md5 mismatch: cdn=%s, computed=%s", cdnMD5, got)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// equalFoldHex is a non-allocating ASCII case-insensitive compare for hex
|
||||
// strings. SHA256 manifests sometimes ship uppercase, sometimes lowercase.
|
||||
func equalFoldHex(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(a); i++ {
|
||||
ca, cb := a[i], b[i]
|
||||
if 'A' <= ca && ca <= 'Z' {
|
||||
ca += 'a' - 'A'
|
||||
}
|
||||
if 'A' <= cb && cb <= 'Z' {
|
||||
cb += 'a' - 'A'
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
184
internal/sec/download_test.go
Normal file
184
internal/sec/download_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const bodyContent = "lark-sec-cli pretend zip bytes"
|
||||
|
||||
// fixtureSHA256 / fixtureMD5 are the hashes of bodyContent.
|
||||
var fixtureSHA256 string
|
||||
var fixtureMD5b64 string
|
||||
|
||||
func init() {
|
||||
sum := sha256.Sum256([]byte(bodyContent))
|
||||
fixtureSHA256 = hex.EncodeToString(sum[:])
|
||||
m := md5.Sum([]byte(bodyContent))
|
||||
fixtureMD5b64 = base64.StdEncoding.EncodeToString(m[:])
|
||||
}
|
||||
|
||||
func newFixtureServer(t *testing.T, setContentMD5 bool) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if setContentMD5 {
|
||||
w.Header().Set("Content-MD5", fixtureMD5b64)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(bodyContent))
|
||||
}))
|
||||
}
|
||||
|
||||
// TestDownload_HappyPath_NoChecksum confirms that a download with no manifest
|
||||
// SHA and no CDN MD5 succeeds — the integrity hooks are opt-in, not required.
|
||||
func TestDownload_HappyPath_NoChecksum(t *testing.T) {
|
||||
srv := newFixtureServer(t, false)
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != bodyContent {
|
||||
t.Errorf("body roundtrip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_SHA256_Match confirms the manifest-provided SHA256 path
|
||||
// passes for a correct hash. Tests both cases (with and without CDN MD5)
|
||||
// so the second layer doesn't interfere.
|
||||
func TestDownload_SHA256_Match(t *testing.T) {
|
||||
for _, withMD5 := range []bool{false, true} {
|
||||
name := "noMD5"
|
||||
if withMD5 {
|
||||
name = "withCDNMd5"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := newFixtureServer(t, withMD5)
|
||||
defer srv.Close()
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
ExpectedSHA256: fixtureSHA256,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_SHA256_Mismatch is the safety property: a wrong manifest hash
|
||||
// rejects the download AND removes the .part file so the next run doesn't
|
||||
// pick up a poisoned zip.
|
||||
func TestDownload_SHA256_Mismatch(t *testing.T) {
|
||||
srv := newFixtureServer(t, false)
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
ExpectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected sha256 mismatch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Errorf("error should mention sha256 mismatch: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
t.Errorf("dst should not exist after mismatch")
|
||||
}
|
||||
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
|
||||
t.Errorf(".part should not exist after mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_ContentMD5_Mismatch confirms the opportunistic check fires
|
||||
// even when no manifest SHA was provided. Catches a CDN edge that returned
|
||||
// content but a stale/wrong Content-MD5 header (or a poisoned proxy).
|
||||
func TestDownload_ContentMD5_Mismatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-MD5", "Z3JhZmFuYTpyZWFsbHk/Pz8/Pz8/PzA9PT0=") // arbitrary
|
||||
_, _ = w.Write([]byte(bodyContent))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected content-md5 mismatch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "content-md5 mismatch") {
|
||||
t.Errorf("error should mention content-md5 mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_SHA256_CaseInsensitive guards the hex compare against case
|
||||
// drift in the manifest (some publishers upper-case).
|
||||
func TestDownload_SHA256_CaseInsensitive(t *testing.T) {
|
||||
srv := newFixtureServer(t, false)
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
ExpectedSHA256: strings.ToUpper(fixtureSHA256),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download (uppercase sha): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_404_NoPartFile confirms that a non-200 response leaves no
|
||||
// .part file behind to confuse the next attempt.
|
||||
func TestDownload_404_NoPartFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404")
|
||||
}
|
||||
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
|
||||
t.Errorf(".part should not exist after 404")
|
||||
}
|
||||
}
|
||||
297
internal/sec/install.go
Normal file
297
internal/sec/install.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
)
|
||||
|
||||
// Installer orchestrates first-time install of lark-sec-cli:
|
||||
// fetch remote manifest via OAPI → download zip → extract into
|
||||
// versions/<version>/ → swap "current" → write state.json.
|
||||
//
|
||||
// After this first install, lark-sec-cli takes over its own updates and
|
||||
// lark-cli is no longer in the update path. The installer therefore only
|
||||
// knows about the bootstrap path — no Tron, no other release sources.
|
||||
type Installer struct {
|
||||
Paths *Paths
|
||||
HTTPClient *http.Client
|
||||
// APIClientFunc resolves the OAPI client lazily. It is invoked only when
|
||||
// the install pipeline actually needs to fetch the remote manifest —
|
||||
// short-circuits (and other callers of installer() that don't install,
|
||||
// like sec status / sec stop) avoid keychain decryption entirely.
|
||||
APIClientFunc func() (*client.APIClient, error)
|
||||
}
|
||||
|
||||
// InstallOptions tunes a single Install call.
|
||||
type InstallOptions struct {
|
||||
// Force re-runs the pipeline even when an install already exists. Used by
|
||||
// `sec install --force` for repair / re-pinning to the bundled bootstrap.
|
||||
Force bool
|
||||
// Region selects which region's URLs to pick from the manifest. Defaults to
|
||||
// DefaultRegion ("cn"). Reserved for future brand split.
|
||||
Region string
|
||||
// Verbose, when non-nil, is the destination for step-by-step trace output.
|
||||
// nil = silent (production default); typically set to stderr by `sec install -v`.
|
||||
Verbose io.Writer
|
||||
}
|
||||
|
||||
// tracef writes one trace line to w if w is non-nil.
|
||||
func tracef(w io.Writer, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[sec install] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// Install runs the bootstrap pipeline and returns the new State on success.
|
||||
// If a usable install already exists on disk and Force is false, returns the
|
||||
// existing state unchanged (no network call).
|
||||
func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, error) {
|
||||
v := opts.Verbose
|
||||
tracef(v, "ensuring sec paths under %s", i.Paths.InstallDir())
|
||||
if err := i.Paths.Ensure(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracef(v, "loading existing state from %s", i.Paths.StateFile())
|
||||
existing, err := LoadState(i.Paths.StateFile())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sec state: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
tracef(v, "existing state: version=%s binary=%s", existing.Version, existing.BinaryPath)
|
||||
} else {
|
||||
tracef(v, "no existing state on disk")
|
||||
}
|
||||
|
||||
// Idempotent short-circuit: nothing to do if an install is already on disk.
|
||||
// Self-upgrades after bootstrap are lark-sec-cli's job, not ours — see the
|
||||
// upgrade subsystem in lark-sec-cli/internal/upgrade/.
|
||||
if !opts.Force && existing != nil && binaryReady(existing.BinaryPath) {
|
||||
tracef(v, "binary exists at %s — short-circuiting (no network)", existing.BinaryPath)
|
||||
return existing, nil
|
||||
}
|
||||
if opts.Force {
|
||||
tracef(v, "--force set; running full install pipeline")
|
||||
} else {
|
||||
tracef(v, "no usable install on disk; running full install pipeline")
|
||||
}
|
||||
|
||||
region := opts.Region
|
||||
if region == "" {
|
||||
region = DefaultRegion
|
||||
}
|
||||
tracef(v, "region=%s", region)
|
||||
|
||||
if i.APIClientFunc == nil {
|
||||
return nil, errors.New("sec installer: APIClientFunc is required to fetch remote manifest")
|
||||
}
|
||||
tracef(v, "resolving OAPI client (will decrypt credentials)")
|
||||
apiClient, err := i.APIClientFunc()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve api client: %w", err)
|
||||
}
|
||||
platform, arch, err := CurrentPlatformArch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "detected platform=%s arch=%s", platform, arch)
|
||||
|
||||
tracef(v, "fetching remote manifest from %s", secCliManifestPath)
|
||||
rm, err := FetchRemoteManifest(ctx, apiClient, region, platform, arch, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "manifest returned %d url(s): %v", len(rm.URLs), rm.URLs)
|
||||
downloadURL := rm.URLs[0]
|
||||
tracef(v, "picked downloadURL=%s", downloadURL)
|
||||
version, err := versionFromURL(downloadURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "parsed version=%s", version)
|
||||
|
||||
versionDir := i.Paths.VersionDir(version)
|
||||
tracef(v, "creating versionDir=%s", versionDir)
|
||||
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zipPath := filepath.Join(i.Paths.VersionsDir(), version+".zip")
|
||||
|
||||
tracef(v, "downloading %s -> %s", downloadURL, zipPath)
|
||||
if err := Download(ctx, DownloadOptions{
|
||||
URL: downloadURL,
|
||||
Destination: zipPath,
|
||||
HTTPClient: i.HTTPClient,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, statErr := os.Stat(zipPath); statErr == nil {
|
||||
tracef(v, "downloaded %d bytes", info.Size())
|
||||
}
|
||||
defer os.Remove(zipPath) // free disk; we keep the unpacked version dir
|
||||
|
||||
tracef(v, "extracting %s -> %s", zipPath, versionDir)
|
||||
if err := ExtractZip(zipPath, versionDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
binaryPath, err := locateBinary(versionDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "located binary at %s", binaryPath)
|
||||
// Ensure executable bit on POSIX — some zips lose it.
|
||||
if runtime.GOOS != "windows" {
|
||||
if info, err := os.Stat(binaryPath); err == nil {
|
||||
_ = os.Chmod(binaryPath, info.Mode()|0o100|0o010|0o001)
|
||||
}
|
||||
}
|
||||
|
||||
tracef(v, "swapping %s -> %s", i.Paths.CurrentLink(), versionDir)
|
||||
if err := swapCurrent(i.Paths.CurrentLink(), versionDir); err != nil {
|
||||
return nil, fmt.Errorf("swap current: %w", err)
|
||||
}
|
||||
|
||||
tracef(v, "writing state.json to %s", i.Paths.StateFile())
|
||||
state := &State{
|
||||
Version: version,
|
||||
InstalledAt: time.Now().UTC(),
|
||||
BinaryPath: i.Paths.BinaryPath(),
|
||||
}
|
||||
if err := SaveState(i.Paths.StateFile(), state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// locateBinary handles two artifact layouts: flat (zip root has the binary)
|
||||
// and nested (zip root is a single dir containing the binary). The bootstrap
|
||||
// manifest's example payload uses nested ("linux-amd64-1.0.1-alpha.23/...");
|
||||
// we accommodate either since the wrapping dir name could change per build.
|
||||
func locateBinary(versionDir string) (string, error) {
|
||||
name := BinaryName()
|
||||
|
||||
flat := filepath.Join(versionDir, name)
|
||||
if _, err := os.Stat(flat); err == nil {
|
||||
return flat, nil
|
||||
}
|
||||
|
||||
var found string
|
||||
walkErr := filepath.WalkDir(versionDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && d.Name() == name {
|
||||
found = path
|
||||
return fs.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return "", walkErr
|
||||
}
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("binary %q not found under %s", name, versionDir)
|
||||
}
|
||||
|
||||
// Promote the binary's parent to be versionDir so "current" → versionDir
|
||||
// produces a predictable layout. Move the *contents* up rather than the
|
||||
// binary alone, because shared libs may sit beside it.
|
||||
parent := filepath.Dir(found)
|
||||
if parent != versionDir {
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := os.Rename(filepath.Join(parent, e.Name()), filepath.Join(versionDir, e.Name())); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
_ = os.Remove(parent)
|
||||
}
|
||||
return filepath.Join(versionDir, name), nil
|
||||
}
|
||||
|
||||
// swapCurrent atomically points <install>/current at versionDir. On POSIX
|
||||
// we use a symlink with the standard rename-into-place trick; on Windows we
|
||||
// fall back to removing the directory and copying, since junctions need
|
||||
// admin / developer-mode privileges we may not have.
|
||||
func swapCurrent(link, versionDir string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Remove any existing target then copy. This is non-atomic, but
|
||||
// concurrent installs on the same Windows host are not a use case
|
||||
// we support — `sec install` runs interactively.
|
||||
if err := os.RemoveAll(link); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return copyDir(versionDir, link)
|
||||
}
|
||||
|
||||
tmp := link + ".new"
|
||||
_ = os.Remove(tmp)
|
||||
if err := os.Symlink(versionDir, tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, link)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
})
|
||||
}
|
||||
|
||||
func binaryReady(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
139
internal/sec/manifest.go
Normal file
139
internal/sec/manifest.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Manifest describes a lark-sec-cli release set: one Entry per build platform,
|
||||
// each carrying one or more region-scoped URL maps keyed by arch. It's what we
|
||||
// embed at build time as the bootstrap manifest. After bootstrap, lark-sec-cli
|
||||
// queries its own release source for updates — lark-cli is uninvolved.
|
||||
type Manifest struct {
|
||||
Entries []Entry
|
||||
}
|
||||
|
||||
// Entry is one row of the bootstrap manifest, one per published platform.
|
||||
type Entry struct {
|
||||
Key int `json:"key"`
|
||||
BuildPlatform string `json:"buildPlatform"` // "darwin" | "linux" | "win32"
|
||||
URLs []RegionURLs `json:"urls"`
|
||||
Branch string `json:"branch"`
|
||||
Version string `json:"version"`
|
||||
Extra EntryExtra `json:"extra"`
|
||||
}
|
||||
|
||||
// RegionURLs maps an arch ("amd64", "arm64", "x86") to its download URL,
|
||||
// scoped to a region ("cn" today; reserved for future brand split).
|
||||
type RegionURLs struct {
|
||||
URLs map[string]string `json:"urls"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// EntryExtra is metadata the release pipeline emits alongside each artifact.
|
||||
// PipelineID is the build identifier lark-sec-cli will later forward to its
|
||||
// own update server when checking for new versions. SHA256 (when present) is
|
||||
// the hex-encoded hash of the zip artifact; the installer fails the download
|
||||
// on mismatch. Manifests built before the release pipeline added the field
|
||||
// leave it empty, in which case integrity falls back to the CDN's own
|
||||
// Content-MD5 header.
|
||||
type EntryExtra struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
UploadDate int64 `json:"upload_date"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
// Artifact is the resolved download target after platform/arch/region selection.
|
||||
type Artifact struct {
|
||||
URL string
|
||||
Version string
|
||||
BuildID string // pipeline_id — recorded in state.json so lark-sec-cli knows what it was installed at
|
||||
SHA256 string // hex-encoded; empty when the manifest doesn't carry one
|
||||
}
|
||||
|
||||
// PickArtifact selects the right Entry for the current GOOS/GOARCH and the
|
||||
// requested region. Returns a clear error explaining which combination was
|
||||
// missing so users can tell whether the build was never published or just not
|
||||
// for their platform.
|
||||
func (m *Manifest) PickArtifact(goos, goarch, region string) (*Artifact, error) {
|
||||
platform, err := platformKey(goos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arch, err := archKey(goos, goarch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, e := range m.Entries {
|
||||
if e.BuildPlatform != platform {
|
||||
continue
|
||||
}
|
||||
for _, ru := range e.URLs {
|
||||
if ru.Region != region {
|
||||
continue
|
||||
}
|
||||
url, ok := ru.URLs[arch]
|
||||
if !ok || url == "" {
|
||||
continue
|
||||
}
|
||||
return &Artifact{
|
||||
URL: url,
|
||||
Version: e.Version,
|
||||
BuildID: e.Extra.PipelineID,
|
||||
SHA256: e.Extra.SHA256,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no artifact for platform=%s arch=%s region=%s", platform, arch, region)
|
||||
}
|
||||
|
||||
// platformKey maps Go's GOOS to the manifest's buildPlatform enum.
|
||||
func platformKey(goos string) (string, error) {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return "darwin", nil
|
||||
case "linux":
|
||||
return "linux", nil
|
||||
case "windows":
|
||||
return "win32", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported GOOS: %s", goos)
|
||||
}
|
||||
}
|
||||
|
||||
// archKey maps Go's GOARCH to the arch key the manifest uses inside RegionURLs.URLs.
|
||||
// Windows 32-bit ships under "x86" while POSIX 32-bit (e.g. 386 on linux) is not
|
||||
// currently published — surface that as an error rather than silently falling back.
|
||||
func archKey(goos, goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "amd64", nil
|
||||
case "arm64":
|
||||
return "arm64", nil
|
||||
case "386":
|
||||
if goos == "windows" {
|
||||
return "x86", nil
|
||||
}
|
||||
return "", fmt.Errorf("32-bit %s is not published", goos)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported GOARCH: %s", goarch)
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentPlatformArch is a convenience for the install flow.
|
||||
func CurrentPlatformArch() (platform, arch string, err error) {
|
||||
platform, err = platformKey(runtime.GOOS)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
arch, err = archKey(runtime.GOOS, runtime.GOARCH)
|
||||
return platform, arch, err
|
||||
}
|
||||
|
||||
// DefaultRegion is the only region published today for bootstrap installs.
|
||||
// Kept here for callers that still want a single source of truth.
|
||||
const DefaultRegion = "cn"
|
||||
108
internal/sec/manifest_test.go
Normal file
108
internal/sec/manifest_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sampleManifest is the manifest example baked into bootstrap.json, trimmed to
|
||||
// the three published platforms. PickArtifact must select the right URL for
|
||||
// each GOOS/GOARCH combination.
|
||||
func sampleManifest() *Manifest {
|
||||
return &Manifest{Entries: []Entry{
|
||||
{
|
||||
Key: 0,
|
||||
BuildPlatform: "linux",
|
||||
Branch: "dev",
|
||||
Version: "1.0.1-alpha.23",
|
||||
Extra: EntryExtra{PipelineID: "367354993"},
|
||||
URLs: []RegionURLs{{
|
||||
Region: "cn",
|
||||
URLs: map[string]string{
|
||||
"amd64": "https://cdn/linux-amd64.zip",
|
||||
"arm64": "https://cdn/linux-arm64.zip",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Key: 1,
|
||||
BuildPlatform: "win32",
|
||||
Branch: "dev",
|
||||
Version: "1.0.1-alpha.23",
|
||||
Extra: EntryExtra{PipelineID: "367354993"},
|
||||
URLs: []RegionURLs{{
|
||||
Region: "cn",
|
||||
URLs: map[string]string{
|
||||
"x86": "https://cdn/win-386.zip",
|
||||
"amd64": "https://cdn/win-amd64.zip",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Key: 2,
|
||||
BuildPlatform: "darwin",
|
||||
Branch: "dev",
|
||||
Version: "1.0.1-alpha.23",
|
||||
Extra: EntryExtra{PipelineID: "367354993"},
|
||||
URLs: []RegionURLs{{
|
||||
Region: "cn",
|
||||
URLs: map[string]string{
|
||||
"amd64": "https://cdn/darwin-amd64.zip",
|
||||
"arm64": "https://cdn/darwin-arm64.zip",
|
||||
},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestPickArtifact_HappyPath(t *testing.T) {
|
||||
m := sampleManifest()
|
||||
cases := []struct {
|
||||
goos, goarch string
|
||||
wantURL string
|
||||
}{
|
||||
{"darwin", "arm64", "https://cdn/darwin-arm64.zip"},
|
||||
{"darwin", "amd64", "https://cdn/darwin-amd64.zip"},
|
||||
{"linux", "amd64", "https://cdn/linux-amd64.zip"},
|
||||
{"linux", "arm64", "https://cdn/linux-arm64.zip"},
|
||||
{"windows", "amd64", "https://cdn/win-amd64.zip"},
|
||||
{"windows", "386", "https://cdn/win-386.zip"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.goos+"/"+c.goarch, func(t *testing.T) {
|
||||
art, err := m.PickArtifact(c.goos, c.goarch, "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("PickArtifact: %v", err)
|
||||
}
|
||||
if art.URL != c.wantURL {
|
||||
t.Errorf("URL = %q, want %q", art.URL, c.wantURL)
|
||||
}
|
||||
if art.Version != "1.0.1-alpha.23" {
|
||||
t.Errorf("Version = %q", art.Version)
|
||||
}
|
||||
if art.BuildID != "367354993" {
|
||||
t.Errorf("BuildID = %q", art.BuildID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickArtifact_Linux386Rejected(t *testing.T) {
|
||||
if _, err := sampleManifest().PickArtifact("linux", "386", "cn"); err == nil {
|
||||
t.Fatal("expected error for linux/386 (not published)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickArtifact_UnknownRegion(t *testing.T) {
|
||||
if _, err := sampleManifest().PickArtifact("darwin", "arm64", "sg"); err == nil {
|
||||
t.Fatal("expected error for region=sg (not present in fixture)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickArtifact_UnsupportedOS(t *testing.T) {
|
||||
if _, err := sampleManifest().PickArtifact("plan9", "amd64", "cn"); err == nil {
|
||||
t.Fatal("expected error for plan9")
|
||||
}
|
||||
}
|
||||
146
internal/sec/paths.go
Normal file
146
internal/sec/paths.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sec manages the first-time bootstrap install of the lark-sec-cli
|
||||
// sidecar from lark-cli's side: download the artifact, lay it out on disk,
|
||||
// record what version landed. Runtime lifecycle (start / stop / status) is
|
||||
// handled by shelling out to lark-sec-cli's own `service enable / disable /
|
||||
// status` commands, so we don't need pid files / env capture / log tees here.
|
||||
// Updates after install are lark-sec-cli's responsibility, not lark-cli's.
|
||||
package sec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
// envInstallDirOverride lets tests and power users redirect the entire sec
|
||||
// tree (install + data) to a single root. When set, install_dir is <root>
|
||||
// and data_dir is <root>/data — no platform-conventional lookup happens.
|
||||
envInstallDirOverride = "LARKSUITE_CLI_SEC_DIR"
|
||||
)
|
||||
|
||||
// BinaryName returns the executable basename inside the sec-cli artifact for
|
||||
// the current platform:
|
||||
//
|
||||
// darwin → libLarkEntCli.dylib
|
||||
// linux → liblarkentcli.so
|
||||
// windows → lark_enterprise_cli.exe
|
||||
//
|
||||
// The .dylib/.so extensions on POSIX are convention only — those files are
|
||||
// normal Mach-O / ELF executables, not loadable libraries.
|
||||
func BinaryName() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "libLarkEntCli.dylib"
|
||||
case "windows":
|
||||
return "lark_enterprise_cli.exe"
|
||||
default:
|
||||
return "liblarkentcli.so"
|
||||
}
|
||||
}
|
||||
|
||||
// Paths exposes the filesystem layout for the sec sidecar. All methods return
|
||||
// absolute paths; nothing on disk is created — callers must call Ensure().
|
||||
type Paths struct {
|
||||
install string
|
||||
data string
|
||||
}
|
||||
|
||||
// DefaultPaths returns Paths rooted at the platform-conventional user data dir,
|
||||
// or at $LARKSUITE_CLI_SEC_DIR when set.
|
||||
func DefaultPaths() (*Paths, error) {
|
||||
if root := os.Getenv(envInstallDirOverride); root != "" {
|
||||
return &Paths{install: root, data: filepath.Join(root, "data")}, nil
|
||||
}
|
||||
install, data, err := platformDirs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Paths{install: install, data: data}, nil
|
||||
}
|
||||
|
||||
// platformDirs returns (install_dir, data_dir) for the current OS, applying
|
||||
// per-platform conventions:
|
||||
//
|
||||
// macOS install = data = ~/Library/Application Support/lark-cli/sec
|
||||
// Linux install = $XDG_DATA_HOME/lark-cli/sec (fallback ~/.local/share/...)
|
||||
// data = $XDG_STATE_HOME/lark-cli/sec (fallback ~/.local/state/...)
|
||||
// Windows install = data = %LOCALAPPDATA%\lark-cli\sec
|
||||
//
|
||||
// Linux splits install/data along XDG lines; macOS and Windows colocate them
|
||||
// because their conventions don't distinguish "share" from "state" at the
|
||||
// per-user level.
|
||||
func platformDirs() (install, data string, err error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
base := filepath.Join(home, "Library", "Application Support", "lark-cli", "sec")
|
||||
return base, filepath.Join(base, "data"), nil
|
||||
case "windows":
|
||||
appData := os.Getenv("LOCALAPPDATA")
|
||||
if appData == "" {
|
||||
return "", "", errors.New("LOCALAPPDATA is not set")
|
||||
}
|
||||
base := filepath.Join(appData, "lark-cli", "sec")
|
||||
return base, filepath.Join(base, "data"), nil
|
||||
case "linux":
|
||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
stateHome := os.Getenv("XDG_STATE_HOME")
|
||||
if stateHome == "" {
|
||||
stateHome = filepath.Join(home, ".local", "state")
|
||||
}
|
||||
return filepath.Join(dataHome, "lark-cli", "sec"),
|
||||
filepath.Join(stateHome, "lark-cli", "sec"),
|
||||
nil
|
||||
default:
|
||||
base := filepath.Join(home, ".lark-cli", "sec")
|
||||
return base, filepath.Join(base, "data"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure creates the directories the installer writes into.
|
||||
func (p *Paths) Ensure() error {
|
||||
for _, d := range []string{p.install, p.data, p.VersionsDir()} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallDir is the root for binaries and version trees.
|
||||
func (p *Paths) InstallDir() string { return p.install }
|
||||
|
||||
// DataDir is the root for state.json (and anything else lark-cli persists
|
||||
// about the install — currently just state.json).
|
||||
func (p *Paths) DataDir() string { return p.data }
|
||||
|
||||
// VersionsDir stores each unpacked release: versions/<version>/<files>.
|
||||
func (p *Paths) VersionsDir() string { return filepath.Join(p.install, "versions") }
|
||||
|
||||
// VersionDir is the unpack target for a specific version string.
|
||||
func (p *Paths) VersionDir(version string) string {
|
||||
return filepath.Join(p.VersionsDir(), version)
|
||||
}
|
||||
|
||||
// CurrentLink points to the active version (symlink on POSIX, plain copy on Windows).
|
||||
func (p *Paths) CurrentLink() string { return filepath.Join(p.install, "current") }
|
||||
|
||||
// BinaryPath is the active sec-cli executable, addressed through the
|
||||
// `current` symlink so it stays valid across version swaps.
|
||||
func (p *Paths) BinaryPath() string {
|
||||
return filepath.Join(p.CurrentLink(), BinaryName())
|
||||
}
|
||||
|
||||
// StateFile records what version is installed and where its binary lives.
|
||||
func (p *Paths) StateFile() string { return filepath.Join(p.data, "state.json") }
|
||||
51
internal/sec/paths_test.go
Normal file
51
internal/sec/paths_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPaths_OverrideViaEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(envInstallDirOverride, dir)
|
||||
p, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
if p.InstallDir() != dir {
|
||||
t.Errorf("InstallDir = %q, want %q", p.InstallDir(), dir)
|
||||
}
|
||||
if p.DataDir() != filepath.Join(dir, "data") {
|
||||
t.Errorf("DataDir = %q, want %s/data", p.DataDir(), dir)
|
||||
}
|
||||
if !strings.HasPrefix(p.StateFile(), dir) {
|
||||
t.Errorf("StateFile not under override root: %q", p.StateFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaths_Ensure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(envInstallDirOverride, dir)
|
||||
p, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
if err := p.Ensure(); err != nil {
|
||||
t.Fatalf("Ensure: %v", err)
|
||||
}
|
||||
for _, d := range []string{p.InstallDir(), p.DataDir(), p.VersionsDir()} {
|
||||
info, err := os.Stat(d)
|
||||
if err != nil {
|
||||
t.Errorf("missing %s: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("%s is not a directory", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
113
internal/sec/remote_manifest.go
Normal file
113
internal/sec/remote_manifest.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// secCliManifestPath is the OAPI endpoint that returns the per-platform
|
||||
// download URLs for lark-sec-cli, gated by tenant_access_token.
|
||||
const secCliManifestPath = "/open-apis/security_plugin/v1/sec_cli/manifest"
|
||||
|
||||
// xTTEnvEnv, when set, injects an x-tt-env header on the manifest request.
|
||||
// Used for BOE / sub-environment routing (e.g. value "boe_tns_api"). Unset
|
||||
// in prod — the gateway treats absence as "no override". This is the only
|
||||
// debug-routing knob in this file; brand/domain switching itself is handled
|
||||
// at the network layer via the lark-env.sh Whistle pattern in the
|
||||
// lark-cli maintainer doc.
|
||||
const xTTEnvEnv = "LARKSUITE_CLI_X_TT_ENV"
|
||||
|
||||
// RemoteManifest is the payload returned by GET /open-apis/security_plugin/v1/sec_cli/manifest
|
||||
// for a single (region, platform, arch) combination. The server returns only
|
||||
// the download URLs; version metadata is parsed from the URL itself (see
|
||||
// versionFromURL).
|
||||
type RemoteManifest struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
// FetchRemoteManifest calls the OAPI manifest endpoint with TAT (bot) auth
|
||||
// and returns the typed payload for the given region/platform/arch. When the
|
||||
// LARKSUITE_CLI_X_TT_ENV env var is set, its value is sent as an x-tt-env
|
||||
// request header for sub-environment routing.
|
||||
//
|
||||
// Errors are returned as-is — there is no fallback to the embedded
|
||||
// bootstrap manifest. Callers that need offline behavior must handle that
|
||||
// explicitly.
|
||||
func FetchRemoteManifest(
|
||||
ctx context.Context,
|
||||
ac *client.APIClient,
|
||||
region, platform, arch string,
|
||||
verbose io.Writer,
|
||||
) (*RemoteManifest, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: secCliManifestPath,
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"region": []string{region},
|
||||
"platform": []string{platform},
|
||||
"arch": []string{arch},
|
||||
},
|
||||
}
|
||||
tracef(verbose, "GET %s?region=%s&platform=%s&arch=%s as=bot", secCliManifestPath, region, platform, arch)
|
||||
|
||||
var extraOpts []larkcore.RequestOptionFunc
|
||||
if v := os.Getenv(xTTEnvEnv); v != "" {
|
||||
h := http.Header{}
|
||||
h.Set("x-tt-env", v)
|
||||
extraOpts = append(extraOpts, larkcore.WithHeaders(h))
|
||||
tracef(verbose, "injecting header x-tt-env=%s (from %s)", v, xTTEnvEnv)
|
||||
}
|
||||
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsBot, extraOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sec_cli manifest request: %w", err)
|
||||
}
|
||||
tracef(verbose, "response status=%d body-len=%d body=%q", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
|
||||
|
||||
var env struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data *RemoteManifest `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &env); err != nil {
|
||||
// Print body unconditionally on decode failure — a malformed response is
|
||||
// the most common case where the caller needs to see exactly what arrived.
|
||||
fmt.Fprintf(os.Stderr, "[sec_cli manifest] decode failed; status=%d len=%d body=%q\n", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
|
||||
return nil, fmt.Errorf("sec_cli manifest decode: %w", err)
|
||||
}
|
||||
if env.Code != 0 {
|
||||
return nil, fmt.Errorf("sec_cli manifest error %d: %s", env.Code, env.Msg)
|
||||
}
|
||||
if env.Data == nil || len(env.Data.URLs) == 0 {
|
||||
return nil, fmt.Errorf("sec_cli manifest: no urls for region=%s platform=%s arch=%s", region, platform, arch)
|
||||
}
|
||||
return env.Data, nil
|
||||
}
|
||||
|
||||
// versionFromURL extracts the release version from a download URL of the form
|
||||
// .../releases/<version>/<pipeline-id>/<platform-arch>/<archive>.zip
|
||||
// The server-side manifest does not return version as a discrete field;
|
||||
// state.json's Version needs *something* to disambiguate concurrent installs
|
||||
// in versions/<version>/, so we parse it out here.
|
||||
var releaseVersionRE = regexp.MustCompile(`/releases/([^/]+)/`)
|
||||
|
||||
func versionFromURL(u string) (string, error) {
|
||||
m := releaseVersionRE.FindStringSubmatch(u)
|
||||
if len(m) < 2 || m[1] == "" {
|
||||
return "", fmt.Errorf("could not parse release version from URL %q", u)
|
||||
}
|
||||
return m[1], nil
|
||||
}
|
||||
79
internal/sec/state.go
Normal file
79
internal/sec/state.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State is the JSON document at <data>/state.json describing the currently
|
||||
// installed lark-sec-cli artifact. It is the source of truth for what binary
|
||||
// to launch. After bootstrap install lark-sec-cli may upgrade itself in
|
||||
// place — when that happens this state file is informational only; the
|
||||
// daemon owns its own canonical version state.
|
||||
type State struct {
|
||||
Version string `json:"version"`
|
||||
BuildID string `json:"build_id"`
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
}
|
||||
|
||||
// LoadState reads state.json. Returns (nil, nil) when the file is absent —
|
||||
// callers treat that as "not yet installed". Decode errors are surfaced
|
||||
// so a corrupt file is never silently overwritten.
|
||||
func LoadState(path string) (*State, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s State
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// SaveState writes state.json atomically: a tmpfile next to the target is
|
||||
// fsynced then renamed in, so concurrent readers either see the previous
|
||||
// state or the new one — never a torn write.
|
||||
func SaveState(path string, s *State) error {
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dirOf(path), ".state-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op after a successful Rename
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func dirOf(path string) string {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' || path[i] == '\\' {
|
||||
return path[:i]
|
||||
}
|
||||
}
|
||||
return "."
|
||||
}
|
||||
48
internal/sec/state_test.go
Normal file
48
internal/sec/state_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveLoadState_Roundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
|
||||
in := &State{
|
||||
Version: "1.2.3",
|
||||
BuildID: "build-42",
|
||||
InstalledAt: time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC),
|
||||
BinaryPath: "/tmp/lark-sec-cli",
|
||||
}
|
||||
if err := SaveState(path, in); err != nil {
|
||||
t.Fatalf("SaveState: %v", err)
|
||||
}
|
||||
got, err := LoadState(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("LoadState returned nil")
|
||||
}
|
||||
if got.Version != in.Version || got.BuildID != in.BuildID || got.BinaryPath != in.BinaryPath {
|
||||
t.Errorf("roundtrip mismatch: got=%+v want=%+v", got, in)
|
||||
}
|
||||
if !got.InstalledAt.Equal(in.InstalledAt) {
|
||||
t.Errorf("InstalledAt mismatch: got=%v want=%v", got.InstalledAt, in.InstalledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadState_AbsentFile(t *testing.T) {
|
||||
got, err := LoadState(filepath.Join(t.TempDir(), "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for missing file, got %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil state for missing file, got %+v", got)
|
||||
}
|
||||
}
|
||||
135
internal/secplugin/README.md
Normal file
135
internal/secplugin/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# secplugin Usage Guide
|
||||
|
||||
Chinese version: see `README.zh-CN.md`.
|
||||
|
||||
`secplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S)
|
||||
requests to go through a local security proxy and can optionally trust an
|
||||
additional CA certificate bundle.
|
||||
|
||||
It supports two configuration methods:
|
||||
|
||||
1. `sec_config.json`
|
||||
2. `LARKSUITE_CLI_SEC_*` environment variables
|
||||
|
||||
## Config File Location
|
||||
|
||||
Default config file path:
|
||||
|
||||
```text
|
||||
~/.lark-cli/sec_config.json
|
||||
```
|
||||
|
||||
If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes:
|
||||
|
||||
```text
|
||||
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
|
||||
```
|
||||
|
||||
## Option 1: Config File
|
||||
|
||||
Put the following content into `sec_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
|
||||
"LARKSUITE_CLI_SEC_AUTH": true,
|
||||
"LARKSUITE_CLI_APP_ID": "cli_xxx",
|
||||
"LARKSUITE_CLI_BRAND": "feishu",
|
||||
"LARKSUITE_CLI_DEFAULT_AS": "bot",
|
||||
"LARKSUITE_CLI_STRICT_MODE": "bot"
|
||||
}
|
||||
```
|
||||
|
||||
Field descriptions:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`: Enables secplugin. Boolean values are supported.
|
||||
- `LARKSUITE_CLI_SEC_PROXY`: Local HTTP proxy address. It must be `http://127.0.0.1:<port>`.
|
||||
- `LARKSUITE_CLI_SEC_CA`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed.
|
||||
- `LARKSUITE_CLI_SEC_AUTH`: Enables proxy-injected token mode.
|
||||
- `LARKSUITE_CLI_APP_ID`: Optional app ID used in `SEC_AUTH` mode.
|
||||
- `LARKSUITE_CLI_BRAND`: Optional, must be `feishu` or `lark`.
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`: Optional, must be `user`, `bot`, or `auto`.
|
||||
- `LARKSUITE_CLI_STRICT_MODE`: Optional, must be `user`, `bot`, or `off`.
|
||||
|
||||
## Option 2: Environment Variables
|
||||
|
||||
You can also enable secplugin directly with environment variables without
|
||||
creating `sec_config.json`:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_SEC_ENABLE=true
|
||||
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
|
||||
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
|
||||
export LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
If you want to provide app metadata in `SEC_AUTH` mode, set these as well:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_APP_ID=cli_xxx
|
||||
export LARKSUITE_CLI_BRAND=feishu
|
||||
export LARKSUITE_CLI_DEFAULT_AS=bot
|
||||
export LARKSUITE_CLI_STRICT_MODE=bot
|
||||
```
|
||||
|
||||
## Precedence
|
||||
|
||||
The following environment variables override the corresponding fields in
|
||||
`sec_config.json` when they are present:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`
|
||||
- `LARKSUITE_CLI_SEC_PROXY`
|
||||
- `LARKSUITE_CLI_SEC_CA`
|
||||
- `LARKSUITE_CLI_SEC_AUTH`
|
||||
- `LARKSUITE_CLI_APP_ID`
|
||||
- `LARKSUITE_CLI_BRAND`
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`
|
||||
- `LARKSUITE_CLI_STRICT_MODE`
|
||||
|
||||
This means:
|
||||
|
||||
- Put stable defaults in `sec_config.json`.
|
||||
- Use environment variables for temporary overrides.
|
||||
- SEC-related environment variables can work even without a config file.
|
||||
|
||||
## SEC_AUTH Mode
|
||||
|
||||
The CLI enters `SEC_AUTH` mode when both of the following are true:
|
||||
|
||||
```text
|
||||
LARKSUITE_CLI_SEC_ENABLE=true
|
||||
LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
In this mode, the CLI does not read real tokens directly. Instead, it returns
|
||||
placeholder tokens and expects the proxy to replace them with real credentials.
|
||||
|
||||
App information is resolved in this order:
|
||||
|
||||
1. `LARKSUITE_CLI_APP_ID` and `LARKSUITE_CLI_BRAND` from environment variables
|
||||
2. The same fields in `sec_config.json`
|
||||
3. The active profile in the regular CLI `config.json`
|
||||
|
||||
If no valid app information can be resolved from any source, the command fails.
|
||||
|
||||
## Constraints
|
||||
|
||||
- `LARKSUITE_CLI_SEC_PROXY` must use the `http` scheme only.
|
||||
- The host of `LARKSUITE_CLI_SEC_PROXY` must be `127.0.0.1`.
|
||||
- `LARKSUITE_CLI_SEC_PROXY` must not contain a path.
|
||||
- `LARKSUITE_CLI_SEC_CA` must be an absolute path to a PEM file.
|
||||
- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`.
|
||||
|
||||
## Recommendations
|
||||
|
||||
For long-term stable setup, prefer `sec_config.json`:
|
||||
|
||||
- Good for developer machines or controlled environments.
|
||||
- Avoids repeatedly injecting environment variables into the shell.
|
||||
|
||||
For temporary debugging, prefer environment variables:
|
||||
|
||||
- Good for switching proxy or CA for just one session.
|
||||
- No need to modify files on disk.
|
||||
130
internal/secplugin/README.zh-CN.md
Normal file
130
internal/secplugin/README.zh-CN.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# secplugin 使用说明
|
||||
|
||||
English version: see `README.md`.
|
||||
|
||||
`secplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。
|
||||
|
||||
支持两种配置方式:
|
||||
|
||||
1. `sec_config.json`
|
||||
2. `LARKSUITE_CLI_SEC_*` 环境变量
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
默认配置文件路径:
|
||||
|
||||
```text
|
||||
~/.lark-cli/sec_config.json
|
||||
```
|
||||
|
||||
如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为:
|
||||
|
||||
```text
|
||||
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
|
||||
```
|
||||
|
||||
## 方式一:使用配置文件
|
||||
|
||||
在 `sec_config.json` 中写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
|
||||
"LARKSUITE_CLI_SEC_AUTH": true,
|
||||
"LARKSUITE_CLI_APP_ID": "cli_xxx",
|
||||
"LARKSUITE_CLI_BRAND": "feishu",
|
||||
"LARKSUITE_CLI_DEFAULT_AS": "bot",
|
||||
"LARKSUITE_CLI_STRICT_MODE": "bot"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`: 是否启用 secplugin,支持布尔值。
|
||||
- `LARKSUITE_CLI_SEC_PROXY`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:<port>`。
|
||||
- `LARKSUITE_CLI_SEC_CA`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。
|
||||
- `LARKSUITE_CLI_SEC_AUTH`: 是否启用代理注入 token 模式。
|
||||
- `LARKSUITE_CLI_APP_ID`: 可选,`SEC_AUTH` 模式下使用的应用 ID。
|
||||
- `LARKSUITE_CLI_BRAND`: 可选,取值为 `feishu` 或 `lark`。
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`: 可选,取值为 `user`、`bot` 或 `auto`。
|
||||
- `LARKSUITE_CLI_STRICT_MODE`: 可选,取值为 `user`、`bot` 或 `off`。
|
||||
|
||||
## 方式二:使用环境变量
|
||||
|
||||
也可以不写 `sec_config.json`,直接通过环境变量启用:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_SEC_ENABLE=true
|
||||
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
|
||||
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
|
||||
export LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
如果你在 `SEC_AUTH` 模式下希望同时提供应用信息,也可以继续设置:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_APP_ID=cli_xxx
|
||||
export LARKSUITE_CLI_BRAND=feishu
|
||||
export LARKSUITE_CLI_DEFAULT_AS=bot
|
||||
export LARKSUITE_CLI_STRICT_MODE=bot
|
||||
```
|
||||
|
||||
## 配置优先级
|
||||
|
||||
以下环境变量存在时,会覆盖 `sec_config.json` 中对应字段:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`
|
||||
- `LARKSUITE_CLI_SEC_PROXY`
|
||||
- `LARKSUITE_CLI_SEC_CA`
|
||||
- `LARKSUITE_CLI_SEC_AUTH`
|
||||
- `LARKSUITE_CLI_APP_ID`
|
||||
- `LARKSUITE_CLI_BRAND`
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`
|
||||
- `LARKSUITE_CLI_STRICT_MODE`
|
||||
|
||||
也就是说:
|
||||
|
||||
- 你可以把默认值写进 `sec_config.json`。
|
||||
- 再用环境变量做临时覆盖。
|
||||
- 如果没有配置文件,但设置了 SEC 相关环境变量,也可以正常工作。
|
||||
|
||||
## SEC_AUTH 模式说明
|
||||
|
||||
当同时满足以下条件时,CLI 会进入 `SEC_AUTH` 模式:
|
||||
|
||||
```text
|
||||
LARKSUITE_CLI_SEC_ENABLE=true
|
||||
LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
此时 CLI 不直接读取真实 token,而是返回占位 token,由代理替换成真实凭证。
|
||||
|
||||
应用信息来源优先级如下:
|
||||
|
||||
1. 环境变量中的 `LARKSUITE_CLI_APP_ID` 和 `LARKSUITE_CLI_BRAND`
|
||||
2. `sec_config.json` 中的同名字段
|
||||
3. 常规 CLI 配置文件 `config.json` 的当前 profile
|
||||
|
||||
如果以上来源都拿不到可用应用信息,命令会报错。
|
||||
|
||||
## 参数约束
|
||||
|
||||
- `LARKSUITE_CLI_SEC_PROXY` 只允许 `http` 协议。
|
||||
- `LARKSUITE_CLI_SEC_PROXY` 的 host 必须是 `127.0.0.1`。
|
||||
- `LARKSUITE_CLI_SEC_PROXY` 不能带路径。
|
||||
- `LARKSUITE_CLI_SEC_CA` 必须是 PEM 文件的绝对路径。
|
||||
- 布尔值支持 `true/false`、`1/0`、`on/off`、`yes/no`、`y/n`。
|
||||
|
||||
## 推荐用法
|
||||
|
||||
长期固定配置建议使用 `sec_config.json`:
|
||||
|
||||
- 适合开发机或受控环境的稳定配置。
|
||||
- 避免在 shell 中反复注入环境变量。
|
||||
|
||||
临时调试建议使用环境变量:
|
||||
|
||||
- 适合本次会话临时切换代理或证书。
|
||||
- 不需要修改磁盘上的配置文件。
|
||||
277
internal/secplugin/config.go
Normal file
277
internal/secplugin/config.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package secplugin implements the ~/.lark-cli/sec_config.json based security proxy plugin mode.
|
||||
//
|
||||
// It supports:
|
||||
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
|
||||
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
|
||||
// - optional "proxy injects token" mode via placeholder tokens (SEC_AUTH)
|
||||
//
|
||||
// In sec plugin mode, certain common CLI env vars (APP_ID / BRAND / DEFAULT_AS /
|
||||
// STRICT_MODE) can also be set in sec_config.json so sandboxes can avoid
|
||||
// environment injection. When both are present, environment variables win.
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SEC plugin constants cover the config file name and placeholder token values.
|
||||
const (
|
||||
// ConfigFileName is the fixed config file name under core.GetConfigDir().
|
||||
ConfigFileName = "sec_config.json"
|
||||
|
||||
// SentinelUAT is the placeholder user access token used in SEC_AUTH mode.
|
||||
SentinelUAT = "secplugin-managed-uat"
|
||||
|
||||
// SentinelTAT is the placeholder tenant access token used in SEC_AUTH mode.
|
||||
SentinelTAT = "secplugin-managed-tat"
|
||||
)
|
||||
|
||||
// Config is the on-disk config format. Keys intentionally mirror env var names.
|
||||
type Config struct {
|
||||
// Enable turns on sec plugin transport handling.
|
||||
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
|
||||
|
||||
// Proxy is the fixed HTTP proxy address used for all outbound requests.
|
||||
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
|
||||
|
||||
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
|
||||
CAPath string `json:"LARKSUITE_CLI_SEC_CA"`
|
||||
|
||||
// Auth enables placeholder-token mode for proxy-side credential injection.
|
||||
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
|
||||
|
||||
// Optional defaults for sec plugin mode; env vars override these.
|
||||
// AppID supplies the app ID when the environment does not set one.
|
||||
AppID string `json:"LARKSUITE_CLI_APP_ID,omitempty"`
|
||||
|
||||
// Brand supplies the tenant brand when the environment does not set one.
|
||||
Brand string `json:"LARKSUITE_CLI_BRAND,omitempty"` // feishu | lark
|
||||
|
||||
// DefaultAs supplies the default identity when the environment does not set one.
|
||||
DefaultAs string `json:"LARKSUITE_CLI_DEFAULT_AS,omitempty"` // user | bot | auto
|
||||
|
||||
// StrictMode supplies the strict mode when the environment does not set one.
|
||||
StrictMode string `json:"LARKSUITE_CLI_STRICT_MODE,omitempty"` // user | bot | off
|
||||
}
|
||||
|
||||
// Path returns the absolute path to the sec plugin config file.
|
||||
func Path() string {
|
||||
return filepath.Join(core.GetConfigDir(), ConfigFileName)
|
||||
}
|
||||
|
||||
// loadOnce guards one-time SEC config loading for process-wide transport reuse.
|
||||
var loadOnce sync.Once
|
||||
|
||||
// loadCfg stores the cached SEC config after the first successful Load call.
|
||||
var loadCfg *Config
|
||||
|
||||
// loadErr stores the cached Load error observed during the first load attempt.
|
||||
var loadErr error
|
||||
|
||||
// Load reads ~/.lark-cli/sec_config.json once and caches the parsed result.
|
||||
// Environment variables (CliSec*) take precedence over config file values.
|
||||
//
|
||||
// Returns (nil, nil) only when:
|
||||
// - the config file does not exist AND
|
||||
// - none of the SEC-related env vars are present.
|
||||
func Load() (*Config, error) {
|
||||
loadOnce.Do(func() {
|
||||
// Start from env-only config if any SEC env var is present.
|
||||
cfg, hasEnv, err := loadFromEnv()
|
||||
if err != nil {
|
||||
loadErr = err
|
||||
return
|
||||
}
|
||||
|
||||
p := Path()
|
||||
if _, err := vfs.Stat(p); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// No file: return env-only config (if any), else nil.
|
||||
if hasEnv {
|
||||
loadCfg = cfg
|
||||
} else {
|
||||
loadCfg = nil
|
||||
}
|
||||
loadErr = nil
|
||||
return
|
||||
}
|
||||
loadErr = fmt.Errorf("failed to stat sec plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
b, err := vfs.ReadFile(p)
|
||||
if err != nil {
|
||||
loadErr = fmt.Errorf("failed to read sec plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
var fileCfg Config
|
||||
if err := json.Unmarshal(b, &fileCfg); err != nil {
|
||||
loadErr = fmt.Errorf("invalid sec plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge: file base + env overrides.
|
||||
if cfg == nil {
|
||||
cfg = &fileCfg
|
||||
} else {
|
||||
*cfg = fileCfg
|
||||
applyEnvOverrides(cfg)
|
||||
}
|
||||
loadCfg = cfg
|
||||
})
|
||||
return loadCfg, loadErr
|
||||
}
|
||||
|
||||
// Enabled reports whether SEC plugin mode is enabled.
|
||||
func (c *Config) Enabled() bool { return c != nil && c.Enable }
|
||||
|
||||
// AuthEnabled reports whether SEC_AUTH token placeholder mode is enabled.
|
||||
func (c *Config) AuthEnabled() bool { return c != nil && c.Enable && c.Auth }
|
||||
|
||||
// loadFromEnv builds a config from SEC-related environment variables only.
|
||||
// It reports whether any SEC-related environment variable was present.
|
||||
func loadFromEnv() (*Config, bool, error) {
|
||||
_, hasEnable := os.LookupEnv(envvars.CliSecEnable)
|
||||
_, hasProxy := os.LookupEnv(envvars.CliSecProxy)
|
||||
_, hasCA := os.LookupEnv(envvars.CliSecCA)
|
||||
_, hasAuth := os.LookupEnv(envvars.CliSecAuth)
|
||||
hasAny := hasEnable || hasProxy || hasCA || hasAuth
|
||||
if !hasAny {
|
||||
return nil, false, nil
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err := applyEnvOverrides(cfg); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
// applyEnvOverrides copies SEC-related environment variable values into cfg.
|
||||
func applyEnvOverrides(cfg *Config) error {
|
||||
if v, ok := os.LookupEnv(envvars.CliSecEnable); ok {
|
||||
b, err := parseBoolEnv(envvars.CliSecEnable, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Enable = b
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliSecAuth); ok {
|
||||
b, err := parseBoolEnv(envvars.CliSecAuth, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Auth = b
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliSecProxy); ok {
|
||||
cfg.Proxy = v
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliSecCA); ok {
|
||||
cfg.CAPath = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseBoolEnv accepts common boolean spellings used in environment variables.
|
||||
func parseBoolEnv(name, raw string) (bool, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
// Treat empty as false when explicitly present.
|
||||
return false, nil
|
||||
}
|
||||
switch s {
|
||||
case "1", "true", "on", "yes", "y":
|
||||
return true, nil
|
||||
case "0", "false", "off", "no", "n":
|
||||
return false, nil
|
||||
}
|
||||
if b, err := strconv.ParseBool(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
|
||||
}
|
||||
|
||||
// proxyURL validates the fixed SEC proxy configuration and returns its URL.
|
||||
func (c *Config) proxyURL() (*url.URL, error) {
|
||||
raw := strings.TrimSpace(c.Proxy)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("%s is empty", envvars.CliSecProxy)
|
||||
}
|
||||
redacted := redactProxyURL(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliSecProxy, redacted, err)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
// Security hardening: only allow a loopback proxy. This prevents accidental
|
||||
// cross-machine proxying of credentials/traffic.
|
||||
if u.Hostname() != "127.0.0.1" {
|
||||
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Port() == "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
|
||||
func redactProxyURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && u.User != nil {
|
||||
u.User = url.User("***")
|
||||
return u.String()
|
||||
}
|
||||
// Fallback: handle "user:pass@proxy:8080"
|
||||
if at := strings.LastIndex(raw, "@"); at > 0 {
|
||||
return "***@" + raw[at+1:]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ApplyToTransport clones base and applies SEC plugin settings to the clone.
|
||||
// Caller owns the returned *http.Transport.
|
||||
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport.(*http.Transport)
|
||||
}
|
||||
u, err := c.proxyURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := base.Clone()
|
||||
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
|
||||
if err := applyExtraRootCA(t, c.CAPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
245
internal/secplugin/config_test.go
Normal file
245
internal/secplugin/config_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// unsetEnv clears key for the duration of the test and restores its original value.
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, had := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, old)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
|
||||
func unsetSecPluginEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
unsetEnv(t, envvars.CliSecEnable)
|
||||
unsetEnv(t, envvars.CliSecProxy)
|
||||
unsetEnv(t, envvars.CliSecCA)
|
||||
unsetEnv(t, envvars.CliSecAuth)
|
||||
}
|
||||
|
||||
// writeFile creates parent directories and writes test data for fixtures.
|
||||
func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, perm); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file
|
||||
// or SEC environment overrides exist.
|
||||
func TestLoad_MissingFileReturnsNil(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetSecPluginEnv(t)
|
||||
// TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr
|
||||
// because multiple tests in this package share the package-level Load()
|
||||
// cache via sync.Once.
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("Load() = %#v, want nil (missing file)", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyToTransport_SetsProxy verifies that a valid SEC config installs a fixed proxy.
|
||||
func TestApplyToTransport_SetsProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetSecPluginEnv(t)
|
||||
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "",
|
||||
"LARKSUITE_CLI_SEC_AUTH": false
|
||||
}`), 0600)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
|
||||
base := http.DefaultTransport.(*http.Transport)
|
||||
tr, err := cfg.ApplyToTransport(base)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyToTransport() error = %v", err)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("Proxy func is nil, want fixed proxy")
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Proxy() error = %v", err)
|
||||
}
|
||||
if u == nil || u.String() != "http://127.0.0.1:3128" {
|
||||
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_RejectsNonLoopbackProxy verifies that SEC mode rejects non-loopback proxies.
|
||||
func TestLoad_RejectsNonLoopbackProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetSecPluginEnv(t)
|
||||
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://10.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "",
|
||||
"LARKSUITE_CLI_SEC_AUTH": false
|
||||
}`), 0600)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
_, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
|
||||
if err == nil {
|
||||
t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_ProxyURLRejectsUnsupportedParts verifies the SEC proxy validator
|
||||
// rejects URLs with missing ports, queries, and fragments.
|
||||
func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing explicit port",
|
||||
raw: "http://127.0.0.1",
|
||||
want: "explicit port is required",
|
||||
},
|
||||
{
|
||||
name: "query string",
|
||||
raw: "http://127.0.0.1:3128?foo=bar",
|
||||
want: "query is not allowed",
|
||||
},
|
||||
{
|
||||
name: "fragment",
|
||||
raw: "http://127.0.0.1:3128#frag",
|
||||
want: "fragment is not allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := (&Config{Proxy: tt.raw}).proxyURL()
|
||||
if err == nil {
|
||||
t.Fatalf("proxyURL() error = nil, want substring %q", tt.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_EnvOnlyConfig verifies that SEC settings can come entirely from environment variables.
|
||||
func TestLoad_EnvOnlyConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
|
||||
t.Setenv(envvars.CliSecEnable, "true")
|
||||
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:7777")
|
||||
t.Setenv(envvars.CliSecCA, "")
|
||||
t.Setenv(envvars.CliSecAuth, "true")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
if !cfg.AuthEnabled() {
|
||||
t.Fatalf("cfg.AuthEnabled() = false, want true")
|
||||
}
|
||||
tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyToTransport() error = %v", err)
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Proxy() error = %v", err)
|
||||
}
|
||||
if u == nil || u.String() != "http://127.0.0.1:7777" {
|
||||
t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_EnvOverridesFile verifies that SEC environment variables override file values.
|
||||
func TestLoad_EnvOverridesFile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
|
||||
// File enables with one proxy.
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "",
|
||||
"LARKSUITE_CLI_SEC_AUTH": false
|
||||
}`), 0600)
|
||||
|
||||
// Env overrides: disable + different proxy (should be irrelevant once disabled).
|
||||
t.Setenv(envvars.CliSecEnable, "false")
|
||||
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:9999")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatalf("Load() = nil, want non-nil (file exists)")
|
||||
}
|
||||
if cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = true, want false (env override)")
|
||||
}
|
||||
}
|
||||
51
internal/secplugin/tls_ca.go
Normal file
51
internal/secplugin/tls_ca.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// applyExtraRootCA augments t with an additional PEM bundle used for SEC proxy
|
||||
// TLS interception.
|
||||
func applyExtraRootCA(t *http.Transport, caPath string) error {
|
||||
caPath = strings.TrimSpace(caPath)
|
||||
if caPath == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(caPath) {
|
||||
return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliSecCA, caPath)
|
||||
}
|
||||
pemBytes, err := vfs.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s %q: %w", envvars.CliSecCA, caPath, err)
|
||||
}
|
||||
|
||||
// Start from system pool when possible; if unavailable, create a new pool.
|
||||
pool, _ := x509.SystemCertPool()
|
||||
if pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if ok := pool.AppendCertsFromPEM(pemBytes); !ok {
|
||||
return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliSecCA, caPath)
|
||||
}
|
||||
|
||||
if t.TLSClientConfig == nil {
|
||||
t.TLSClientConfig = &tls.Config{}
|
||||
} else {
|
||||
// Clone to avoid mutating shared config from the base transport.
|
||||
t.TLSClientConfig = t.TLSClientConfig.Clone()
|
||||
}
|
||||
t.TLSClientConfig.MinVersion = tls.VersionTLS12
|
||||
t.TLSClientConfig.RootCAs = pool
|
||||
return nil
|
||||
}
|
||||
138
internal/secplugin/tls_ca_test.go
Normal file
138
internal/secplugin/tls_ca_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests.
|
||||
func mustCreateTestCertPEM(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey() error = %v", err)
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "secplugin-test-ca",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "secplugin-test-ca",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate() error = %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged.
|
||||
func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
if err := applyExtraRootCA(tr, " "); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig != nil {
|
||||
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute.
|
||||
func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
err := applyExtraRootCA(tr, "ca.pem")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsMissingFile verifies read errors for missing PEM bundles.
|
||||
func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem"))
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to read") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want read error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles.
|
||||
func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "invalid.pem")
|
||||
writeFile(t, caPath, []byte("not a pem"), 0600)
|
||||
|
||||
tr := &http.Transport{}
|
||||
err := applyExtraRootCA(tr, caPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent.
|
||||
func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
|
||||
tr := &http.Transport{}
|
||||
if err := applyExtraRootCA(tr, caPath); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig = nil, want initialized config")
|
||||
}
|
||||
if tr.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("RootCAs = nil, want cert pool")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings.
|
||||
func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
|
||||
original := &tls.Config{ServerName: "open.feishu.cn"}
|
||||
tr := &http.Transport{TLSClientConfig: original}
|
||||
if err := applyExtraRootCA(tr, caPath); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig == original {
|
||||
t.Fatal("TLSClientConfig pointer reused, want clone")
|
||||
}
|
||||
if tr.TLSClientConfig.ServerName != original.ServerName {
|
||||
t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName)
|
||||
}
|
||||
if tr.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("RootCAs = nil, want cert pool")
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/secplugin"
|
||||
)
|
||||
|
||||
// Proxy environment constants control shared transport proxy behavior.
|
||||
const (
|
||||
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
||||
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
||||
@@ -36,6 +39,7 @@ func DetectProxyEnv() (key, value string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
|
||||
var proxyWarningOnce sync.Once
|
||||
|
||||
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||
@@ -84,6 +88,31 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
return t
|
||||
})
|
||||
|
||||
// secProxyTransport is a fixed-proxy clone of http.DefaultTransport (with optional
|
||||
// custom root CA), lazily built on first use when sec plugin mode is enabled.
|
||||
var secProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
|
||||
cfg, err := secplugin.Load()
|
||||
if err != nil || cfg == nil || !cfg.Enabled() {
|
||||
return def
|
||||
}
|
||||
t, err := cfg.ApplyToTransport(def)
|
||||
if err != nil {
|
||||
// Fail closed: do not silently fall back to direct egress when the
|
||||
// operator explicitly enabled SEC plugin mode.
|
||||
blocked := def.Clone()
|
||||
blocked.Proxy = func(*http.Request) (*url.URL, error) {
|
||||
return nil, fmt.Errorf("sec plugin enabled but config is invalid: %v", err)
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
|
||||
//
|
||||
// By default it returns http.DefaultTransport — the stdlib-provided
|
||||
@@ -99,6 +128,23 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
|
||||
// (~90s) fires.
|
||||
func SharedTransport() http.RoundTripper {
|
||||
// SEC plugin mode overrides all other proxy behavior (env proxies and
|
||||
// LARK_CLI_NO_PROXY), per operator intent.
|
||||
if cfg, err := secplugin.Load(); err != nil {
|
||||
// Fail closed: if the config file exists but is malformed/unreadable,
|
||||
// do not silently fall back to direct egress.
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return http.DefaultTransport
|
||||
}
|
||||
blocked := def.Clone()
|
||||
blocked.Proxy = func(*http.Request) (*url.URL, error) {
|
||||
return nil, fmt.Errorf("sec plugin config is invalid: %v", err)
|
||||
}
|
||||
return blocked
|
||||
} else if cfg != nil && cfg.Enabled() {
|
||||
return secProxyTransport()
|
||||
}
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
@@ -6,11 +6,43 @@ package util
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// unsetEnv clears key for the duration of the test and restores its original value.
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, had := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, old)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
|
||||
func unsetSecPluginEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
// Ensure developer machine env doesn't accidentally enable SEC plugin mode
|
||||
// and change expectations for SharedTransport().
|
||||
unsetEnv(t, envvars.CliSecEnable)
|
||||
unsetEnv(t, envvars.CliSecProxy)
|
||||
unsetEnv(t, envvars.CliSecCA)
|
||||
unsetEnv(t, envvars.CliSecAuth)
|
||||
}
|
||||
|
||||
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
|
||||
func TestDetectProxyEnv(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
|
||||
// Clear all proxy env vars first
|
||||
for _, k := range proxyEnvKeys {
|
||||
t.Setenv(k, "")
|
||||
@@ -28,7 +60,10 @@ func TestDetectProxyEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
|
||||
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr := SharedTransport()
|
||||
if tr != http.DefaultTransport {
|
||||
@@ -36,7 +71,10 @@ func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
|
||||
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
tr := SharedTransport()
|
||||
if tr == http.DefaultTransport {
|
||||
@@ -51,7 +89,10 @@ func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
|
||||
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
a := SharedTransport()
|
||||
b := SharedTransport()
|
||||
@@ -60,7 +101,10 @@ func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
|
||||
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
||||
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
||||
// http.DefaultTransport, NOT the cached no-proxy clone.
|
||||
@@ -77,7 +121,10 @@ func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
|
||||
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
@@ -90,7 +137,10 @@ func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
|
||||
func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
// Reset the once guard for this test
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
@@ -111,7 +161,10 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings.
|
||||
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
for _, k := range proxyEnvKeys {
|
||||
@@ -126,7 +179,10 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
|
||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||
@@ -140,7 +196,10 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
|
||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||
@@ -160,7 +219,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
||||
func TestRedactProxyURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
@@ -183,7 +245,10 @@ func TestRedactProxyURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials.
|
||||
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||
|
||||
3
main.go
3
main.go
@@ -9,7 +9,8 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
|
||||
_ "github.com/larksuite/cli/extension/credential/secplugin" // activate sec plugin credential provider (SEC_AUTH placeholder tokens)
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.33",
|
||||
"version": "1.0.34",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -149,29 +149,26 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
|
||||
|
||||
uploadAttachmentRT := newBaseTestRuntime(
|
||||
uploadAttachmentRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"record-id": "rec_1",
|
||||
"field-id": "fld_att",
|
||||
"file": "/tmp/report.pdf",
|
||||
"name": "report-final.pdf",
|
||||
},
|
||||
map[string][]string{"file": {"/tmp/report.pdf"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t,
|
||||
BaseRecordUploadAttachment.DryRun(ctx, uploadAttachmentRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_att",
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"POST /open-apis/drive/v1/medias/upload_all",
|
||||
"bitable_file",
|
||||
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"report-final.pdf",
|
||||
`"mime_type":"\u003cdetected_mime_type\u003e"`,
|
||||
`"size":"\u003cfile_size\u003e"`,
|
||||
"deprecated_set_attachment",
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/append_attachments",
|
||||
"report.pdf",
|
||||
`"image_width":"\u003cimage_width_if_image\u003e"`,
|
||||
`"image_height":"\u003cimage_height_if_image\u003e"`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1589,12 +1595,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Run("upload attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.txt")
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.png")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString("hello attachment"); err != nil {
|
||||
t.Fatalf("WriteString() err=%v", err)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
||||
img.Set(0, 0, color.RGBA{R: 255, A: 255})
|
||||
if err := png.Encode(tmpFile, img); err != nil {
|
||||
t.Fatalf("png.Encode() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
@@ -1609,28 +1617,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
@@ -1640,34 +1626,27 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "report.txt",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "base-attachment.png",
|
||||
"size": 73,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1676,11 +1655,10 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "report.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_1"`) || !strings.Contains(got, `"report.txt"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_1"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
@@ -1689,19 +1667,13 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("upload body=%s", uploadBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"existing_tok"`) ||
|
||||
!strings.Contains(updateBody, `"name":"existing.pdf"`) ||
|
||||
!strings.Contains(updateBody, `"size":2048`) ||
|
||||
!strings.Contains(updateBody, `"image_width":640`) ||
|
||||
!strings.Contains(updateBody, `"image_height":480`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
|
||||
!strings.Contains(updateBody, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(appendBody, `"image_width":3`) ||
|
||||
!strings.Contains(appendBody, `"image_height":2`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1728,17 +1700,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -1778,26 +1739,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1806,17 +1764,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_big"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
if !strings.Contains(prepareBody, `"file_name":"`+filepath.Base(tmpFile.Name())+`"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
@@ -1847,14 +1804,11 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"size":20971521`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_big"`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1928,6 +1882,434 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-name-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "renamed.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--name is no longer supported") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download attachment includes extra query parameter", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
extra := `{"bitablePerm":{"tableId":"tbl_x","attachments":{"fld_att":{"rec_x":["box_a"]}}}}`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "box_a",
|
||||
"name": "pic.png",
|
||||
"size": 7,
|
||||
"extra_info": extra,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
downloadStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download?" + url.Values{"extra": []string{extra}}.Encode(),
|
||||
RawBody: []byte("payload"),
|
||||
ContentType: "image/png",
|
||||
}
|
||||
reg.Register(downloadStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "pic.png")); err != nil {
|
||||
t.Fatalf("expected downloaded file: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
got, _ := gotItems[0].(map[string]interface{})
|
||||
if got["file_token"] != "box_a" || got["saved_path"] == "" || got["extra_info_used"] != nil {
|
||||
t.Fatalf("download output=%#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all row attachments when file token omitted", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download without file token requires output directory", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "file.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output must be an existing directory") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "same.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download duplicate requested file token only once", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_a",
|
||||
"--output", "a.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all preflights local target conflicts before writing", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("downloads", "b.txt"), []byte("existing"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "output file already exists: downloads/b.txt") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err == nil {
|
||||
t.Fatalf("a.txt should not be written after preflight conflict")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
Status: 500,
|
||||
RawBody: []byte("server error"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
downloaded, _ := detail["downloaded"].([]map[string]interface{})
|
||||
failed, _ := detail["failed"].([]map[string]interface{})
|
||||
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
|
||||
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected first file to remain: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remove attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
removeStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{"fld_att": []interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(removeStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordRemoveAttachment, []string{
|
||||
"+record-remove-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_b",
|
||||
"--yes",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, `"removed"`) || strings.Contains(got, `"updated"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(removeStub.CapturedBody)
|
||||
if !strings.Contains(body, `"rec_x"`) ||
|
||||
!strings.Contains(body, `"fld_att"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_a"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_b"`) {
|
||||
t.Fatalf("remove body=%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-delete",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
|
||||
"+record-history-list",
|
||||
"+base-get", "+base-copy", "+base-create",
|
||||
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
|
||||
@@ -175,14 +175,15 @@ func TestBaseFieldUpdateRisk(t *testing.T) {
|
||||
|
||||
func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
BaseFieldDelete.Command: BaseFieldDelete.Risk,
|
||||
BaseViewDelete.Command: BaseViewDelete.Risk,
|
||||
BaseRecordDelete.Command: BaseRecordDelete.Risk,
|
||||
BaseFormDelete.Command: BaseFormDelete.Risk,
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
BaseFieldDelete.Command: BaseFieldDelete.Risk,
|
||||
BaseViewDelete.Command: BaseViewDelete.Risk,
|
||||
BaseRecordDelete.Command: BaseRecordDelete.Risk,
|
||||
BaseRecordRemoveAttachment.Command: BaseRecordRemoveAttachment.Risk,
|
||||
BaseFormDelete.Command: BaseFormDelete.Risk,
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
for command, risk := range cases {
|
||||
@@ -338,6 +339,79 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "upload attachment",
|
||||
shortcut: BaseRecordUploadAttachment,
|
||||
wantHelp: []string{
|
||||
"repeat to append multiple attachments in one cell",
|
||||
"max 50 files, max 2GB each",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-upload-attachment",
|
||||
"Repeat --file to append multiple attachments",
|
||||
"Reuse returned file_token values for download/remove",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "download attachment",
|
||||
shortcut: BaseRecordDownloadAttachment,
|
||||
wantHelp: []string{
|
||||
"repeat to download selected files",
|
||||
"omit to download all attachments in the record",
|
||||
"with multiple or omitted file tokens this must be an existing directory",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-download-attachment",
|
||||
"Omit --file-token to download every attachment in the record",
|
||||
"Base attachments should be downloaded with this command",
|
||||
"other download commands may fail",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove attachment",
|
||||
shortcut: BaseRecordRemoveAttachment,
|
||||
wantHelp: []string{
|
||||
"remove from the target cell",
|
||||
"max 50 tokens",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-remove-attachment",
|
||||
"Repeat --file-token",
|
||||
"requires --yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOrder(t *testing.T, help string, before string, after string) {
|
||||
t.Helper()
|
||||
beforeIndex := strings.Index(help, before)
|
||||
|
||||
@@ -8,27 +8,37 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseAttachmentMaxBatchSize = 50
|
||||
baseAttachmentGetMaxRecords = 10
|
||||
)
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-upload-attachment",
|
||||
Description: "Upload a local file to a Base attachment field and write it into the target record",
|
||||
Description: "Upload one or more local files and append the returned file_token values to a Base attachment cell",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
@@ -37,34 +47,99 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
{Name: "file", Type: "string_array", Desc: "local file path; repeat to append multiple attachments in one cell; max 50 files, max 2GB each; files > 20MB use multipart upload automatically", Required: true},
|
||||
{Name: "name", Desc: "deprecated; attachment names are derived from local file basenames", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-upload-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file ./report.pdf`,
|
||||
`Repeat --file to append multiple attachments: --file ./report.pdf --file ./screenshot.png`,
|
||||
`Reuse returned file_token values for download/remove`,
|
||||
},
|
||||
DryRun: dryRunRecordUploadAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordUploadAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUploadAttachment(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseRecordDownloadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-download-attachment",
|
||||
Description: "Download Base record attachments by record-id, optionally filtering by file-token",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read", "docs:document.media:download"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token returned by Base; repeat to download selected files; omit to download all attachments in the record", Required: false},
|
||||
{Name: "output", Desc: "local save path; with exactly one file token this may be a file path; with multiple or omitted file tokens this must be an existing directory", Required: true},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-download-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --file-token <file_token> --output ./downloads/`,
|
||||
`Omit --file-token to download every attachment in the record.`,
|
||||
`Base attachments should be downloaded with this command; other download commands may fail for Base attachment files.`,
|
||||
`With one --file-token, --output may be a file path or directory; with multiple or omitted --file-token values, --output must be an existing directory.`,
|
||||
},
|
||||
DryRun: dryRunRecordDownloadAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordDownloadAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordDownloadAttachment(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseRecordRemoveAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-remove-attachment",
|
||||
Description: "Remove one or more file_token values from a Base record attachment cell",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:record:update", "base:field:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
|
||||
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
||||
`This is a high-risk write command and requires --yes.`,
|
||||
},
|
||||
DryRun: dryRunRecordRemoveAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordRemoveAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordRemoveAttachment(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
filePath := runtime.Str("file")
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
files := runtime.StrArray("file")
|
||||
filePath := "<file>"
|
||||
fileName := "<local_file_name>"
|
||||
if len(files) > 0 {
|
||||
filePath = files[0]
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
Desc("3-step orchestration: validate attachment field → upload local file(s) to Base → append uploaded file token(s) to the attachment cell").
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Desc("[1] Read target field and ensure it is an attachment field").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("field_id", runtime.Str("field-id")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("field_id", runtime.Str("field-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Desc("[2a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
@@ -72,7 +147,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
Desc("[2b] Upload attachment parts (repeated for each large file)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
@@ -80,14 +155,14 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
Desc("[2c] Finalize multipart attachment upload and get file token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
} else {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Desc("[2] Upload local file(s) to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
@@ -97,46 +172,87 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/append_attachments").
|
||||
Desc("[3] Append uploaded file token(s) to the target attachment cell").
|
||||
Body(map[string]interface{}{
|
||||
"<attachment_field_name>": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "<existing_file_token>",
|
||||
"name": "<existing_file_name>",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"name": fileName,
|
||||
"mime_type": "<detected_mime_type>",
|
||||
"size": "<file_size>",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
runtime.Str("record-id"): map[string]interface{}{
|
||||
runtime.Str("field-id"): []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"image_width": "<image_width_if_image>",
|
||||
"image_height": "<image_height_if_image>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
filePath := runtime.Str("file")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
func dryRunRecordDownloadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: read Base attachment metadata → download each requested attachment file").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/get_attachments").
|
||||
Desc("[1] Read attachment metadata for the record").
|
||||
Body(map[string]interface{}{"record_id_list": []string{runtime.Str("record-id")}}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
GET("/open-apis/drive/v1/medias/:file_token/download").
|
||||
Desc("[2] Download attachment media through the Base attachment flow").
|
||||
Set("file_token", "<file_token>").
|
||||
Set("output", runtime.Str("output")).
|
||||
Params(map[string]interface{}{"extra": "<extra_info_if_present>"})
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), runtime.Str("field-id"), fileTokenPatchItems(runtime.StrArray("file-token")))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/remove_attachments").
|
||||
Desc("Remove attachment file token(s) from the target attachment cell").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("name") {
|
||||
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||
}
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range files {
|
||||
if _, err := validateAttachmentInputFile(runtime, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
||||
if statErr != nil || !info.IsDir() {
|
||||
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
_, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
return err
|
||||
}
|
||||
|
||||
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
||||
@@ -146,44 +262,172 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
resolvedFieldID = runtime.Str("field-id")
|
||||
}
|
||||
|
||||
record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-id"))
|
||||
appendItems := make([]interface{}, 0, len(files))
|
||||
for _, filePath := range files {
|
||||
fileInfo, err := validateAttachmentInputFile(runtime, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appendItems = append(appendItems, attachmentAppendItem(attachment))
|
||||
}
|
||||
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, appendItems)
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "append_attachments"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attachments, err := mergeRecordAttachments(record, fieldName(field), attachment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
fieldName(field): attachments,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"record": data,
|
||||
"attachment": attachment,
|
||||
"attachments": attachments,
|
||||
"updated": true,
|
||||
}, nil)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
resolvedFieldID = runtime.Str("field-id")
|
||||
}
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, fileTokenPatchItems(tokens))
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "remove_attachments"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordDownloadAttachment(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := fetchBaseAttachments(runtime, runtime.Str("base-token"), baseTableID(runtime), []string{runtime.Str("record-id")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := selectAttachmentDownloadItems(attachments, runtime.Str("record-id"), tokens)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := planAttachmentDownloadTargets(runtime, items, runtime.Str("output"), len(tokens) != 1 || len(items) > 1, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
downloaded := make([]map[string]interface{}, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
failed := attachmentDownloadFailure(target, err)
|
||||
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
|
||||
}
|
||||
downloaded = append(downloaded, saved)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"downloaded": downloaded}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return nil, output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return nil, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil, output.ErrValidation("file path is a directory: %s", filePath)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func normalizeAttachmentFiles(files []string) ([]string, error) {
|
||||
return normalizeStringList(files, stringListNormalizeOptions{
|
||||
typeError: "attachment files must be a string array",
|
||||
emptyError: "provide at least one --file",
|
||||
itemName: "attachment file",
|
||||
duplicateName: "attachment file",
|
||||
limitName: "attachment file count",
|
||||
max: baseAttachmentMaxBatchSize,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeAttachmentFileTokens(tokens []string) ([]string, error) {
|
||||
return normalizeStringList(tokens, stringListNormalizeOptions{
|
||||
typeError: "attachment file tokens must be a string array",
|
||||
emptyError: "provide at least one --file-token",
|
||||
itemName: "attachment file token",
|
||||
duplicateName: "attachment file token",
|
||||
limitName: "attachment file token count",
|
||||
max: baseAttachmentMaxBatchSize,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, error) {
|
||||
if len(tokens) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
normalized := make([]string, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
}
|
||||
normalized = append(normalized, token)
|
||||
}
|
||||
normalized = dedupeStringsPreserveOrder(normalized)
|
||||
if len(normalized) > baseAttachmentMaxBatchSize {
|
||||
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func dedupeStringsPreserveOrder(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
if fio == nil {
|
||||
return false
|
||||
}
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -195,57 +439,24 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
|
||||
func fetchBaseRecord(runtime *common.RuntimeContext, baseToken, tableIDValue, recordID string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, nil)
|
||||
}
|
||||
|
||||
func mergeRecordAttachments(record map[string]interface{}, fieldName string, uploaded map[string]interface{}) ([]interface{}, error) {
|
||||
fields, _ := record["fields"].(map[string]interface{})
|
||||
if fields == nil {
|
||||
return []interface{}{uploaded}, nil
|
||||
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
||||
if len(recordIDs) == 0 {
|
||||
return nil, output.ErrValidation("provide at least one record id")
|
||||
}
|
||||
current, exists := fields[fieldName]
|
||||
if !exists || util.IsNil(current) {
|
||||
return []interface{}{uploaded}, nil
|
||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
}
|
||||
items, ok := current.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current)
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
||||
"record_id_list": recordIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := make([]interface{}, 0, len(items)+1)
|
||||
for _, item := range items {
|
||||
attachment, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q contains unexpected attachment item type %T", fieldName, item)
|
||||
}
|
||||
merged = append(merged, normalizeAttachmentForPatch(attachment))
|
||||
attachments, _ := data["attachments"].(map[string]interface{})
|
||||
if attachments == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
merged = append(merged, uploaded)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} {
|
||||
normalized := map[string]interface{}{}
|
||||
if fileToken, _ := attachment["file_token"].(string); fileToken != "" {
|
||||
normalized["file_token"] = fileToken
|
||||
}
|
||||
if name, _ := attachment["name"].(string); name != "" {
|
||||
normalized["name"] = name
|
||||
}
|
||||
if mimeType, _ := attachment["mime_type"].(string); mimeType != "" {
|
||||
normalized["mime_type"] = mimeType
|
||||
}
|
||||
if size, ok := attachment["size"]; ok && !util.IsNil(size) {
|
||||
normalized["size"] = size
|
||||
}
|
||||
if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) {
|
||||
normalized["image_width"] = imageWidth
|
||||
}
|
||||
if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) {
|
||||
normalized["image_height"] = imageHeight
|
||||
}
|
||||
normalized["deprecated_set_attachment"] = true
|
||||
return normalized
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
@@ -280,15 +491,51 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
"deprecated_set_attachment": true,
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
}
|
||||
if width, height, ok := detectAttachmentImageDimensions(runtime.FileIO(), filePath, mimeType); ok {
|
||||
attachment["image_width"] = width
|
||||
attachment["image_height"] = height
|
||||
} else if attachmentImageDimensionsWarningEnabled(mimeType) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Warning: image dimensions unavailable for %s; attachment may display as square\n", fileName)
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func attachmentAppendItem(attachment map[string]interface{}) map[string]interface{} {
|
||||
item := map[string]interface{}{
|
||||
"file_token": attachment["file_token"],
|
||||
}
|
||||
if width, ok := attachment["image_width"]; ok && !util.IsNil(width) {
|
||||
item["image_width"] = width
|
||||
}
|
||||
if height, ok := attachment["image_height"]; ok && !util.IsNil(height) {
|
||||
item["image_height"] = height
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func fileTokenPatchItems(tokens []string) []interface{} {
|
||||
items := make([]interface{}, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
items = append(items, map[string]interface{}{"file_token": token})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildSingleCellAttachmentsBody(recordID, fieldID string, items []interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
recordID: map[string]interface{}{
|
||||
fieldID: items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
@@ -311,6 +558,309 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
|
||||
func detectAttachmentImageDimensions(fio fileio.FileIO, filePath string, mimeType string) (int, int, bool) {
|
||||
if fio == nil || !strings.HasPrefix(mimeType, "image/") {
|
||||
return 0, 0, false
|
||||
}
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
func attachmentImageDimensionsWarningEnabled(mimeType string) bool {
|
||||
switch mimeType {
|
||||
case "image/gif", "image/jpeg", "image/png":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type baseAttachmentDownloadItem struct {
|
||||
RecordID string
|
||||
FieldID string
|
||||
FileToken string
|
||||
Name string
|
||||
Size interface{}
|
||||
ExtraInfo string
|
||||
MimeType string
|
||||
RawPayload map[string]interface{}
|
||||
}
|
||||
|
||||
type baseAttachmentDownloadTarget struct {
|
||||
Item baseAttachmentDownloadItem
|
||||
TargetPath string
|
||||
ResolvedPath string
|
||||
}
|
||||
|
||||
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
||||
recordRaw, ok := attachments[recordID]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
}
|
||||
fields, ok := recordRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||
}
|
||||
byToken := map[string]baseAttachmentDownloadItem{}
|
||||
fieldIDs := make([]string, 0, len(fields))
|
||||
for currentFieldID := range fields {
|
||||
fieldIDs = append(fieldIDs, currentFieldID)
|
||||
}
|
||||
sort.Strings(fieldIDs)
|
||||
for _, currentFieldID := range fieldIDs {
|
||||
rawList := fields[currentFieldID]
|
||||
items, ok := rawList.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||
}
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||
}
|
||||
fileToken, _ := item["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := byToken[fileToken]; exists {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
extraInfo, _ := item["extra_info"].(string)
|
||||
mimeType, _ := item["mime_type"].(string)
|
||||
byToken[fileToken] = baseAttachmentDownloadItem{
|
||||
RecordID: recordID,
|
||||
FieldID: currentFieldID,
|
||||
FileToken: fileToken,
|
||||
Name: name,
|
||||
Size: item["size"],
|
||||
ExtraInfo: extraInfo,
|
||||
MimeType: mimeType,
|
||||
RawPayload: item,
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]baseAttachmentDownloadItem, 0, len(tokens))
|
||||
if len(tokens) == 0 {
|
||||
for _, item := range byToken {
|
||||
result = append(result, item)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
||||
rightName := strings.ToLower(baseAttachmentDownloadName(result[j]))
|
||||
if leftName != rightName {
|
||||
return leftName < rightName
|
||||
}
|
||||
return result[i].FileToken < result[j].FileToken
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
for _, token := range tokens {
|
||||
item, ok := byToken[token]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseAttachmentDownloadItem, outputPath string, outputIsDir bool, overwrite bool) ([]baseAttachmentDownloadTarget, error) {
|
||||
names := downloadTargetNames(items, outputIsDir || outputPathLooksDirectory(runtime, outputPath))
|
||||
targets := make([]baseAttachmentDownloadTarget, 0, len(items))
|
||||
seen := map[string]baseAttachmentDownloadItem{}
|
||||
for _, item := range items {
|
||||
targetName := names[item.FileToken]
|
||||
targetPath := outputPath
|
||||
if targetName != "" {
|
||||
targetPath = filepath.Join(outputPath, targetName)
|
||||
}
|
||||
resolved, err := runtime.ResolveSavePath(targetPath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if previous, exists := seen[resolved]; exists {
|
||||
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||
}
|
||||
seen[resolved] = item
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
targets = append(targets, baseAttachmentDownloadTarget{
|
||||
Item: item,
|
||||
TargetPath: targetPath,
|
||||
ResolvedPath: resolved,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func downloadTargetNames(items []baseAttachmentDownloadItem, outputIsDir bool) map[string]string {
|
||||
if !outputIsDir {
|
||||
return nil
|
||||
}
|
||||
nameCounts := make(map[string]int, len(items))
|
||||
for _, item := range items {
|
||||
nameCounts[baseAttachmentDownloadName(item)]++
|
||||
}
|
||||
names := make(map[string]string, len(items))
|
||||
for _, item := range items {
|
||||
name := baseAttachmentDownloadName(item)
|
||||
if nameCounts[name] > 1 {
|
||||
name = attachmentNameWithTokenSuffix(name, item.FileToken)
|
||||
}
|
||||
names[item.FileToken] = name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func baseAttachmentDownloadName(item baseAttachmentDownloadItem) string {
|
||||
name := filepath.Base(strings.TrimSpace(item.Name))
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
name = item.FileToken
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func attachmentNameWithTokenSuffix(name, fileToken string) string {
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
if stem == "" {
|
||||
stem = name
|
||||
}
|
||||
return stem + "_" + safeAttachmentFileTokenSuffix(fileToken) + ext
|
||||
}
|
||||
|
||||
func safeAttachmentFileTokenSuffix(fileToken string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range fileToken {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('_')
|
||||
}
|
||||
suffix := strings.Trim(b.String(), "_")
|
||||
if suffix == "" {
|
||||
return "file"
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
||||
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
query := larkcore.QueryParams{}
|
||||
if item.ExtraInfo != "" {
|
||||
query.Set("extra", item.ExtraInfo)
|
||||
}
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(item.FileToken)),
|
||||
QueryParams: query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
||||
if savedPath == "" {
|
||||
savedPath = targetPath
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"record_id": item.RecordID,
|
||||
"field_id": item.FieldID,
|
||||
"file_token": item.FileToken,
|
||||
"name": item.Name,
|
||||
"size": item.Size,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"record_id": target.Item.RecordID,
|
||||
"field_id": target.Item.FieldID,
|
||||
"file_token": target.Item.FileToken,
|
||||
"name": target.Item.Name,
|
||||
"target_path": target.TargetPath,
|
||||
"resolved_path": target.ResolvedPath,
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitInternal,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "io",
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func stripMIMEParams(value string) string {
|
||||
if i := strings.IndexByte(value, ';'); i != -1 {
|
||||
value = value[:i]
|
||||
|
||||
@@ -5,6 +5,9 @@ package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -82,6 +85,42 @@ func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentImageDimensions(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
||||
img.Set(0, 0, color.RGBA{G: 255, A: 255})
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("png.Encode() error = %v", err)
|
||||
}
|
||||
fio := attachmentTestFileIO{openFile: newAttachmentTestFile(buf.Bytes())}
|
||||
|
||||
width, height, ok := detectAttachmentImageDimensions(fio, "image.png", "image/png")
|
||||
if !ok || width != 4 || height != 3 {
|
||||
t.Fatalf("detectAttachmentImageDimensions() = (%d,%d,%v), want (4,3,true)", width, height, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentImageDimensionsWarningEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
mimeType string
|
||||
want bool
|
||||
}{
|
||||
{mimeType: "image/gif", want: true},
|
||||
{mimeType: "image/jpeg", want: true},
|
||||
{mimeType: "image/png", want: true},
|
||||
{mimeType: "image/webp", want: false},
|
||||
{mimeType: "application/pdf", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mimeType, func(t *testing.T) {
|
||||
if got := attachmentImageDimensionsWarningEnabled(tt.mimeType); got != tt.want {
|
||||
t.Fatalf("attachmentImageDimensionsWarningEnabled(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordShareLinkCreate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDownloadAttachment,
|
||||
BaseRecordRemoveAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
BaseBaseGet,
|
||||
|
||||
31
shortcuts/common/drive_meta.go
Normal file
31
shortcuts/common/drive_meta.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return GetString(meta, "title"), nil
|
||||
}
|
||||
123
shortcuts/common/drive_meta_test.go
Normal file
123
shortcuts/common/drive_meta_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
var driveMetaTestSeq atomic.Int64
|
||||
|
||||
func TestFetchDriveMetaTitle(t *testing.T) {
|
||||
t.Run("returns title from batch_query response", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "My Document"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "My Document" {
|
||||
t.Errorf("title = %q, want %q", title, "My Document")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns empty string when metas is empty", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "" {
|
||||
t.Errorf("title = %q, want empty string", title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns empty string when meta has no title", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "" {
|
||||
t.Errorf("title = %q, want empty string", title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("propagates API error", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991668,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err == nil {
|
||||
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
runtime := &RuntimeContext{
|
||||
ctx: context.Background(),
|
||||
Config: cfg,
|
||||
Factory: f,
|
||||
resolvedAs: core.AsBot,
|
||||
}
|
||||
return runtime, reg
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -55,3 +56,79 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceRef holds the parsed type and token from a Lark resource URL.
|
||||
type ResourceRef struct {
|
||||
Type string // e.g. "docx", "bitable", "wiki", "sheet", etc.
|
||||
Token string // the token extracted from the URL path
|
||||
}
|
||||
|
||||
// urlPathToType maps URL path prefixes to resource types.
|
||||
// Longer prefixes must come first to avoid false matches
|
||||
// (e.g. "/drive/folder/" before a hypothetical "/drive/").
|
||||
// Aliases (e.g. "/bitable/" → "bitable") must come after the
|
||||
// canonical prefix to keep the list deterministic.
|
||||
var urlPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnote/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
}
|
||||
|
||||
// ParseResourceURL parses a Lark/Feishu URL and extracts the resource type
|
||||
// and token from the URL path. It is the inverse of BuildResourceURL.
|
||||
//
|
||||
// Supported path patterns:
|
||||
//
|
||||
// /docx/TOKEN -> {Type: "docx", Token: TOKEN}
|
||||
// /doc/TOKEN -> {Type: "doc", Token: TOKEN}
|
||||
// /sheets/TOKEN -> {Type: "sheet", Token: TOKEN}
|
||||
// /base/TOKEN -> {Type: "bitable", Token: TOKEN}
|
||||
// /wiki/TOKEN -> {Type: "wiki", Token: TOKEN}
|
||||
// /file/TOKEN -> {Type: "file", Token: TOKEN}
|
||||
// /drive/folder/TOKEN -> {Type: "folder", Token: TOKEN}
|
||||
// /mindnote/TOKEN -> {Type: "mindnote", Token: TOKEN}
|
||||
// /slides/TOKEN -> {Type: "slides", Token: TOKEN}
|
||||
//
|
||||
// Returns (ResourceRef{}, false) when the URL does not match any known pattern.
|
||||
func ParseResourceURL(rawURL string) (ResourceRef, bool) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
path := u.Path
|
||||
|
||||
for _, mapping := range urlPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
// Trim trailing slashes and stop at the next path segment boundary.
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
return ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,102 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestParseResourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantType string
|
||||
wantToken string
|
||||
wantOK bool
|
||||
}{
|
||||
// All 9 supported types
|
||||
{"docx", "https://xxx.feishu.cn/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
{"doc", "https://xxx.feishu.cn/doc/doccnABC", "doc", "doccnABC", true},
|
||||
{"sheet", "https://xxx.feishu.cn/sheets/shtcnABC", "sheet", "shtcnABC", true},
|
||||
{"bitable via /base/", "https://xxx.feishu.cn/base/bascnABC", "bitable", "bascnABC", true},
|
||||
{"bitable via /bitable/", "https://xxx.feishu.cn/bitable/bascnABC", "bitable", "bascnABC", true},
|
||||
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
|
||||
{"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true},
|
||||
{"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true},
|
||||
{"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true},
|
||||
{"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true},
|
||||
|
||||
// Lark domain
|
||||
{"lark docx", "https://xxx.larksuite.com/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
{"lark wiki", "https://xxx.larksuite.com/wiki/wikcnABC", "wiki", "wikcnABC", true},
|
||||
|
||||
// With query parameters
|
||||
{"with query", "https://xxx.feishu.cn/docx/doxcnABC?from=wiki", "docx", "doxcnABC", true},
|
||||
{"with fragment", "https://xxx.feishu.cn/docx/doxcnABC#section", "docx", "doxcnABC", true},
|
||||
|
||||
// With trailing slash
|
||||
{"trailing slash", "https://xxx.feishu.cn/docx/doxcnABC/", "docx", "doxcnABC", true},
|
||||
|
||||
// With extra path segments after token
|
||||
{"extra path", "https://xxx.feishu.cn/docx/doxcnABC/edit", "docx", "doxcnABC", true},
|
||||
|
||||
// Non-Lark host with Lark-like path (host validation is the caller's responsibility)
|
||||
{"non-lark host with lark path", "https://google.com/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
|
||||
// Negative cases
|
||||
{"unrecognized path", "https://xxx.feishu.cn/calendar/calABC", "", "", false},
|
||||
{"non-lark host unrecognized path", "https://example.com/page", "", "", false},
|
||||
{"empty input", "", "", "", false},
|
||||
{"bare token", "doxcnABC", "", "", false},
|
||||
{"invalid url parse", "://not-a-valid-url", "", "", false},
|
||||
{"matching prefix but empty token", "https://xxx.feishu.cn/docx/", "", "", false},
|
||||
{"matching prefix but whitespace-only token", "https://xxx.feishu.cn/docx/ ", "", "", false},
|
||||
{"whitespace-only input", " ", "", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ref, ok := ParseResourceURL(tt.rawURL)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseResourceURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK)
|
||||
}
|
||||
if ok {
|
||||
if ref.Type != tt.wantType {
|
||||
t.Errorf("ParseResourceURL(%q) Type = %q, want %q", tt.rawURL, ref.Type, tt.wantType)
|
||||
}
|
||||
if ref.Token != tt.wantToken {
|
||||
t.Errorf("ParseResourceURL(%q) Token = %q, want %q", tt.rawURL, ref.Token, tt.wantToken)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseResourceURL_RoundTrip verifies that ParseResourceURL is the inverse
|
||||
// of BuildResourceURL for all supported types.
|
||||
func TestParseResourceURL_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
types := []string{"docx", "doc", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"}
|
||||
token := "testTOKEN123"
|
||||
|
||||
for _, kind := range types {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
built := BuildResourceURL(core.BrandFeishu, kind, token)
|
||||
if built == "" {
|
||||
t.Fatalf("BuildResourceURL returned empty for kind %q", kind)
|
||||
}
|
||||
ref, ok := ParseResourceURL(built)
|
||||
if !ok {
|
||||
t.Fatalf("ParseResourceURL(%q) returned ok=false", built)
|
||||
}
|
||||
if ref.Type != kind {
|
||||
t.Errorf("round-trip type mismatch: got %q, want %q", ref.Type, kind)
|
||||
}
|
||||
if ref.Token != token {
|
||||
t.Errorf("round-trip token mismatch: got %q, want %q", ref.Token, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -103,13 +103,15 @@ func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
||||
// payload is under "bot", not "data" as the newer Lark API convention.
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"data"`
|
||||
} `json:"bot"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestFetchBotInfo_Success(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_abc123",
|
||||
"app_name": "TestBot",
|
||||
},
|
||||
@@ -86,7 +86,7 @@ func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_header",
|
||||
"app_name": "HeaderBot",
|
||||
},
|
||||
@@ -119,7 +119,7 @@ func TestFetchBotInfo_OnceSemantics(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_once",
|
||||
"app_name": "OnceBot",
|
||||
},
|
||||
@@ -183,7 +183,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "",
|
||||
"app_name": "EmptyBot",
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -118,7 +119,7 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
return common.FlagErrorf(selectionRequiredMessageV1(mode))
|
||||
}
|
||||
if err := validateSelectionByTitleV1(selTitle); err != nil {
|
||||
return err
|
||||
@@ -127,6 +128,14 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectionRequiredMessageV1(mode string) string {
|
||||
msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
if mode == "replace_all" {
|
||||
msg += ". If you intended to replace the entire document body, use --mode overwrite instead."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func validateSelectionByTitleV1(title string) error {
|
||||
if title == "" {
|
||||
return nil
|
||||
@@ -160,6 +169,16 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
|
||||
// Overwrite replaces the entire document, silently discarding any
|
||||
// whiteboard or file-attachment blocks that cannot be re-created from
|
||||
// Markdown. Pre-fetch the current content and warn when such blocks
|
||||
// are present so the caller can take a backup before proceeding.
|
||||
if runtime.Str("mode") == "overwrite" {
|
||||
if w := warnOverwriteResourceBlocks(runtime); w != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
@@ -197,3 +216,74 @@ func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// resourceBlockRe matches the opening of a <whiteboard …> or <file …> tag
|
||||
// (followed by whitespace, > or /) to avoid false positives on tag names like
|
||||
// <file-view> or prose that merely mentions the word "whiteboard".
|
||||
var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`)
|
||||
|
||||
// warnOverwriteResourceBlocks pre-fetches the current document and returns a
|
||||
// non-empty warning string when the document contains whiteboard or file
|
||||
// attachment blocks that would be permanently deleted by an overwrite. Returns
|
||||
// an empty string (no warning) when the document is clean or the fetch fails
|
||||
// (we never block the overwrite on a best-effort check).
|
||||
//
|
||||
// This function is not unit-tested because it depends on an external MCP call
|
||||
// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks,
|
||||
// which has full table-driven coverage.
|
||||
//
|
||||
// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite
|
||||
// call, even when the document has no resource blocks. The cost is intentional:
|
||||
// the guard is best-effort and silent on failure, so the latency is bounded and
|
||||
// the trade-off is acceptable to avoid silent data loss.
|
||||
func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// skip_task_detail reduces response payload by omitting per-block task
|
||||
// metadata, making the pre-fetch faster and cheaper.
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
// Fetch failed — silently skip the guard rather than blocking overwrite.
|
||||
return ""
|
||||
}
|
||||
md, _ := result["markdown"].(string)
|
||||
return checkOverwriteResourceBlocks(md)
|
||||
}
|
||||
|
||||
// checkOverwriteResourceBlocks scans Markdown for resource block tags that
|
||||
// cannot survive an overwrite: <whiteboard …> and <file …>. Returns a
|
||||
// warning string listing the counts if any are found, empty string otherwise.
|
||||
func checkOverwriteResourceBlocks(markdown string) string {
|
||||
matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1)
|
||||
whiteboards, files := 0, 0
|
||||
for _, m := range matches {
|
||||
switch m[1] {
|
||||
case "whiteboard":
|
||||
whiteboards++
|
||||
case "file":
|
||||
files++
|
||||
}
|
||||
}
|
||||
var found []string
|
||||
if whiteboards == 1 {
|
||||
found = append(found, "1 whiteboard block")
|
||||
} else if whiteboards > 1 {
|
||||
found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards))
|
||||
}
|
||||
if files == 1 {
|
||||
found = append(found, "1 file attachment block")
|
||||
} else if files > 1 {
|
||||
found = append(found, fmt.Sprintf("%d file attachment blocks", files))
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"the document contains %s that cannot be reconstructed from Markdown; "+
|
||||
"overwrite will permanently delete them. "+
|
||||
"Consider fetching a backup with `docs +fetch` before overwriting.",
|
||||
strings.Join(found, " and "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,33 @@ func TestValidCommandsV2(t *testing.T) {
|
||||
|
||||
// ── V1 tests ──
|
||||
|
||||
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_all")
|
||||
for _, needle := range []string{
|
||||
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
|
||||
"replace the entire document body",
|
||||
"--mode overwrite",
|
||||
} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Fatalf("message missing %q: %s", needle, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_range")
|
||||
if strings.Contains(msg, "--mode overwrite") {
|
||||
t.Fatalf("replace_range message should not suggest overwrite: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") {
|
||||
t.Fatalf("unexpected message: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
t.Run("blank whiteboard tags", func(t *testing.T) {
|
||||
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
|
||||
@@ -55,6 +83,72 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckOverwriteResourceBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
wantWarn bool
|
||||
wantSubs []string
|
||||
}{
|
||||
{
|
||||
name: "empty markdown is clean",
|
||||
markdown: "",
|
||||
wantWarn: false,
|
||||
},
|
||||
{
|
||||
name: "plain prose is clean",
|
||||
markdown: "## Heading\n\nsome text",
|
||||
wantWarn: false,
|
||||
},
|
||||
{
|
||||
name: "single whiteboard triggers warning",
|
||||
markdown: `<whiteboard token="abc123"/>`,
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 whiteboard block", "overwrite"},
|
||||
},
|
||||
{
|
||||
name: "multiple whiteboards counted",
|
||||
markdown: "<whiteboard token=\"a\"/>\n<whiteboard token=\"b\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"2 whiteboard blocks"},
|
||||
},
|
||||
{
|
||||
name: "single file attachment triggers warning",
|
||||
markdown: `<file token="tok" name="report.pdf"/>`,
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 file attachment block"},
|
||||
},
|
||||
{
|
||||
name: "multiple file attachments counted",
|
||||
markdown: "<file token=\"a\"/>\n<file token=\"b\"/>\n<file token=\"c\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"3 file attachment blocks"},
|
||||
},
|
||||
{
|
||||
name: "whiteboard and file together both counted",
|
||||
markdown: "<whiteboard token=\"wb\"/>\n<file token=\"f\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 whiteboard block", "1 file attachment block"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := checkOverwriteResourceBlocks(tt.markdown)
|
||||
if (got != "") != tt.wantWarn {
|
||||
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
|
||||
}
|
||||
for _, sub := range tt.wantSubs {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("expected warning to contain %q, got: %s", sub, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhiteboardResult(t *testing.T) {
|
||||
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
@@ -101,3 +195,35 @@ func TestNormalizeWhiteboardResult(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateSelectionByTitleV1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
wantErr bool
|
||||
errSub string
|
||||
}{
|
||||
{name: "empty title is valid", title: "", wantErr: false},
|
||||
{name: "single heading is valid", title: "## Section", wantErr: false},
|
||||
{name: "h1 heading is valid", title: "# Top", wantErr: false},
|
||||
{name: "deep heading is valid", title: "### Sub-section", wantErr: false},
|
||||
{name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"},
|
||||
{name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"},
|
||||
{name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"},
|
||||
{name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateSelectionByTitleV1(tt.title)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) {
|
||||
t.Errorf("expected error to contain %q, got: %v", tt.errSub, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
"docs:document:export",
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
@@ -101,28 +102,38 @@ var DriveExport = common.Shortcut{
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk.
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/docs/v1/content",
|
||||
map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
},
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.DoAPIJSONWithLogID(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
@@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len([]byte(common.GetString(data, "content"))),
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -228,34 +228,6 @@ func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExp
|
||||
return status
|
||||
}
|
||||
|
||||
// fetchDriveMetaTitle looks up the document title so exported files can use a
|
||||
// human-readable default name when possible.
|
||||
func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
// saveContentToOutputDir validates the target path, enforces overwrite policy,
|
||||
// and writes the payload atomically via FileIO.Save.
|
||||
func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
|
||||
|
||||
@@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -132,16 +143,19 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
wantURL: "/open-apis/docs/v1/content",
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
wantFileName: `"file_name": "notes.md"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
@@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document object, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
|
||||
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document.content, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
|
||||
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
183
shortcuts/drive/drive_inspect.go
Normal file
183
shortcuts/drive/drive_inspect.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var DriveInspect = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+inspect",
|
||||
Description: "Inspect a Lark document URL to get its type, title, and canonical token (with wiki unwrapping)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{
|
||||
Name: "url",
|
||||
Desc: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "type",
|
||||
Desc: "document type (required when --url is a bare token; auto-detected for URLs)",
|
||||
Enum: []string{"doc", "docx", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"},
|
||||
},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
if raw == "" {
|
||||
return output.ErrValidation("--url cannot be empty")
|
||||
}
|
||||
|
||||
_, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Not a recognized URL pattern.
|
||||
if strings.Contains(raw, "://") {
|
||||
return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw)
|
||||
}
|
||||
// Bare token: --type is required.
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
|
||||
if ref.Type == "wiki" {
|
||||
dry.Desc("2-step: inspect wiki node, then batch query metadata")
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Inspect wiki node to get underlying document").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[2] Batch query document metadata (title)").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{"doc_token": "<obj_token from step 1>", "doc_type": "<obj_type from step 1>"},
|
||||
},
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
dry.Desc("1-step: batch query document metadata")
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{"doc_token": ref.Token, "doc_type": ref.Type},
|
||||
},
|
||||
})
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
|
||||
// Step 1: Parse URL to extract {type, token}.
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Bare token: use --type.
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
}
|
||||
|
||||
inputURL := raw
|
||||
docType := ref.Type
|
||||
docToken := ref.Token
|
||||
|
||||
var wikiNode map[string]interface{}
|
||||
|
||||
// Step 2: If type is "wiki", unwrap via get_node API.
|
||||
if docType == "wiki" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docToken},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
spaceID := common.GetString(node, "space_id")
|
||||
nodeToken := common.GetString(node, "node_token")
|
||||
|
||||
if objType == "" || objToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
|
||||
wikiNode = map[string]interface{}{
|
||||
"space_id": spaceID,
|
||||
"node_token": nodeToken,
|
||||
"obj_token": objToken,
|
||||
"obj_type": objType,
|
||||
}
|
||||
|
||||
docType = objType
|
||||
docToken = objToken
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki unwrapped to %s: %s\n", docType, common.MaskToken(docToken))
|
||||
}
|
||||
|
||||
// Step 3: Call batch_query to verify and get title.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Build the resolved URL.
|
||||
resolvedURL := common.BuildResourceURL(runtime.Config.Brand, docType, docToken)
|
||||
|
||||
// Step 5: Build output.
|
||||
result := map[string]interface{}{
|
||||
"input_url": inputURL,
|
||||
"type": docType,
|
||||
"title": title,
|
||||
"token": docToken,
|
||||
"url": resolvedURL,
|
||||
}
|
||||
if wikiNode != nil {
|
||||
result["wiki_node"] = wikiNode
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", docType)
|
||||
if title != "" {
|
||||
fmt.Fprintf(w, "Title: %s\n", title)
|
||||
}
|
||||
fmt.Fprintf(w, "Token: %s\n", docToken)
|
||||
if resolvedURL != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", resolvedURL)
|
||||
}
|
||||
if wikiNode != nil {
|
||||
fmt.Fprintf(w, "Wiki: space_id=%s, node_token=%s\n", wikiNode["space_id"], wikiNode["node_token"])
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
466
shortcuts/drive/drive_inspect_test.go
Normal file
466
shortcuts/drive/drive_inspect_test.go
Normal file
@@ -0,0 +1,466 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"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/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestDriveInspectValidate_EmptyURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --url, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_UnsupportedURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://google.com/some/page")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_NonLarkHostWithLarkPath(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://google.com/docx/doxcnLooksValid")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithoutType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token without --type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidWikiURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API step, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Errorf("API URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[0].URL)
|
||||
}
|
||||
// Verify body contains request_docs with the correct token and type.
|
||||
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
|
||||
if !ok || len(reqDocs) != 1 {
|
||||
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
|
||||
}
|
||||
doc, _ := reqDocs[0].(map[string]interface{})
|
||||
if doc["doc_token"] != "doxcnABC" {
|
||||
t.Errorf("doc_token = %v, want doxcnABC", doc["doc_token"])
|
||||
}
|
||||
if doc["doc_type"] != "docx" {
|
||||
t.Errorf("doc_type = %v, want docx", doc["doc_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_WikiURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API steps, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Errorf("step 1 URL = %q, want /open-apis/wiki/v2/spaces/get_node", got.API[0].URL)
|
||||
}
|
||||
// Verify step 1 params contain the wiki token.
|
||||
if got.API[0].Params["token"] != "wikcnABC" {
|
||||
t.Errorf("step 1 params.token = %v, want wikcnABC", got.API[0].Params["token"])
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Errorf("step 2 URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[1].URL)
|
||||
}
|
||||
// Verify step 2 body contains request_docs placeholder.
|
||||
if got.API[1].Body["request_docs"] == nil {
|
||||
t.Error("step 2 body should contain request_docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API step, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestDriveInspectExecute_DocxURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnABC" {
|
||||
t.Errorf("token = %v, want doxcnABC", data["token"])
|
||||
}
|
||||
if data["title"] != "Test Doc" {
|
||||
t.Errorf("title = %v, want Test Doc", data["title"])
|
||||
}
|
||||
if _, ok := data["wiki_node"]; ok {
|
||||
t.Error("wiki_node should not be present for non-wiki URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_WikiURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "doxcnUnwrapped",
|
||||
"space_id": "space123",
|
||||
"node_token": "wikcnNodeToken",
|
||||
"title": "Wiki Doc",
|
||||
"node_type": "origin",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx (unwrapped from wiki)", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnUnwrapped" {
|
||||
t.Errorf("token = %v, want doxcnUnwrapped", data["token"])
|
||||
}
|
||||
if data["title"] != "Wiki Doc" {
|
||||
t.Errorf("title = %v, want Wiki Doc", data["title"])
|
||||
}
|
||||
wikiNode, ok := data["wiki_node"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("wiki_node should be present for wiki URL")
|
||||
}
|
||||
if wikiNode["space_id"] != "space123" {
|
||||
t.Errorf("wiki_node.space_id = %v, want space123", wikiNode["space_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "",
|
||||
"obj_token": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incomplete wiki node data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnBare", "doc_type": "docx", "title": "Bare Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "doxcnBare",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnBare" {
|
||||
t.Errorf("token = %v, want doxcnBare", data["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991668,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for batch_query failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Pretty format outputs to stdout as text, not JSON envelope.
|
||||
// Just verify it didn't error.
|
||||
_ = stdout
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, or wiki delete-space operations",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
|
||||
Risk: "read",
|
||||
// This shortcut multiplexes multiple backend APIs with different scope
|
||||
// requirements, so scenario-specific prechecks are handled in Validate.
|
||||
@@ -28,8 +28,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, or wiki_delete_space tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, or wiki_delete_space", Required: true},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -40,9 +40,10 @@ var DriveTaskResult = common.Shortcut{
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
"wiki_delete_space": true,
|
||||
"wiki_delete_node": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space", scenario)
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
@@ -54,7 +55,7 @@ var DriveTaskResult = common.Shortcut{
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check", "wiki_move", "wiki_delete_space":
|
||||
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for %s scenario", scenario)
|
||||
}
|
||||
@@ -108,6 +109,11 @@ var DriveTaskResult = common.Shortcut{
|
||||
Desc("[1] Query wiki delete-space task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "delete_space"})
|
||||
case "wiki_delete_node":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki delete-node task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "delete_node"})
|
||||
}
|
||||
|
||||
return dry
|
||||
@@ -136,6 +142,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
result, err = queryWikiMoveTask(runtime, taskID)
|
||||
case "wiki_delete_space":
|
||||
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
|
||||
case "wiki_delete_node":
|
||||
result, err = queryWikiDeleteNodeTask(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -236,7 +244,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
|
||||
switch scenario {
|
||||
case "import", "export", "task_check":
|
||||
required = []string{"drive:drive.metadata:readonly"}
|
||||
case "wiki_move", "wiki_delete_space":
|
||||
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
required = []string{"wiki:space:read"}
|
||||
}
|
||||
|
||||
@@ -540,3 +548,64 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
|
||||
"status_msg": label,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryWikiDeleteNodeTask returns the normalized status of an async wiki
|
||||
// delete-node task. For historical reasons the gateway stashes delete-node
|
||||
// status under the generic `simple_task_result` key (NOT `delete_node_result`),
|
||||
// and that object only carries `status` — there is no `status_msg`, so the
|
||||
// label falls back to the status code. Mirrors queryWikiDeleteSpaceTask;
|
||||
// intentionally duplicated here (rather than importing the wiki package) to
|
||||
// keep drive from depending on shortcuts/wiki.
|
||||
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "delete_node"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := common.GetMap(data, "task")
|
||||
if task == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
resolvedTaskID := common.GetString(task, "task_id")
|
||||
if resolvedTaskID == "" {
|
||||
resolvedTaskID = taskID
|
||||
}
|
||||
|
||||
result := common.GetMap(task, "simple_task_result")
|
||||
var status string
|
||||
if result != nil {
|
||||
status = common.GetString(result, "status")
|
||||
}
|
||||
|
||||
// Keep in sync with wiki.parseWikiAsyncTaskStatus / wikiAsyncTaskStatus
|
||||
// classification (intentionally duplicated to avoid a drive→wiki import —
|
||||
// see the doc comment above). If the success/failed/processing rules change
|
||||
// there, mirror the change here.
|
||||
lowered := strings.ToLower(strings.TrimSpace(status))
|
||||
ready := lowered == "success"
|
||||
failed := lowered == "failure" || lowered == "failed"
|
||||
|
||||
resolvedStatus := strings.TrimSpace(status)
|
||||
if resolvedStatus == "" {
|
||||
resolvedStatus = "processing"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "wiki_delete_node",
|
||||
"task_id": resolvedTaskID,
|
||||
"ready": ready,
|
||||
"failed": failed,
|
||||
"status": resolvedStatus,
|
||||
"status_msg": resolvedStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -417,10 +417,10 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
|
||||
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// wiki_move and wiki_delete_space both read wiki task status, so both must
|
||||
// require wiki:space:read. A single table keeps this invariant explicit
|
||||
// without duplicating near-identical test functions per scenario.
|
||||
for _, scenario := range []string{"wiki_move", "wiki_delete_space"} {
|
||||
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
|
||||
// status, so all must require wiki:space:read. A single table keeps this
|
||||
// invariant explicit without duplicating near-identical test functions.
|
||||
for _, scenario := range []string{"wiki_move", "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")
|
||||
@@ -518,6 +518,105 @@ func TestDriveTaskResultWikiDeleteSpaceSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunWikiDeleteNodeIncludesTaskTypeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "wiki_delete_node"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("task-id", "task_del_node_1"); err != nil {
|
||||
t.Fatalf("set --task-id: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Params["task_type"] != "delete_node" {
|
||||
t.Fatalf("wiki delete-node params = %#v, want task_type=delete_node", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiDeleteNodeSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_del_node_1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// Gateway returns delete-node status under the generic
|
||||
// simple_task_result key (NOT delete_node_result), and it
|
||||
// carries only `status` (no status_msg).
|
||||
"simple_task_result": map[string]interface{}{
|
||||
"status": "success",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_delete_node",
|
||||
"--task-id", "task_del_node_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["scenario"] != "wiki_delete_node" || data["task_id"] != "task_del_node_1" {
|
||||
t.Fatalf("unexpected wiki_delete_node envelope: %#v", data)
|
||||
}
|
||||
if data["ready"] != true || data["failed"] != false || data["status"] != "success" {
|
||||
t.Fatalf("unexpected readiness fields: %#v", data)
|
||||
}
|
||||
// simple_task_result has no status_msg; label must fall back to status.
|
||||
if data["status_msg"] != "success" {
|
||||
t.Fatalf("status_msg = %#v, want fallback to status", data["status_msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultRejectsUnknownScenarioListsWikiDeleteNode(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "bogus",
|
||||
"--task-id", "task_x",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki_delete_node") {
|
||||
t.Fatalf("expected unsupported-scenario error listing wiki_delete_node, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -29,5 +29,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSearch,
|
||||
DriveInspect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+search",
|
||||
"+inspect",
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
|
||||
@@ -166,6 +166,7 @@ type DraftProjection struct {
|
||||
LargeAttachmentsSummary []LargeAttachmentSummary `json:"large_attachments_summary,omitempty"`
|
||||
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
|
||||
@@ -140,9 +140,53 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
|
||||
|
||||
proj.LargeAttachmentsSummary = projectLargeAttachments(snapshot.Headers, htmlBody)
|
||||
|
||||
proj.Priority = parsePriorityFromHeaders(snapshot.Headers)
|
||||
|
||||
return proj
|
||||
}
|
||||
|
||||
// parsePriorityFromHeaders derives the read-side priority projection from
|
||||
// EML headers. It mirrors the write-side helper helpers.go:parsePriority
|
||||
// (which translates --set-priority high|normal|low into set_header /
|
||||
// remove_header X-Cli-Priority ops). Lookup order is case-insensitive
|
||||
// via headerValue:
|
||||
// 1. X-Cli-Priority (CLI/OAPI-specific header recognised by
|
||||
// mail-data-access headersToPbBodyExtra)
|
||||
// 2. X-Priority (RFC standard, fallback for IMAP-回灌 historical drafts)
|
||||
//
|
||||
// When neither header is present (including after the write-side translates
|
||||
// --set-priority normal into remove_header X-Cli-Priority), this returns
|
||||
// "normal" — absence of a priority header is the standard email convention
|
||||
// for normal priority. Agents cannot distinguish "explicitly normal" from
|
||||
// "never set" — known limitation.
|
||||
func parsePriorityFromHeaders(headers []Header) string {
|
||||
if v := headerValue(headers, "X-Cli-Priority"); v != "" {
|
||||
return mapPriorityValue(v)
|
||||
}
|
||||
if v := headerValue(headers, "X-Priority"); v != "" {
|
||||
return mapPriorityValue(v)
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// mapPriorityValue normalises a raw priority header value to the projection
|
||||
// vocabulary {"high","normal","low","unknown"}. The accepted input table is
|
||||
// kept in sync with backend gopkg/mail_priority.PriorityValueToType so that
|
||||
// CLI read-side projection observes the same set of values the server
|
||||
// recognises on write.
|
||||
func mapPriorityValue(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "high", "1 (highest)":
|
||||
return "high"
|
||||
case "3", "normal", "3 (normal)":
|
||||
return "normal"
|
||||
case "5", "low", "5 (lowest)":
|
||||
return "low"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// projectLargeAttachments extracts large attachment info from the draft.
|
||||
// It first tries the server-format header (X-Lark-Large-Attachment) which
|
||||
// carries filename and size directly. Falls back to merging CLI-format
|
||||
|
||||
@@ -178,6 +178,170 @@ func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority projection (X-Cli-Priority primary, X-Priority fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestProjectPriorityXCliPriorityHigh(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority high
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 1
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityFallbackXPriorityLow(t *testing.T) {
|
||||
// Only the standard X-Priority header is present (e.g. an IMAP-回灌
|
||||
// historical draft). The fallback path should kick in.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority low (fallback)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: 5
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "low" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "low")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityBothAbsentNormal(t *testing.T) {
|
||||
// Neither header is present — default priority is normal.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: no priority
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityXCliPriorityOutlookStyleHigh(t *testing.T) {
|
||||
// X-Cli-Priority set to the Outlook-style string "high" (any case).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority high (string)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: High
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityUnmappedValueUnknown(t *testing.T) {
|
||||
// Value outside the recognised mapping table (e.g. "urgent") falls
|
||||
// back to "unknown".
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority urgent
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: urgent
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "unknown" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityXCliPriorityWinsOverXPriority(t *testing.T) {
|
||||
// X-Cli-Priority must take precedence over X-Priority when both are
|
||||
// set (defensive: agent or upstream may write both).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: both headers
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 1
|
||||
X-Priority: 5
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q (X-Cli-Priority must win)", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityNormalThree(t *testing.T) {
|
||||
// X-Cli-Priority=3 → "normal" (rare in CLI write path since
|
||||
// `--set-priority normal` actually removes the header, but this case
|
||||
// covers e.g. a draft set by another OAPI client that wrote 3).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority three
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 3
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityFallbackXPriorityNormalString(t *testing.T) {
|
||||
// IMAP-回灌 / external client writes the RFC-standard `X-Priority: Normal`
|
||||
// string. The fallback path must project this as "normal" — symmetric with
|
||||
// how `X-Priority: High` / `Low` are already handled.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority normal (fallback)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: Normal
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityOutlookStyleThreeNormal(t *testing.T) {
|
||||
// Outlook-style `3 (Normal)` parenthesised form — symmetric with the
|
||||
// already-supported `1 (Highest)` / `5 (Lowest)`.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority three (normal)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: 3 (Normal)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingInlineCIDReportedAsProjectionWarning(t *testing.T) {
|
||||
// Missing CID references should NOT prevent parsing; they are reported
|
||||
// as warnings in Project() instead.
|
||||
|
||||
@@ -2602,3 +2602,14 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd
|
||||
senderEmail, toAddrs, ccAddrs,
|
||||
)
|
||||
}
|
||||
|
||||
// validateBotMailboxNotMe rejects the combination of bot identity with --mailbox me.
|
||||
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
|
||||
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
|
||||
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
|
||||
return output.ErrValidation(
|
||||
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " +
|
||||
"pass an explicit email address, e.g. --mailbox alice@example.com")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -293,6 +293,9 @@ func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID stri
|
||||
if len(projection.Warnings) > 0 {
|
||||
fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; ")))
|
||||
}
|
||||
if projection.Priority != "" {
|
||||
fmt.Fprintf(w, "priority: %s\n", sanitizeForTerminal(projection.Priority))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -553,6 +556,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
"`add_inline`/`replace_inline`/`remove_inline` are for CID-based inline images",
|
||||
"`replace_inline` keeps the original filename and content_type when those fields are omitted",
|
||||
"protected headers require `allow_protected_header_edits=true`",
|
||||
"--set-priority high|normal|low controls draft priority via X-Cli-Priority header (CLI/OAPI specific). high → set_header X-Cli-Priority=1; low → set_header X-Cli-Priority=5; normal → remove_header X-Cli-Priority. Backend mail-data-access headersToPbBodyExtra recognizes X-Cli-Priority but not standard X-Priority/Importance for OAPI flow.",
|
||||
},
|
||||
"command_example": "lark-cli mail +draft-edit --print-patch-template",
|
||||
"patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",
|
||||
|
||||
@@ -26,6 +26,9 @@ var MailMessage = common.Shortcut{
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageID := runtime.Str("message-id")
|
||||
|
||||
@@ -34,6 +34,9 @@ var MailMessages = common.Shortcut{
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
|
||||
130
shortcuts/mail/mail_shortcut_validation_test.go
Normal file
130
shortcuts/mail/mail_shortcut_validation_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertValidationError fails the test unless err is a *output.ExitError with
|
||||
// ExitValidation code whose message contains wantSubstr.
|
||||
func assertValidationError(t *testing.T, err error, wantSubstr string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("expected detail type \"validation\", got %+v", exitErr.Detail)
|
||||
}
|
||||
if wantSubstr != "" && !strings.Contains(exitErr.Error(), wantSubstr) {
|
||||
t.Errorf("expected error message to contain %q, got: %v", wantSubstr, exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidatePasses fails the test if err is a validation error; other
|
||||
// errors (e.g. API call failures from missing tokens) are acceptable because
|
||||
// we only care that the Validate callback passed.
|
||||
func assertValidatePasses(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Code == output.ExitValidation {
|
||||
t.Fatalf("Validate callback should have passed but returned validation error: %v", exitErr)
|
||||
}
|
||||
// Non-validation errors (auth/API failures) are expected without HTTP mocks.
|
||||
}
|
||||
|
||||
// TC-1: +message --as bot --mailbox me → ErrValidation
|
||||
func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "bot", "--mailbox", "me", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-2: +message --as bot --mailbox explicit → Validate passes
|
||||
func TestMailMessageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "bot", "--mailbox", "alice@example.com", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-3: +message --as user --mailbox me → Validate passes
|
||||
func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "user", "--mailbox", "me", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-4: +messages --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-5: +messages --as bot --mailbox explicit → Validate passes
|
||||
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-6: +thread --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailThreadBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailThread, []string{
|
||||
"+thread", "--as", "bot", "--thread-id", "thread_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-7: +thread --as bot --mailbox explicit → Validate passes
|
||||
func TestMailThreadBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailThread, []string{
|
||||
"+thread", "--as", "bot", "--mailbox", "alice@example.com", "--thread-id", "thread_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-8: +triage --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailTriageBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--as", "bot",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-9: +triage --as bot --mailbox explicit → Validate passes
|
||||
func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--as", "bot", "--mailbox", "alice@example.com",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
@@ -75,6 +75,9 @@ var MailTemplateCreate = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ var MailTemplateUpdate = common.Shortcut{
|
||||
if runtime.Bool("print-patch-template") {
|
||||
return nil
|
||||
}
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ var MailThread = common.Shortcut{
|
||||
{Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
threadID := runtime.Str("thread-id")
|
||||
|
||||
@@ -64,6 +64,9 @@ var MailTriage = common.Shortcut{
|
||||
{Name: "labels", Type: "bool", Desc: "include label IDs in output"},
|
||||
{Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
query := runtime.Str("query")
|
||||
|
||||
@@ -12,7 +12,10 @@ func Shortcuts() []common.Shortcut {
|
||||
WikiNodeCreate,
|
||||
WikiDeleteSpace,
|
||||
WikiSpaceList,
|
||||
WikiSpaceCreate,
|
||||
WikiNodeList,
|
||||
WikiNodeCopy,
|
||||
WikiNodeGet,
|
||||
WikiNodeDelete,
|
||||
}
|
||||
}
|
||||
|
||||
207
shortcuts/wiki/wiki_async_task.go
Normal file
207
shortcuts/wiki/wiki_async_task.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Shared async-task polling for wiki delete operations. The wiki delete
|
||||
// endpoints (DELETE /spaces/{id}, DELETE /spaces/{id}/nodes/{token}) may
|
||||
// return either an empty task_id (sync completion) or a task_id that must
|
||||
// be polled against /wiki/v2/tasks/{task_id}?task_type=<...>.
|
||||
//
|
||||
// For historical reasons /wiki/v2/tasks/{task_id} stashes the status under a
|
||||
// different key per task type: delete-space uses `delete_space_result`, while
|
||||
// delete-node uses the generic `simple_task_result` (the gateway's reusable
|
||||
// "future async tasks share this" field). move tasks use `move_result` and are
|
||||
// handled separately in wiki_move.go. Every key still exposes a `status`, so
|
||||
// the poll loop / classification is factored out here and the caller passes
|
||||
// the right result key.
|
||||
//
|
||||
// Note: `simple_task_result` only carries `status` (no `status_msg`), so for
|
||||
// delete-node StatusLabel() falls back to the status code — which is fine.
|
||||
|
||||
const (
|
||||
wikiAsyncStatusSuccess = "success"
|
||||
wikiAsyncStatusFailure = "failure"
|
||||
wikiAsyncStatusProcessing = "processing"
|
||||
|
||||
wikiAsyncTaskTypeDeleteSpace = "delete_space"
|
||||
wikiAsyncTaskTypeDeleteNode = "delete_node"
|
||||
|
||||
wikiAsyncResultDeleteSpace = "delete_space_result"
|
||||
// wikiAsyncResultSimpleTask is the generic result key the gateway uses for
|
||||
// delete-node (and intends to reuse for future async task types). It is
|
||||
// NOT `delete_node_result` — that key does not exist in the response.
|
||||
wikiAsyncResultSimpleTask = "simple_task_result"
|
||||
)
|
||||
|
||||
// wikiAsyncTaskStatus is the unified poll-response shape used by every wiki
|
||||
// delete task. The taskID is captured so error/resume hints can name it.
|
||||
type wikiAsyncTaskStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
// normalizedStatus collapses whitespace and case so " SUCCESS " classifies
|
||||
// the same as "success". Ready()/Failed() (control flow) derive from this;
|
||||
// StatusCode()/StatusLabel() (display) deliberately surface the raw backend
|
||||
// value instead. For the real status enums (delete-node: processing/success/
|
||||
// failed; delete-space's documented set) the two agree. They only diverge for
|
||||
// an undocumented status string, which is intentional — an unrecognized status
|
||||
// is shown verbatim rather than masked as a hard failure.
|
||||
func (s wikiAsyncTaskStatus) normalizedStatus() string {
|
||||
return strings.ToLower(strings.TrimSpace(s.Status))
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) Ready() bool {
|
||||
return s.normalizedStatus() == wikiAsyncStatusSuccess
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) Failed() bool {
|
||||
// The sample protocol only documents "success" as a terminal OK. Treat any
|
||||
// explicit "failure"/"failed" signal as terminal, and unknown non-success
|
||||
// values as still-processing so we don't misreport a novel status as a hard
|
||||
// failure.
|
||||
lowered := s.normalizedStatus()
|
||||
return lowered == wikiAsyncStatusFailure || lowered == "failed"
|
||||
}
|
||||
|
||||
// StatusCode returns a never-empty status value for the output envelope. If
|
||||
// the backend response omits delete_*_result.status (or sends whitespace),
|
||||
// fall back to "processing" so the documented timeout-shape stays accurate.
|
||||
func (s wikiAsyncTaskStatus) StatusCode() string {
|
||||
if status := strings.TrimSpace(s.Status); status != "" {
|
||||
return status
|
||||
}
|
||||
return wikiAsyncStatusProcessing
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) StatusLabel() string {
|
||||
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return s.StatusCode()
|
||||
}
|
||||
|
||||
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
|
||||
// translate from runtime.CallAPI responses or test fakes.
|
||||
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
|
||||
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
|
||||
// resultKey selects the right shape ("delete_space_result" for delete-space,
|
||||
// "simple_task_result" for delete-node).
|
||||
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, resultKey)
|
||||
status := wikiAsyncTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
if result != nil {
|
||||
status.Status = common.GetString(result, "status")
|
||||
status.StatusMsg = common.GetString(result, "status_msg")
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// pollWikiAsyncTask runs the bounded polling loop shared by every wiki delete
|
||||
// shortcut. label is the human-readable operation name surfaced in stderr
|
||||
// progress lines ("delete-space" / "delete-node"). nextCommand is the resume
|
||||
// hint embedded into the wrapped error when every poll fails.
|
||||
//
|
||||
// attempts/interval are taken as parameters (instead of consts) so callers
|
||||
// can keep their per-operation tunable constants for back-compat with the
|
||||
// existing test hooks.
|
||||
func pollWikiAsyncTask(
|
||||
ctx context.Context,
|
||||
runtime *common.RuntimeContext,
|
||||
taskID, label string,
|
||||
attempts int,
|
||||
interval time.Duration,
|
||||
fetcher wikiAsyncTaskFetcher,
|
||||
nextCommand string,
|
||||
) (wikiAsyncTaskStatus, bool, error) {
|
||||
lastStatus := wikiAsyncTaskStatus{TaskID: taskID}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
// The delete request already succeeded. Treat poll failures as transient
|
||||
// until every attempt fails, then return a resume hint instead of
|
||||
// discarding the task identifier.
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := fetcher(ctx, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status attempt %d/%d failed: %v\n", label, attempt, attempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s task completed successfully.\n", label)
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
label, taskID, nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
// ErrWithHint rebuilds the error and drops the upstream Lark
|
||||
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
|
||||
// ExitError by hand so the original API code survives a fully
|
||||
// failed poll, matching wrapWikiNodeDeleteAPIError.
|
||||
return lastStatus, false, &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
181
shortcuts/wiki/wiki_async_task_test.go
Normal file
181
shortcuts/wiki/wiki_async_task_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
|
||||
// so it gets a dedicated test surface here rather than relying only on the
|
||||
// transitive coverage from the delete-space / delete-node paths.
|
||||
|
||||
func TestPollWikiAsyncTaskSuccessFirstPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_ok", "delete-node", 3, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "success"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pollWikiAsyncTask() error = %v", err)
|
||||
}
|
||||
if !ready || !status.Ready() {
|
||||
t.Fatalf("ready = %v, status = %+v, want ready", ready, status)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "delete-node task completed successfully") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskFailureIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_x", "delete-node", 3, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "failure", StatusMsg: "denied"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on failure")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "delete-node task task_x failed: denied") {
|
||||
t.Fatalf("err = %v, want terminal failure with reason", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskTimeoutWhenAlwaysProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_slow", "delete-space", 2, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "processing"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
// A still-processing task after the bounded window is a soft timeout:
|
||||
// no error, ready=false, status preserved so the caller can print the
|
||||
// follow-up command.
|
||||
if err != nil {
|
||||
t.Fatalf("pollWikiAsyncTask() error = %v, want nil on timeout", err)
|
||||
}
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on timeout")
|
||||
}
|
||||
if status.StatusCode() != "processing" {
|
||||
t.Fatalf("status = %+v, want processing preserved", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{}, errors.New("transport boom")
|
||||
},
|
||||
"lark-cli drive +task_result --task-id task_lost",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false when every poll failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
|
||||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
|
||||
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
|
||||
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
upstream := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991663,
|
||||
Message: "permission denied",
|
||||
Hint: "grant the wiki:node:retrieve scope",
|
||||
},
|
||||
}
|
||||
_, _, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{}, upstream
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
|
||||
}
|
||||
// The upstream hint must lead so the actionable cause is read first, with
|
||||
// the resume guidance appended. Type and exit code propagate from upstream.
|
||||
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
|
||||
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
|
||||
}
|
||||
if exitErr.Detail.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskHonoursContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
ctx, runtime, "task_cancel", "delete-node", 5, time.Hour,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
calls++
|
||||
cancel() // cancel before the next attempt's inter-poll wait
|
||||
return wikiAsyncTaskStatus{Status: "processing"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on cancellation")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fetcher calls = %d, want 1 (cancelled before second poll)", calls)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,10 +20,12 @@ var (
|
||||
wikiDeleteSpacePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// Back-compat aliases — the shared async-task helper now owns the strings,
|
||||
// but tests still reference these names.
|
||||
const (
|
||||
wikiDeleteSpaceStatusSuccess = "success"
|
||||
wikiDeleteSpaceStatusFailure = "failure"
|
||||
wikiDeleteSpaceStatusProcessing = "processing"
|
||||
wikiDeleteSpaceStatusSuccess = wikiAsyncStatusSuccess
|
||||
wikiDeleteSpaceStatusFailure = wikiAsyncStatusFailure
|
||||
wikiDeleteSpaceStatusProcessing = wikiAsyncStatusProcessing
|
||||
)
|
||||
|
||||
// WikiDeleteSpace deletes a wiki space. The DELETE endpoint may complete
|
||||
@@ -73,48 +74,10 @@ type wikiDeleteSpaceResponse struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
type wikiDeleteSpaceTaskStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
// normalizedStatus collapses whitespace and case so " SUCCESS " is
|
||||
// classified the same as "success". Ready / Failed / StatusCode all derive
|
||||
// from this so classification and the output `status` field can't disagree.
|
||||
func (s wikiDeleteSpaceTaskStatus) normalizedStatus() string {
|
||||
return strings.ToLower(strings.TrimSpace(s.Status))
|
||||
}
|
||||
|
||||
func (s wikiDeleteSpaceTaskStatus) Ready() bool {
|
||||
return s.normalizedStatus() == wikiDeleteSpaceStatusSuccess
|
||||
}
|
||||
|
||||
func (s wikiDeleteSpaceTaskStatus) Failed() bool {
|
||||
// The sample protocol only documents "success" as a terminal OK. Treat any
|
||||
// explicit "failure"/"failed" signal as terminal, and unknown non-success
|
||||
// values as still-processing so we don't misreport a novel status as a hard
|
||||
// failure.
|
||||
lowered := s.normalizedStatus()
|
||||
return lowered == wikiDeleteSpaceStatusFailure || lowered == "failed"
|
||||
}
|
||||
|
||||
// StatusCode returns a never-empty status value for the output envelope. If
|
||||
// the backend response omits delete_space_result.status (or sends whitespace),
|
||||
// fall back to "processing" so the documented timeout-shape stays accurate.
|
||||
func (s wikiDeleteSpaceTaskStatus) StatusCode() string {
|
||||
if status := strings.TrimSpace(s.Status); status != "" {
|
||||
return status
|
||||
}
|
||||
return wikiDeleteSpaceStatusProcessing
|
||||
}
|
||||
|
||||
func (s wikiDeleteSpaceTaskStatus) StatusLabel() string {
|
||||
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return s.StatusCode()
|
||||
}
|
||||
// wikiDeleteSpaceTaskStatus is an alias for the shared wiki async-task shape;
|
||||
// kept as a named type for the existing test surface. delete-node uses the
|
||||
// same type directly under its real name (wikiAsyncTaskStatus).
|
||||
type wikiDeleteSpaceTaskStatus = wikiAsyncTaskStatus
|
||||
|
||||
type wikiDeleteSpaceClient interface {
|
||||
DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error)
|
||||
@@ -150,7 +113,7 @@ func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID str
|
||||
if err != nil {
|
||||
return wikiDeleteSpaceTaskStatus{}, err
|
||||
}
|
||||
return parseWikiDeleteSpaceTaskStatus(taskID, common.GetMap(data, "task"))
|
||||
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultDeleteSpace)
|
||||
}
|
||||
|
||||
func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec {
|
||||
@@ -237,77 +200,18 @@ func wikiDeleteSpaceTaskResultCommand(taskID string, identity core.Identity) str
|
||||
}
|
||||
|
||||
func pollWikiDeleteSpaceTask(ctx context.Context, client wikiDeleteSpaceClient, runtime *common.RuntimeContext, taskID string) (wikiDeleteSpaceTaskStatus, bool, error) {
|
||||
lastStatus := wikiDeleteSpaceTaskStatus{TaskID: taskID}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
// The delete request already succeeded. Treat poll failures as transient
|
||||
// until every attempt fails, then return a resume hint instead of discarding
|
||||
// the task identifier.
|
||||
for attempt := 1; attempt <= wikiDeleteSpacePollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-time.After(wikiDeleteSpacePollInterval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := client.GetDeleteSpaceTask(ctx, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status attempt %d/%d failed: %v\n", attempt, wikiDeleteSpacePollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki delete-space task %s failed: %s", taskID, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status %d/%d: %s\n", attempt, wikiDeleteSpacePollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
nextCommand := wikiDeleteSpaceTaskResultCommand(taskID, runtime.As())
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki delete-space task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
taskID,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
return pollWikiAsyncTask(
|
||||
ctx, runtime, taskID, "delete-space",
|
||||
wikiDeleteSpacePollAttempts, wikiDeleteSpacePollInterval,
|
||||
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
|
||||
return client.GetDeleteSpaceTask(ctx, id)
|
||||
},
|
||||
wikiDeleteSpaceTaskResultCommand(taskID, runtime.As()),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiDeleteSpaceTaskStatus is kept as a thin wrapper for the existing
|
||||
// test surface; new callers should use parseWikiAsyncTaskStatus directly.
|
||||
func parseWikiDeleteSpaceTaskStatus(taskID string, task map[string]interface{}) (wikiDeleteSpaceTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiDeleteSpaceTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, "delete_space_result")
|
||||
status := wikiDeleteSpaceTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
if result != nil {
|
||||
status.Status = common.GetString(result, "status")
|
||||
status.StatusMsg = common.GetString(result, "status_msg")
|
||||
}
|
||||
return status, nil
|
||||
return parseWikiAsyncTaskStatus(taskID, task, wikiAsyncResultDeleteSpace)
|
||||
}
|
||||
|
||||
@@ -266,8 +266,19 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
withSingleWikiDeleteSpacePoll(t)
|
||||
|
||||
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
|
||||
// Seed an error that carries an upstream Lark Detail.Code so the test
|
||||
// pins that structured fields survive a fully failed poll (not just the
|
||||
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
|
||||
client := &fakeWikiDeleteSpaceClient{
|
||||
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
|
||||
taskErrs: []error{&output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 131006,
|
||||
Message: "poll failed",
|
||||
Hint: "retry original",
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
|
||||
@@ -287,6 +298,9 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Code != 131006 {
|
||||
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
}
|
||||
|
||||
28
shortcuts/wiki/wiki_helpers.go
Normal file
28
shortcuts/wiki/wiki_helpers.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeURL returns the user-facing link for a wiki node. The create/copy
|
||||
// OpenAPI responses carry a real `url` (undocumented in the server-docs schema
|
||||
// but present in practice); prefer it so the CLI surfaces the canonical link.
|
||||
// Fall back to BuildResourceURL synthesis only when the response omits it.
|
||||
//
|
||||
// Shared by +node-create and +node-copy, hence kept here rather than in either
|
||||
// command's file.
|
||||
func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if u := strings.TrimSpace(node.URL); u != "" {
|
||||
return u
|
||||
}
|
||||
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
||||
}
|
||||
@@ -415,6 +415,7 @@ func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
|
||||
"node_type": "origin",
|
||||
"title": "Architecture (Copy)",
|
||||
"has_child": false,
|
||||
"url": "https://abc.feishu.cn/wiki/wik_copied_real",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
@@ -451,6 +452,9 @@ func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
|
||||
if envelope.Data["space_id"] != "space_dst" {
|
||||
t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst")
|
||||
}
|
||||
if got, want := envelope.Data["url"], "https://abc.feishu.cn/wiki/wik_copied_real"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (copy must surface the response url)", got, want)
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
|
||||
|
||||
@@ -89,6 +89,9 @@ var WikiNodeCopy = common.Shortcut{
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n",
|
||||
common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID))
|
||||
out := wikiNodeCopyOutput(node)
|
||||
if u := wikiNodeURL(runtime.Config.Brand, node); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderWikiNodeCopyPretty(w, out)
|
||||
})
|
||||
@@ -106,6 +109,9 @@ func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) {
|
||||
if parent, _ := out["parent_node_token"].(string); parent != "" {
|
||||
fmt.Fprintf(w, " parent_node_token: %s\n", parent)
|
||||
}
|
||||
if url, _ := out["url"].(string); url != "" {
|
||||
fmt.Fprintf(w, " url: %s\n", url)
|
||||
}
|
||||
}
|
||||
|
||||
func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
|
||||
@@ -118,6 +118,7 @@ type wikiNodeRecord struct {
|
||||
OriginNodeToken string
|
||||
Title string
|
||||
HasChild bool
|
||||
URL string
|
||||
}
|
||||
|
||||
// wikiSpaceRecord contains the response fields used when resolving spaces.
|
||||
@@ -456,6 +457,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
|
||||
OriginNodeToken: common.GetString(node, "origin_node_token"),
|
||||
Title: common.GetString(node, "title"),
|
||||
HasChild: common.GetBool(node, "has_child"),
|
||||
URL: common.GetString(node, "url"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -498,7 +500,7 @@ func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wiki
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "wiki", execution.Node.NodeToken); u != "" {
|
||||
if u := wikiNodeURL(runtime.Config.Brand, execution.Node); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -107,24 +107,6 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func TestWikiShortcutsIncludeAllCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 6 {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts))
|
||||
}
|
||||
if shortcuts[0].Command != "+move" {
|
||||
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")
|
||||
}
|
||||
if shortcuts[1].Command != "+node-create" {
|
||||
t.Fatalf("shortcuts[1].Command = %q, want %q", shortcuts[1].Command, "+node-create")
|
||||
}
|
||||
if shortcuts[2].Command != "+delete-space" {
|
||||
t.Fatalf("shortcuts[2].Command = %q, want %q", shortcuts[2].Command, "+delete-space")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -469,6 +451,7 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
|
||||
"origin_node_token": "",
|
||||
"title": "Wiki Node",
|
||||
"has_child": false,
|
||||
"url": "https://abc.feishu.cn/wiki/wik_created_real",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
@@ -502,8 +485,8 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
|
||||
if envelope.Data["node_token"] != "wik_created" {
|
||||
t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created")
|
||||
}
|
||||
if got, want := envelope.Data["url"], "https://www.feishu.cn/wiki/wik_created"; got != want {
|
||||
t.Fatalf("url = %#v, want %q", got, want)
|
||||
if got, want := envelope.Data["url"], "https://abc.feishu.cn/wiki/wik_created_real"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (response url must win over synthesized fallback)", got, want)
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
@@ -646,3 +629,47 @@ func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) {
|
||||
t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
node *wikiNodeRecord
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers response url over synthesized fallback",
|
||||
node: &wikiNodeRecord{NodeToken: "wik_token", URL: "https://abc.feishu.cn/wiki/wik_real"},
|
||||
want: "https://abc.feishu.cn/wiki/wik_real",
|
||||
},
|
||||
{
|
||||
name: "falls back to synthesized url when response omits it",
|
||||
node: &wikiNodeRecord{NodeToken: "wik_token"},
|
||||
want: "https://www.feishu.cn/wiki/wik_token",
|
||||
},
|
||||
{
|
||||
name: "blank response url is treated as absent",
|
||||
node: &wikiNodeRecord{NodeToken: "wik_token", URL: " "},
|
||||
want: "https://www.feishu.cn/wiki/wik_token",
|
||||
},
|
||||
{
|
||||
name: "nil node yields empty string",
|
||||
node: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no token and no url yields empty string",
|
||||
node: &wikiNodeRecord{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := wikiNodeURL(core.BrandFeishu, tc.node); got != tc.want {
|
||||
t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
440
shortcuts/wiki/wiki_node_delete.go
Normal file
440
shortcuts/wiki/wiki_node_delete.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeDeleteObjTypes is the set of obj_type values the delete-node API
|
||||
// accepts. Unlike wikiNodeGetObjTypeEnum this includes "wiki" — for
|
||||
// delete-node, obj_type="wiki" means the token is a wiki node_token, whereas
|
||||
// the get_node API omits obj_type for node_tokens.
|
||||
var wikiNodeDeleteObjTypes = []string{
|
||||
"wiki", "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
|
||||
}
|
||||
|
||||
var (
|
||||
wikiDeleteNodePollAttempts = 30
|
||||
wikiDeleteNodePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// Lark wiki API error codes the delete-node API surfaces with actionable
|
||||
// CLI workarounds. The full list is in the OpenAPI spec; we only special-case
|
||||
// the codes whose remediation is non-obvious (UI approval, subtree size).
|
||||
const (
|
||||
wikiDeleteNodeErrCodeApprovalRequired = 131011
|
||||
wikiDeleteNodeErrCodeSubtreeTooLarge = 131003
|
||||
)
|
||||
|
||||
// WikiNodeDelete deletes a wiki node (or pulls a cloud doc out of Wiki). The
|
||||
// API mirrors +delete-space — synchronous on small deletes, async with a
|
||||
// task_id for cascade deletes — so this shortcut shares the async-polling
|
||||
// helper. Space ID is optional: when omitted, +node-delete first looks up the
|
||||
// node via get_node to resolve the space ID so callers do not have to chain
|
||||
// commands.
|
||||
var WikiNodeDelete = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-delete",
|
||||
Description: "Delete a wiki node, polling the async delete task when needed",
|
||||
Risk: "high-risk-write",
|
||||
// API spec lists wiki:node:create as the only declared scope for the
|
||||
// delete endpoint. Naming is unfortunate, but the scope-preflight needs
|
||||
// the literal string.
|
||||
Scopes: []string{"wiki:node:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "node-token", Desc: "wiki node_token, cloud-doc obj_token, or a Lark URL embedding one of them", Required: true},
|
||||
// Not Required at the cobra level: URL inputs auto-infer obj_type
|
||||
// from the path, and the parser enforces explicit obj_type for raw
|
||||
// tokens. Forcing Cobra Required here breaks the URL ergonomic.
|
||||
{Name: "obj-type", Desc: "token kind; no default — pass explicitly when --node-token is a raw token (URL inputs auto-infer)", Enum: wikiNodeDeleteObjTypes},
|
||||
{Name: "space-id", Desc: "wiki space ID; auto-resolved via get_node when omitted"},
|
||||
{Name: "include-children", Type: "bool", Default: "true", Desc: "cascade delete the subtree (default); pass --include-children=false to lift direct children up to the parent"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Deletion is irreversible; double-check --node-token and --obj-type before running.",
|
||||
"This is a high-risk-write command; pass --yes to confirm the deletion.",
|
||||
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>; URL paths also imply --obj-type.",
|
||||
"Run +node-get first to confirm space_id / obj_type when in doubt.",
|
||||
"Auto-resolving space_id (when --space-id is omitted) also calls get_node, which needs the wiki:node:retrieve scope; pass --space-id to skip that lookup if your token only carries wiki:node:create.",
|
||||
"Async deletes return a task_id; this command polls for a bounded window and then prints a follow-up drive +task_result command.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiNodeDeleteSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeDeleteSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildWikiNodeDeleteDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiNodeDeleteSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(ctx, wikiNodeDeleteAPI{runtime: runtime}, runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiNodeDeleteSpec is the normalized input for the shortcut. Token / ObjType
|
||||
// reconcile URL inputs with the explicit flags; SourceKind is purely for the
|
||||
// dry-run description string.
|
||||
type wikiNodeDeleteSpec struct {
|
||||
NodeToken string
|
||||
ObjType string
|
||||
SpaceID string
|
||||
IncludeChildren bool
|
||||
SourceKind string // "raw" | "url"
|
||||
}
|
||||
|
||||
// RequestBody builds the JSON body for DELETE /spaces/{id}/nodes/{token}.
|
||||
func (spec wikiNodeDeleteSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"obj_type": spec.ObjType,
|
||||
"include_children": spec.IncludeChildren,
|
||||
}
|
||||
}
|
||||
|
||||
// wikiNodeDeleteClient isolates the network operations so business logic can
|
||||
// be unit-tested without real HTTP calls. Mirrors wikiDeleteSpaceClient.
|
||||
type wikiNodeDeleteClient interface {
|
||||
ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error)
|
||||
DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error)
|
||||
GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
}
|
||||
|
||||
type wikiNodeDeleteAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
|
||||
params := map[string]interface{}{"token": token}
|
||||
// get_node takes obj_type only when the token is an obj_token. For
|
||||
// wiki node_tokens the API rejects an obj_type kwarg, so omit it.
|
||||
if objType != "" && objType != "wiki" {
|
||||
params["obj_type"] = objType
|
||||
}
|
||||
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWikiNodeRecord(common.GetMap(data, "node"))
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
validate.EncodePathSegment(spaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
),
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return "", wrapWikiNodeDeleteAPIError(err)
|
||||
}
|
||||
return common.GetString(data, "task_id"), nil
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiAsyncTaskStatus{}, err
|
||||
}
|
||||
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultSimpleTask)
|
||||
}
|
||||
|
||||
func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec, error) {
|
||||
return parseWikiNodeDeleteSpec(
|
||||
runtime.Str("node-token"),
|
||||
runtime.Str("obj-type"),
|
||||
runtime.Str("space-id"),
|
||||
runtime.Bool("include-children"),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiNodeDeleteSpec normalizes the raw flag values: extracts a token
|
||||
// from a URL when provided, reconciles URL-implied obj_type against the
|
||||
// explicit flag, and validates that the resulting obj_type is one the delete
|
||||
// API accepts.
|
||||
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
|
||||
}
|
||||
|
||||
spec := wikiNodeDeleteSpec{
|
||||
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
|
||||
SpaceID: strings.TrimSpace(rawSpaceID),
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
}
|
||||
spec.NodeToken = token
|
||||
spec.SourceKind = "url"
|
||||
|
||||
// /wiki/<token> implies node_token → obj_type=wiki for the delete API.
|
||||
// Cloud doc paths (/docx/, /sheets/, ...) already give us a concrete type.
|
||||
inferred := urlObjType
|
||||
if inferred == "" {
|
||||
inferred = "wiki"
|
||||
}
|
||||
switch {
|
||||
case spec.ObjType == "":
|
||||
spec.ObjType = inferred
|
||||
case spec.ObjType != inferred:
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, inferred,
|
||||
)
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
} else {
|
||||
spec.NodeToken = tokenInput
|
||||
spec.SourceKind = "raw"
|
||||
}
|
||||
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type is required (one of: %s)",
|
||||
strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
}
|
||||
if !isValidWikiDeleteObjType(spec.ObjType) {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type %q is not valid; pick one of: %s",
|
||||
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func isValidWikiDeleteObjType(v string) bool {
|
||||
for _, t := range wikiNodeDeleteObjTypes {
|
||||
if v == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildWikiNodeDeleteDryRun(spec wikiNodeDeleteSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().Desc(
|
||||
"async-aware: delete wiki node -> poll wiki delete-node task when task_id is returned (auto-resolves space_id via get_node when --space-id is omitted)",
|
||||
)
|
||||
|
||||
if spec.SpaceID == "" {
|
||||
params := map[string]interface{}{"token": spec.NodeToken}
|
||||
if spec.ObjType != "" && spec.ObjType != "wiki" {
|
||||
params["obj_type"] = spec.ObjType
|
||||
}
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve space_id via get_node").
|
||||
Params(params)
|
||||
dry.DELETE(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
"<resolved_space_id>",
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc("[2] Delete wiki node").
|
||||
Body(spec.RequestBody())
|
||||
} else {
|
||||
dry.DELETE(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
validate.EncodePathSegment(spec.SpaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc("[1] Delete wiki node").
|
||||
Body(spec.RequestBody())
|
||||
}
|
||||
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[N] Poll wiki delete-node task result when async").
|
||||
Set("task_id", "<task_id>").
|
||||
Params(map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode})
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
func runWikiNodeDelete(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (map[string]interface{}, error) {
|
||||
spaceID, err := resolveWikiNodeDeleteSpaceID(ctx, client, runtime, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting wiki node %s in space %s (obj_type=%s, include_children=%t)...\n",
|
||||
common.MaskToken(spec.NodeToken), common.MaskToken(spaceID), spec.ObjType, spec.IncludeChildren)
|
||||
|
||||
taskID, err := client.DeleteNode(ctx, spaceID, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"space_id": spaceID,
|
||||
"node_token": spec.NodeToken,
|
||||
"obj_type": spec.ObjType,
|
||||
"include_children": spec.IncludeChildren,
|
||||
}
|
||||
|
||||
// Empty task_id means the delete completed synchronously. Match the
|
||||
// shape used by +delete-space so downstream scripts can read `status`
|
||||
// uniformly regardless of which branch fired.
|
||||
if taskID == "" {
|
||||
out["ready"] = true
|
||||
out["failed"] = false
|
||||
out["status"] = wikiAsyncStatusSuccess
|
||||
out["status_msg"] = wikiAsyncStatusSuccess
|
||||
return out, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki node delete is async, polling task %s...\n", taskID)
|
||||
nextCommand := wikiDeleteNodeTaskResultCommand(taskID, runtime.As())
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
ctx, runtime, taskID, "delete-node",
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval,
|
||||
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
|
||||
return client.GetDeleteNodeTask(ctx, id)
|
||||
},
|
||||
nextCommand,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out["task_id"] = taskID
|
||||
out["ready"] = ready
|
||||
out["failed"] = status.Failed()
|
||||
out["status"] = status.StatusCode()
|
||||
out["status_msg"] = status.StatusLabel()
|
||||
|
||||
if !ready {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-node task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveWikiNodeDeleteSpaceID returns the explicit space_id when the caller
|
||||
// supplied one, otherwise resolves it via get_node. The latter saves callers
|
||||
// from running +node-get first when they only have a node_token.
|
||||
func resolveWikiNodeDeleteSpaceID(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (string, error) {
|
||||
if spec.SpaceID != "" {
|
||||
return spec.SpaceID, nil
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving space_id via get_node for token %s...\n", common.MaskToken(spec.NodeToken))
|
||||
node, err := client.ResolveNode(ctx, spec.NodeToken, spec.ObjType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
spaceID, err := requireWikiNodeSpaceID(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved to space %s\n", common.MaskToken(spaceID))
|
||||
return spaceID, nil
|
||||
}
|
||||
|
||||
func wikiDeleteNodeTaskResultCommand(taskID string, identity core.Identity) string {
|
||||
asFlag := string(identity)
|
||||
if asFlag == "" {
|
||||
asFlag = "user"
|
||||
}
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_delete_node --task-id %s --as %s", taskID, asFlag)
|
||||
}
|
||||
|
||||
// wrapWikiNodeDeleteAPIError attaches actionable hints to the two Lark error
|
||||
// codes whose remediation lives outside the CLI:
|
||||
// - 131011: approval required (deletion gated by Wiki UI approval flow)
|
||||
// - 131003: subtree too large to cascade-delete (must split or use
|
||||
// include_children=false)
|
||||
//
|
||||
// Other codes pass through untouched so the generic error envelope still
|
||||
// surfaces the original code+message.
|
||||
func wrapWikiNodeDeleteAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return err
|
||||
}
|
||||
var hint string
|
||||
switch exitErr.Detail.Code {
|
||||
case wikiDeleteNodeErrCodeApprovalRequired:
|
||||
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
|
||||
case wikiDeleteNodeErrCodeSubtreeTooLarge:
|
||||
hint = "the subtree is too large to cascade-delete in one call; pass --include-children=false to keep the children (they will be moved up to the parent), or delete sub-trees first"
|
||||
}
|
||||
if hint == "" {
|
||||
return err
|
||||
}
|
||||
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
|
||||
hint = existing + "\n" + hint
|
||||
}
|
||||
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
|
||||
// the ExitError by hand so the Lark error code stays available to logs and
|
||||
// downstream pivots.
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
611
shortcuts/wiki/wiki_node_delete_test.go
Normal file
611
shortcuts/wiki/wiki_node_delete_test.go
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ── parseWikiNodeDeleteSpec ─────────────────────────────────────────────────
|
||||
|
||||
func TestParseWikiNodeDeleteSpecAcceptsRawWikiToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "raw" || !spec.IncludeChildren {
|
||||
t.Fatalf("spec = %+v", spec)
|
||||
}
|
||||
body := spec.RequestBody()
|
||||
if body["obj_type"] != "wiki" || body["include_children"] != true {
|
||||
t.Fatalf("RequestBody = %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsMissingObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("wikcnABC", "", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required") {
|
||||
t.Fatalf("expected obj-type required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsInvalidObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("wikcnABC", "comment", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "is not valid") {
|
||||
t.Fatalf("expected invalid obj-type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsEmptyToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec(" ", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "--node-token is required") {
|
||||
t.Fatalf("expected token required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecExtractsTokenFromWikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/wiki/wikcnABC", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "url" {
|
||||
t.Fatalf("spec = %+v, want url-extracted node_token + obj_type=wiki", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecInfersObjTypeFromDocxURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "docxXYZ" || spec.ObjType != "docx" || spec.IncludeChildren {
|
||||
t.Fatalf("spec = %+v, want docxXYZ obj_type=docx include_children=false", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsURLObjTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
|
||||
t.Fatalf("expected obj-type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsPartialPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("/wiki/wikcnABC", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
|
||||
t.Fatalf("expected partial-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildWikiNodeDeleteDryRunWithoutSpaceIDShowsResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeDeleteDryRun(spec)
|
||||
got := decodeDryRunAPIs(t, dry)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(dry.api) = %d, want 3 (get_node, delete, task poll)", len(got))
|
||||
}
|
||||
if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("step[0].URL = %q, want get_node", got[0].URL)
|
||||
}
|
||||
if got[0].Params["obj_type"] != "docx" || got[0].Params["token"] != "docxXYZ" {
|
||||
t.Fatalf("step[0].params = %#v", got[0].Params)
|
||||
}
|
||||
if got[1].URL != "/open-apis/wiki/v2/spaces/<resolved_space_id>/nodes/docxXYZ" {
|
||||
t.Fatalf("step[1].URL = %q, want delete with placeholder", got[1].URL)
|
||||
}
|
||||
if got[1].Body["obj_type"] != "docx" || got[1].Body["include_children"] != true {
|
||||
t.Fatalf("step[1].body = %#v", got[1].Body)
|
||||
}
|
||||
if got[2].Params["task_type"] != "delete_node" {
|
||||
t.Fatalf("step[2].params task_type = %#v, want delete_node", got[2].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWikiNodeDeleteDryRunWithSpaceIDOmitsResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "7629741305993170448", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeDeleteDryRun(spec)
|
||||
got := decodeDryRunAPIs(t, dry)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(dry.api) = %d, want 2 (delete + task poll) when --space-id supplied", len(got))
|
||||
}
|
||||
if got[0].Method != "DELETE" || got[0].URL != "/open-apis/wiki/v2/spaces/7629741305993170448/nodes/wikcnABC" {
|
||||
t.Fatalf("step[0] = %+v", got[0])
|
||||
}
|
||||
if got[0].Body["include_children"] != false {
|
||||
t.Fatalf("body include_children = %#v", got[0].Body["include_children"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── runWikiNodeDelete unit ──────────────────────────────────────────────────
|
||||
|
||||
type fakeWikiNodeDeleteClient struct {
|
||||
resolveErr error
|
||||
resolveNode *wikiNodeRecord
|
||||
resolveCalls []string
|
||||
|
||||
deleteErr error
|
||||
deleteTaskID string
|
||||
deleteCalls []struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeDeleteSpec
|
||||
}
|
||||
|
||||
taskStatuses []wikiAsyncTaskStatus
|
||||
taskErrs []error
|
||||
taskCallArgs []string
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
|
||||
fake.resolveCalls = append(fake.resolveCalls, token)
|
||||
if fake.resolveErr != nil {
|
||||
return nil, fake.resolveErr
|
||||
}
|
||||
return fake.resolveNode, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
fake.deleteCalls = append(fake.deleteCalls, struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeDeleteSpec
|
||||
}{SpaceID: spaceID, Spec: spec})
|
||||
if fake.deleteErr != nil {
|
||||
return "", fake.deleteErr
|
||||
}
|
||||
return fake.deleteTaskID, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
idx := len(fake.taskCallArgs)
|
||||
fake.taskCallArgs = append(fake.taskCallArgs, taskID)
|
||||
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
|
||||
return wikiAsyncTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
|
||||
}
|
||||
if idx < len(fake.taskStatuses) {
|
||||
status := fake.taskStatuses[idx]
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
return wikiAsyncTaskStatus{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
var wikiDeleteNodePollMu sync.Mutex
|
||||
|
||||
func withSingleWikiDeleteNodePoll(t *testing.T) {
|
||||
t.Helper()
|
||||
wikiDeleteNodePollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = prevAttempts, prevInterval
|
||||
wikiDeleteNodePollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func newWikiNodeDeleteRuntime(t *testing.T, as core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +node-delete"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime, stderr
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteResolvesSpaceWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
resolveNode: &wikiNodeRecord{SpaceID: "space_resolved"},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC",
|
||||
ObjType: "wiki",
|
||||
IncludeChildren: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if len(client.resolveCalls) != 1 || client.resolveCalls[0] != "wikcnABC" {
|
||||
t.Fatalf("resolve calls = %v", client.resolveCalls)
|
||||
}
|
||||
if len(client.deleteCalls) != 1 || client.deleteCalls[0].SpaceID != "space_resolved" {
|
||||
t.Fatalf("delete calls = %+v", client.deleteCalls)
|
||||
}
|
||||
if out["space_id"] != "space_resolved" || out["ready"] != true || out["status"] != "success" {
|
||||
t.Fatalf("sync output = %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteSkipsResolveWhenSpaceProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{}
|
||||
|
||||
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_explicit",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if len(client.resolveCalls) != 0 {
|
||||
t.Fatalf("resolveCalls should be empty when --space-id supplied, got %v", client.resolveCalls)
|
||||
}
|
||||
if client.deleteCalls[0].SpaceID != "space_explicit" {
|
||||
t.Fatalf("delete used wrong space: %+v", client.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncReadyShape(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "success"}},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123", IncludeChildren: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if out["task_id"] != "task_async_node" || out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("async-ready output = %#v", out)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "async, polling task") || !strings.Contains(stderr.String(), "delete-node task completed successfully") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncTimeoutReturnsNextCommand(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "processing"}},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
wantNext := wikiDeleteNodeTaskResultCommand("task_async_node", core.AsUser)
|
||||
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wantNext {
|
||||
t.Fatalf("timeout output = %#v", out)
|
||||
}
|
||||
if !strings.Contains(wantNext, "wiki_delete_node") {
|
||||
t.Fatalf("next command should scope wiki_delete_node, got %q", wantNext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "failure", StatusMsg: "permission denied"}},
|
||||
}
|
||||
|
||||
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "delete-node task task_async_node failed: permission denied") {
|
||||
t.Fatalf("expected async failure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── error code hint mapping ─────────────────────────────────────────────────
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeApprovalRequired,
|
||||
Message: "node requires delete approval",
|
||||
},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
|
||||
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
|
||||
}
|
||||
// Original code/message must be preserved so logs and dashboards still
|
||||
// pivot on the upstream error code.
|
||||
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
|
||||
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
|
||||
}
|
||||
if exitErr.Detail.Message != "node requires delete approval" {
|
||||
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
|
||||
Message: "subtree too large",
|
||||
},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
|
||||
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
if !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("unknown code should pass through; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorIgnoresNonExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := errors.New("transport boom")
|
||||
if got := wrapWikiNodeDeleteAPIError(in); got != in {
|
||||
t.Fatalf("non-ExitError should pass through, got %T %v", got, got)
|
||||
}
|
||||
if got := wrapWikiNodeDeleteAPIError(nil); got != nil {
|
||||
t.Fatalf("nil should pass through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mounted execute (httpmock) ──────────────────────────────────────────────
|
||||
|
||||
func TestWikiNodeDeleteExecuteRequiresYesConfirmation(t *testing.T) {
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected high-risk confirmation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteSync(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["ready"] != true || data["failed"] != false || data["space_id"] != "space_123" {
|
||||
t.Fatalf("sync output = %#v", data)
|
||||
}
|
||||
if data["obj_type"] != "wiki" || data["include_children"] != true {
|
||||
t.Fatalf("obj_type/include_children = %#v / %#v", data["obj_type"], data["include_children"])
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(deleteStub.CapturedBody, &captured); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if captured["obj_type"] != "wiki" || captured["include_children"] != true {
|
||||
t.Fatalf("captured DELETE body = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteResolvesSpaceIDFromURL(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
resolveStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_resolved",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var resolveQuery string
|
||||
resolveStub.OnMatch = func(req *http.Request) { resolveQuery = req.URL.RawQuery }
|
||||
reg.Register(resolveStub)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_resolved/nodes/docxXYZ",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "https://feishu.cn/docx/docxXYZ",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resolveQuery, "token=docxXYZ") || !strings.Contains(resolveQuery, "obj_type=docx") {
|
||||
t.Fatalf("resolve query = %q, want token+obj_type", resolveQuery)
|
||||
}
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["space_id"] != "space_resolved" || data["obj_type"] != "docx" {
|
||||
t.Fatalf("output = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteAsyncSuccess(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_async_node"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_async_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// Gateway returns delete-node status under the generic
|
||||
// simple_task_result key (NOT delete_node_result).
|
||||
"simple_task_result": map[string]interface{}{
|
||||
"status": "success",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["task_id"] != "task_async_node" || data["ready"] != true || data["failed"] != false {
|
||||
t.Fatalf("async-success output = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type dryRunStep struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
func decodeDryRunAPIs(t *testing.T, dry *common.DryRunAPI) []dryRunStep {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []dryRunStep `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
return got.API
|
||||
}
|
||||
370
shortcuts/wiki/wiki_node_get.go
Normal file
370
shortcuts/wiki/wiki_node_get.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the
|
||||
// obj_type the wiki get_node API expects when the token is an obj_token.
|
||||
// /wiki/ is handled separately because node_tokens take no obj_type.
|
||||
//
|
||||
// INVARIANT: the prefixes must be mutually exclusive (no prefix may be a
|
||||
// prefix of another). tokenAndObjTypeFromWikiURL ranges this map, and Go map
|
||||
// iteration order is randomized — overlapping prefixes would make the match
|
||||
// non-deterministic. The trailing slash keeps them disjoint today (e.g.
|
||||
// "/docx/" does not start with "/doc/"); preserve that when adding entries.
|
||||
var wikiNodeGetURLObjTypes = map[string]string{
|
||||
"/docx/": "docx",
|
||||
"/doc/": "doc",
|
||||
"/sheets/": "sheet",
|
||||
"/base/": "bitable",
|
||||
"/mindnote/": "mindnote",
|
||||
"/slides/": "slides",
|
||||
"/file/": "file",
|
||||
}
|
||||
|
||||
// wikiNodeGetObjTypeEnum is the union of obj_types accepted by the upstream
|
||||
// API. It is a superset of the create / move enums so this shortcut can look
|
||||
// up legacy `doc` nodes too.
|
||||
var wikiNodeGetObjTypeEnum = []string{
|
||||
"doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
|
||||
}
|
||||
|
||||
// WikiNodeGet wraps wiki.spaces.get_node so callers can resolve a node by
|
||||
// node_token, obj_token, or a Lark URL without hand-rolling a
|
||||
// `wiki spaces get_node --params ...` invocation. The shortcut prints a
|
||||
// formatted view of the node (title / obj_type / obj_token / parent /
|
||||
// creator / updated_at) and is intended as the "what am I about to
|
||||
// touch?" step before +move / +node-copy / +delete-space.
|
||||
var WikiNodeGet = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-get",
|
||||
Description: "Get wiki node details by node_token, obj_token, or Lark URL",
|
||||
Risk: "read",
|
||||
Scopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true},
|
||||
{Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
|
||||
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiNodeGetSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeGetSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildWikiNodeGetDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiNodeGetSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
|
||||
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := common.GetMap(data, "node")
|
||||
node, err := parseWikiNodeRecord(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
|
||||
return output.ErrValidation(
|
||||
"--space-id %q does not match the resolved node space %q (node_token=%s)",
|
||||
spec.SpaceID, node.SpaceID, node.NodeToken,
|
||||
)
|
||||
}
|
||||
if spec.SpaceID != "" && node.SpaceID == "" {
|
||||
// The cross-check was requested but get_node returned no space_id,
|
||||
// so it silently passed. Surface that the assertion was a no-op
|
||||
// rather than letting the caller assume it was verified.
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"Warning: --space-id %q could not be verified; the resolved node carries no space_id.\n",
|
||||
spec.SpaceID)
|
||||
}
|
||||
|
||||
out := wikiNodeGetOutput(node, raw)
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderWikiNodeGetPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiNodeGetSpec is the normalized input for the shortcut.
|
||||
type wikiNodeGetSpec struct {
|
||||
// Token is the resolved token (after URL extraction) to send to the API.
|
||||
Token string
|
||||
// ObjType is the resolved obj_type. Empty for node_tokens (the API does
|
||||
// not need obj_type for `wik`-prefixed tokens).
|
||||
ObjType string
|
||||
// SpaceID is an optional cross-check; when set, the response space_id must match.
|
||||
SpaceID string
|
||||
// SourceKind records how Token was derived for the dry-run description:
|
||||
// "url-wiki", "url-obj", "raw-node", "raw-obj".
|
||||
SourceKind string
|
||||
}
|
||||
|
||||
// RequestParams returns the query params for GET /wiki/v2/spaces/get_node.
|
||||
func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} {
|
||||
params := map[string]interface{}{"token": spec.Token}
|
||||
if spec.ObjType != "" {
|
||||
params["obj_type"] = spec.ObjType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) {
|
||||
return parseWikiNodeGetSpec(
|
||||
runtime.Str("token"),
|
||||
runtime.Str("obj-type"),
|
||||
runtime.Str("space-id"),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a
|
||||
// URL when needed, picks the obj_type (URL path > explicit flag > none for
|
||||
// node_tokens), and validates the token shape.
|
||||
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--token is required")
|
||||
}
|
||||
|
||||
spec := wikiNodeGetSpec{
|
||||
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
|
||||
SpaceID: strings.TrimSpace(rawSpaceID),
|
||||
}
|
||||
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput)
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
}
|
||||
spec.Token = token
|
||||
if urlObjType == "" {
|
||||
spec.SourceKind = "url-wiki"
|
||||
} else {
|
||||
spec.SourceKind = "url-obj"
|
||||
}
|
||||
switch {
|
||||
case spec.ObjType == "" && urlObjType != "":
|
||||
spec.ObjType = urlObjType
|
||||
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, urlObjType,
|
||||
)
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
spec.SourceKind = "raw-node"
|
||||
// node_tokens take no obj_type; reject a conflicting flag rather
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
// A raw obj_token needs an explicit obj_type: get_node would
|
||||
// otherwise default to "doc" and fail confusingly for docx /
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateOptionalResourceName(spec.Token, "--token"); err != nil {
|
||||
return wikiNodeGetSpec{}, err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
|
||||
return wikiNodeGetSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
|
||||
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
|
||||
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
|
||||
//
|
||||
// This is a heuristic based on the current Lark token-naming convention, not a
|
||||
// guaranteed invariant: if Lark ever introduces a non-node token type that
|
||||
// also starts with `wik`, it would be misclassified. Worst case is a
|
||||
// confusing API error (no data risk); revisit if the token scheme changes.
|
||||
func looksLikeWikiNodeToken(token string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(token), "wik")
|
||||
}
|
||||
|
||||
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
||||
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
||||
// don't need one.
|
||||
func tokenAndObjTypeFromWikiURL(path string) (token, objType string, ok bool) {
|
||||
if t, found := wikiPathSegmentAfter(path, "/wiki/"); found {
|
||||
return t, "", true
|
||||
}
|
||||
for prefix, ot := range wikiNodeGetURLObjTypes {
|
||||
if t, found := wikiPathSegmentAfter(path, prefix); found {
|
||||
return t, ot, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// wikiPathSegmentAfter returns the first path segment after prefix, or ("",
|
||||
// false) when path doesn't start with prefix or the segment is empty.
|
||||
func wikiPathSegmentAfter(path, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func buildWikiNodeGetDryRun(spec wikiNodeGetSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
switch spec.SourceKind {
|
||||
case "url-wiki":
|
||||
dry.Desc("Resolve wiki node from /wiki/ URL")
|
||||
case "url-obj":
|
||||
dry.Desc("Resolve wiki node from Lark document URL (obj_type inferred from path)")
|
||||
case "raw-node":
|
||||
dry.Desc("Look up wiki node by node_token")
|
||||
case "raw-obj":
|
||||
dry.Desc("Look up wiki node by obj_token")
|
||||
}
|
||||
return dry.GET("/open-apis/wiki/v2/spaces/get_node").Params(spec.RequestParams())
|
||||
}
|
||||
|
||||
// wikiNodeGetOutput shapes the structured output. It carries the formatted
|
||||
// values (title/obj_type/obj_token/parent_node_token/creator/updated_at)
|
||||
// the user asked for, plus enough raw fields (node_type, has_child, owner,
|
||||
// timestamps) that callers can pipe into +move / +node-copy without rerunning
|
||||
// get_node.
|
||||
//
|
||||
// No synthesized `url` is emitted: get_node returns none, and a
|
||||
// BuildResourceURL fallback (www.feishu.cn/wiki/<node_token>) is a
|
||||
// non-canonical link that misleads in a read/confirm command. Sibling read
|
||||
// shortcuts (+node-list, +node-copy) likewise omit it; node_token/obj_token
|
||||
// are the precise identifiers.
|
||||
func wikiNodeGetOutput(node *wikiNodeRecord, raw map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"space_id": node.SpaceID,
|
||||
"node_token": node.NodeToken,
|
||||
"obj_token": node.ObjToken,
|
||||
"obj_type": node.ObjType,
|
||||
"node_type": node.NodeType,
|
||||
"parent_node_token": node.ParentNodeToken,
|
||||
"origin_node_token": node.OriginNodeToken,
|
||||
"title": node.Title,
|
||||
"has_child": node.HasChild,
|
||||
}
|
||||
|
||||
creator := strings.TrimSpace(common.GetString(raw, "node_creator"))
|
||||
if creator == "" {
|
||||
creator = strings.TrimSpace(common.GetString(raw, "creator"))
|
||||
}
|
||||
out["creator"] = creator
|
||||
out["owner"] = common.GetString(raw, "owner")
|
||||
|
||||
objEditRaw := common.GetString(raw, "obj_edit_time")
|
||||
out["obj_edit_time"] = objEditRaw
|
||||
out["obj_create_time"] = common.GetString(raw, "obj_create_time")
|
||||
out["node_create_time"] = common.GetString(raw, "node_create_time")
|
||||
out["updated_at"] = formatWikiTimestamp(objEditRaw)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// formatWikiTimestamp turns a Lark unix-seconds string (the format used by
|
||||
// wiki.spaces.get_node) into a UTC RFC3339 string. UTC (not the host's local
|
||||
// zone) keeps the output stable regardless of where the CLI runs. Returns ""
|
||||
// when the input is empty or not numeric so the pretty renderer falls back
|
||||
// to "-".
|
||||
func formatWikiTimestamp(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func renderWikiNodeGetPretty(w io.Writer, out map[string]interface{}) {
|
||||
fmt.Fprintln(w, "Wiki node:")
|
||||
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
|
||||
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
|
||||
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
|
||||
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"]))
|
||||
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"]))
|
||||
fmt.Fprintf(w, " parent_node_token: %s\n", valueOrDash(out["parent_node_token"]))
|
||||
fmt.Fprintf(w, " node_type: %s\n", valueOrDash(out["node_type"]))
|
||||
if origin, _ := out["origin_node_token"].(string); origin != "" {
|
||||
fmt.Fprintf(w, " origin_node_token: %s\n", origin)
|
||||
}
|
||||
hasChild, _ := out["has_child"].(bool)
|
||||
fmt.Fprintf(w, " has_child: %t\n", hasChild)
|
||||
fmt.Fprintf(w, " creator: %s\n", valueOrDash(out["creator"]))
|
||||
if owner, _ := out["owner"].(string); owner != "" {
|
||||
fmt.Fprintf(w, " owner: %s\n", owner)
|
||||
}
|
||||
fmt.Fprintf(w, " updated_at: %s\n", valueOrDash(out["updated_at"]))
|
||||
}
|
||||
321
shortcuts/wiki/wiki_node_get_test.go
Normal file
321
shortcuts/wiki/wiki_node_get_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("wikcnABC", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node wikcnABC with no obj_type", spec)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC"}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: wikcnABC}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("docxXYZ", "docx", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
|
||||
t.Fatalf("spec = %+v, want raw-obj docxXYZ obj_type=docx", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
|
||||
// upfront instead of defaulting to "doc" and hitting an opaque API error.
|
||||
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
|
||||
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
|
||||
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecExtractsTokenFromWikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/wiki/wikcnABC?foo=bar", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "url-wiki" {
|
||||
t.Fatalf("spec = %+v, want url-wiki wikcnABC", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecExtractsTokenAndObjTypeFromDocxURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "url-obj" {
|
||||
t.Fatalf("spec = %+v, want url-obj docxXYZ", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsURLObjTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("https://feishu.cn/sheets/shtXYZ", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
|
||||
t.Fatalf("expected URL/obj-type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") {
|
||||
t.Fatalf("expected unsupported URL path error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("/wiki/wikcnABC", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
|
||||
t.Fatalf("expected partial-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") {
|
||||
t.Fatalf("expected required-token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeGetDryRun(spec)
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("dry-run api = %#v, want single get_node call", got.API)
|
||||
}
|
||||
if got.API[0].Params["token"] != "docxXYZ" || got.API[0].Params["obj_type"] != "docx" {
|
||||
t.Fatalf("dry-run params = %#v", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWikiTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := formatWikiTimestamp(""); got != "" {
|
||||
t.Fatalf("formatWikiTimestamp(empty) = %q, want empty", got)
|
||||
}
|
||||
if got := formatWikiTimestamp("not-a-number"); got != "" {
|
||||
t.Fatalf("formatWikiTimestamp(non-numeric) = %q, want empty", got)
|
||||
}
|
||||
// Output is UTC, so it is deterministic regardless of host timezone.
|
||||
if got := formatWikiTimestamp("1700000000"); got != "2023-11-14T22:13:20Z" {
|
||||
t.Fatalf("formatWikiTimestamp(1700000000) = %q, want 2023-11-14T22:13:20Z (UTC)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "wikcnPARENT",
|
||||
"node_type": "origin",
|
||||
"title": "Design Spec",
|
||||
"has_child": true,
|
||||
"node_creator": "ou_creator",
|
||||
"owner": "ou_owner",
|
||||
"obj_edit_time": "1700000000",
|
||||
"obj_create_time": "1690000000",
|
||||
"node_create_time": "1690000001",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
var capturedQuery string
|
||||
stub.OnMatch = func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "https://feishu.cn/docx/docxXYZ",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
|
||||
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["title"] != "Design Spec" {
|
||||
t.Fatalf("title = %#v, want Design Spec", data["title"])
|
||||
}
|
||||
if data["obj_type"] != "docx" || data["obj_token"] != "docxXYZ" {
|
||||
t.Fatalf("obj_type/obj_token = %#v / %#v", data["obj_type"], data["obj_token"])
|
||||
}
|
||||
if data["parent_node_token"] != "wikcnPARENT" {
|
||||
t.Fatalf("parent_node_token = %#v", data["parent_node_token"])
|
||||
}
|
||||
if data["creator"] != "ou_creator" {
|
||||
t.Fatalf("creator = %#v, want ou_creator", data["creator"])
|
||||
}
|
||||
if data["owner"] != "ou_owner" {
|
||||
t.Fatalf("owner = %#v, want ou_owner", data["owner"])
|
||||
}
|
||||
if got, _ := data["updated_at"].(string); got != "2023-11-14T22:13:20Z" {
|
||||
t.Fatalf("updated_at = %#v, want 2023-11-14T22:13:20Z (UTC)", data["updated_at"])
|
||||
}
|
||||
// +node-get deliberately does not synthesize a url (get_node returns none;
|
||||
// a BuildResourceURL fallback would be a non-canonical, misleading link in
|
||||
// a read/confirm command).
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("did not expect a url field in +node-get output, got %#v", data["url"])
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "Fetching wiki node") {
|
||||
t.Fatalf("stderr = %q, want fetching message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Fallback Creator",
|
||||
"creator": "ou_legacy_creator",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "wikcnABC",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["creator"] != "ou_legacy_creator" {
|
||||
t.Fatalf("creator = %#v, want fallback to creator field", data["creator"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_actual",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Mismatch",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "wikcnABC",
|
||||
"--space-id", "space_expected",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the resolved node space") {
|
||||
t.Fatalf("expected space mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
120
shortcuts/wiki/wiki_space_create.go
Normal file
120
shortcuts/wiki/wiki_space_create.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// WikiSpaceCreate wraps wiki.spaces.create. The raw API only takes two
|
||||
// optional string fields, so the shortcut's value is flag ergonomics
|
||||
// (no hand-written --params JSON), output flattening (data.space.* lifted
|
||||
// to the top level), and a dry-run preview.
|
||||
//
|
||||
// The API only accepts a user access token (no tenant/bot), so AuthTypes is
|
||||
// user-only — the framework's CheckIdentity rejects --as bot for us.
|
||||
var WikiSpaceCreate = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+space-create",
|
||||
Description: "Create a wiki space",
|
||||
Risk: "write",
|
||||
// The API accepts wiki:wiki or wiki:space:write_only. The framework's
|
||||
// scope preflight does exact-string matching (see +space-list), so
|
||||
// declare the narrowest form the API takes to avoid false-rejecting
|
||||
// tokens that only carry wiki:space:write_only.
|
||||
Scopes: []string{"wiki:space:write_only"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "wiki space name", Required: true},
|
||||
{Name: "description", Desc: "wiki space description"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only --as user is supported; the create API does not accept a tenant/bot token.",
|
||||
"The underlying spaces.create API is flagged danger in the schema browser; a space is recoverable via `wiki +delete-space` if created by mistake.",
|
||||
"--name is required: an unnamed space is almost always an accident and is hard to find later.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiSpaceCreateSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiSpaceCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(wikiSpacesAPIPath).
|
||||
Body(spec.RequestBody())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiSpaceCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
|
||||
|
||||
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := common.GetMap(data, "space")
|
||||
if raw == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
|
||||
}
|
||||
|
||||
out := wikiSpaceCreateOutput(raw)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki space %s\n", common.MaskToken(common.GetString(out, "space_id")))
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiSpaceCreateSpec is the normalized CLI input.
|
||||
type wikiSpaceCreateSpec struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// RequestBody converts the normalized input into the OpenAPI payload. Both
|
||||
// fields are optional per the API, but Validate enforces a non-empty name,
|
||||
// so name is always present here.
|
||||
func (spec wikiSpaceCreateSpec) RequestBody() map[string]interface{} {
|
||||
body := map[string]interface{}{"name": spec.Name}
|
||||
if spec.Description != "" {
|
||||
body["description"] = spec.Description
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpec, error) {
|
||||
spec := wikiSpaceCreateSpec{
|
||||
Name: strings.TrimSpace(runtime.Str("name")),
|
||||
Description: strings.TrimSpace(runtime.Str("description")),
|
||||
}
|
||||
if spec.Name == "" {
|
||||
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// wikiSpaceCreateOutput flattens data.space into the top-level envelope. It
|
||||
// reads the raw map (rather than parseWikiSpaceRecord) so the description
|
||||
// the caller just set round-trips back in the output.
|
||||
func wikiSpaceCreateOutput(raw map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(raw, "space_id"),
|
||||
"name": common.GetString(raw, "name"),
|
||||
"description": common.GetString(raw, "description"),
|
||||
"space_type": common.GetString(raw, "space_type"),
|
||||
"visibility": common.GetString(raw, "visibility"),
|
||||
"open_sharing": common.GetString(raw, "open_sharing"),
|
||||
}
|
||||
}
|
||||
207
shortcuts/wiki/wiki_space_create_test.go
Normal file
207
shortcuts/wiki/wiki_space_create_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWikiSpaceCreateDeclaredContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if WikiSpaceCreate.Command != "+space-create" {
|
||||
t.Fatalf("Command = %q, want +space-create", WikiSpaceCreate.Command)
|
||||
}
|
||||
if WikiSpaceCreate.Risk != "write" {
|
||||
t.Fatalf("Risk = %q, want write", WikiSpaceCreate.Risk)
|
||||
}
|
||||
if !reflect.DeepEqual(WikiSpaceCreate.AuthTypes, []string{"user"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user]", WikiSpaceCreate.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(WikiSpaceCreate.Scopes, []string{"wiki:space:write_only"}) {
|
||||
t.Fatalf("Scopes = %v, want [wiki:space:write_only]", WikiSpaceCreate.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWikiSpaceCreateSpecRejectsBlankName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", " ", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
if _, err := readWikiSpaceCreateSpec(runtime); err == nil || !strings.Contains(err.Error(), "--name is required") {
|
||||
t.Fatalf("expected blank-name rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nameOnly := wikiSpaceCreateSpec{Name: "Eng Wiki"}.RequestBody()
|
||||
if !reflect.DeepEqual(nameOnly, map[string]interface{}{"name": "Eng Wiki"}) {
|
||||
t.Fatalf("name-only body = %#v", nameOnly)
|
||||
}
|
||||
|
||||
full := wikiSpaceCreateSpec{Name: "Eng Wiki", Description: "team docs"}.RequestBody()
|
||||
if full["name"] != "Eng Wiki" || full["description"] != "team docs" {
|
||||
t.Fatalf("full body = %#v", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", "Eng Wiki", "")
|
||||
cmd.Flags().String("description", "team docs", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
|
||||
dry := WikiSpaceCreate.DryRun(nil, runtime)
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/wiki/v2/spaces" {
|
||||
t.Fatalf("dry-run api = %#v", got.API)
|
||||
}
|
||||
if got.API[0].Body["name"] != "Eng Wiki" || got.API[0].Body["description"] != "team docs" {
|
||||
t.Fatalf("dry-run body = %#v", got.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateDryRunBlankNameSurfacesError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
|
||||
dry := WikiSpaceCreate.DryRun(nil, runtime)
|
||||
data, _ := json.Marshal(dry)
|
||||
if !strings.Contains(string(data), "--name is required") {
|
||||
t.Fatalf("dry-run should surface validation error, got %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateMountedExecuteFlattensSpace(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "7160145948494381236",
|
||||
"name": "Eng Wiki",
|
||||
"description": "team docs",
|
||||
"space_type": "team",
|
||||
"visibility": "private",
|
||||
"open_sharing": "closed",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--description", "team docs",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["space_id"] != "7160145948494381236" {
|
||||
t.Fatalf("space_id = %#v", data["space_id"])
|
||||
}
|
||||
if data["name"] != "Eng Wiki" || data["description"] != "team docs" {
|
||||
t.Fatalf("name/description = %#v / %#v", data["name"], data["description"])
|
||||
}
|
||||
if data["space_type"] != "team" || data["visibility"] != "private" || data["open_sharing"] != "closed" {
|
||||
t.Fatalf("space_type/visibility/open_sharing = %#v", data)
|
||||
}
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("output must not include a url field, got %#v", data["url"])
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if captured["name"] != "Eng Wiki" || captured["description"] != "team docs" {
|
||||
t.Fatalf("captured request body = %#v", captured)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Created wiki space") {
|
||||
t.Fatalf("stderr = %q, want creation log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateRejectsBotIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supports: user") {
|
||||
t.Fatalf("expected bot identity rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateErrorsWhenNoSpaceReturned(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "returned no space") {
|
||||
t.Fatalf("expected missing-space error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces"
|
||||
wikiSpacesAPIPath = "/open-apis/wiki/v2/spaces"
|
||||
wikiSpaceListDefaultPageSize = 50
|
||||
wikiSpaceListMaxPageSize = 50
|
||||
)
|
||||
@@ -59,7 +59,7 @@ var WikiSpaceList = common.Shortcut{
|
||||
if wikiListShouldAutoPaginate(runtime) {
|
||||
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||
}
|
||||
return dry.GET(wikiSpaceListAPIPath).Params(params)
|
||||
return dry.GET(wikiSpacesAPIPath).Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
@@ -103,7 +103,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil)
|
||||
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
|
||||
#### 处理流程
|
||||
|
||||
**推荐方式:使用 `drive +inspect` 自动解包**
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
```
|
||||
|
||||
返回结果包含 `type`(底层文档类型)、`token`(真实 file_token)、`title`、`url` 等字段,直接用于后续操作。
|
||||
|
||||
**手动方式:使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user