mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
26 Commits
v1.0.33
...
eval-searc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02a38f077 | ||
|
|
3a3fc31d0b | ||
|
|
8c73f49e91 | ||
|
|
9272b9da99 | ||
|
|
27a5eeddcc | ||
|
|
0c4eadd41e | ||
|
|
69c34481f5 | ||
|
|
fa45e1c7e4 | ||
|
|
d793790807 | ||
|
|
13411d9a51 | ||
|
|
939b7b6fb6 | ||
|
|
a4c5ec99c8 | ||
|
|
7c54f9b023 | ||
|
|
e6bc292575 | ||
|
|
4aa61db8b2 | ||
|
|
28c66be199 | ||
|
|
0e70b056f8 | ||
|
|
95ffff4212 | ||
|
|
e511404065 | ||
|
|
b8469d2dc6 | ||
|
|
afa084e7a4 | ||
|
|
3354494579 | ||
|
|
2bb69d1942 | ||
|
|
c4fb7006d2 | ||
|
|
583349e572 | ||
|
|
315e0ab50c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -536,11 +536,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
||||
// produces no skills key in the composed notice.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
@@ -571,13 +568,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// TestSetupNotices_InSync verifies that matching state produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -604,13 +601,13 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// TestSetupNotices_Drift verifies mismatching state produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -659,7 +656,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,12 @@ var (
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||
// normalizeVersion canonicalizes a version string for state comparison.
|
||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||
func normalizeVersion(s string) string {
|
||||
@@ -121,7 +122,9 @@ func updateRun(opts *UpdateOptions) error {
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
updater.CleanupStaleFiles()
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
@@ -137,13 +140,9 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
// Run skills sync before returning — covers the case where the
|
||||
// binary is already current but skills were never synced.
|
||||
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||
var skillsResult *selfupdate.NpmResult
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -185,16 +184,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
// skills_status: pure report, no side effect, no stamp write.
|
||||
// ReadStamp errors are silently swallowed — if we can't read the
|
||||
// stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
@@ -210,7 +200,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -288,10 +278,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||
// stamp gets persisted on success and dedup applies if a previous
|
||||
// run already stamped this version.
|
||||
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -328,27 +315,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
// stamp on success. Skips the npx invocation when the stamp already
|
||||
// matches stampVersion (unless force is true). The stamp write failure
|
||||
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||
// from RunSkillsUpdate.
|
||||
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r := updater.RunSkillsUpdate()
|
||||
if r.Err == nil {
|
||||
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
}
|
||||
return r
|
||||
return result
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
@@ -356,7 +337,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||
// skills_status (spec §4.2) instead of skills_action.
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
@@ -364,16 +345,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
}
|
||||
if check {
|
||||
// Pure report — read stamp directly, emit skills_status block.
|
||||
// ReadStamp errors are silently swallowed — if we can't read
|
||||
// the stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
} else {
|
||||
applySkillsResult(out, skillsResult)
|
||||
}
|
||||
@@ -387,36 +359,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||
func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return
|
||||
}
|
||||
status := map[string]interface{}{
|
||||
"current": state.Version,
|
||||
"target": target,
|
||||
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
|
||||
}
|
||||
if len(state.OfficialSkills) > 0 {
|
||||
status["official"] = len(state.OfficialSkills)
|
||||
}
|
||||
if len(state.UpdatedSkills) > 0 {
|
||||
status["updated"] = len(state.UpdatedSkills)
|
||||
}
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
env["skills_action"] = "in_sync"
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
}
|
||||
}
|
||||
|
||||
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||
// sync result for non-JSON output.
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
summary := map[string]interface{}{
|
||||
"official": len(r.Official),
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
// dedup hit — silent (already up to date)
|
||||
case r.Err != nil:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -28,7 +26,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
@@ -41,22 +38,34 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("lark-calendar\nlark-mail\n")
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("lark-calendar\ncustom-skill\n")
|
||||
default:
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
@@ -168,9 +177,7 @@ func TestUpdateManual_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
|
||||
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
|
||||
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
|
||||
// Isolate config dir because skills sync writes skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -186,7 +193,6 @@ func TestUpdateNpm_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -216,7 +222,6 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -230,7 +235,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -246,7 +251,6 @@ func TestUpdateForce_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -323,7 +327,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -339,7 +343,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -451,8 +454,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
@@ -649,7 +652,7 @@ func TestPermissionHint(t *testing.T) {
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -668,7 +671,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -750,7 +752,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -785,8 +786,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -812,8 +812,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
if !strings.Contains(out, "skills_summary") {
|
||||
t.Errorf("expected skills_summary in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +838,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -861,100 +861,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
if !strings.Contains(out, "lark-cli update --force") {
|
||||
t.Errorf("expected force retry hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
|
||||
// for direct calls to internals like runSkillsAndStamp that write to
|
||||
// io.ErrOut.
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
|
||||
func newTestIO() *cmdutil.IOStreams {
|
||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
|
||||
t.Error("SkillsCommandOverride called, want skipped due to dedup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil {
|
||||
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsUpdateOverride not called with force=true")
|
||||
t.Error("SkillsCommandOverride not called with force=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,8 +969,7 @@ func TestTruncate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -987,9 +982,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1000,17 +995,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
|
||||
t.Error("skills sync not called in already-up-to-date branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1029,9 +1026,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||
}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1042,17 +1039,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in manual branch, want called")
|
||||
t.Error("skills sync not called in manual branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1075,9 +1074,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
VerifyOverride: func(expectedVersion string) error { return nil },
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1088,18 +1087,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in npm branch")
|
||||
t.Error("skills sync not called in npm branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.22" {
|
||||
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.22" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.20",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedSkills: []string{"lark-calendar"},
|
||||
SkippedDeletedSkills: []string{"lark-mail"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1117,9 +1123,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1130,7 +1136,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
|
||||
t.Error("skills sync called under --check, want skipped")
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1144,12 +1150,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||
}
|
||||
if status["official"] != float64(2) || status["updated"] != float64(1) {
|
||||
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1164,9 +1172,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1177,12 +1185,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
|
||||
t.Error("skills sync called under --check (already-latest), want skipped")
|
||||
}
|
||||
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1204,38 +1215,26 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
|
||||
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
|
||||
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
|
||||
// Force WriteStamp to fail by pointing config dir at a path that exists
|
||||
// as a regular file (so MkdirAll fails).
|
||||
tmp := t.TempDir()
|
||||
badPath := filepath.Join(tmp, "blocker")
|
||||
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
origSync := syncSkills
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{} // success
|
||||
},
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
|
||||
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
|
||||
// message is printed to ErrOut on a successful (Err == nil) result.
|
||||
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
|
||||
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
|
||||
if !strings.Contains(stderr.String(), "Skills updated") {
|
||||
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
||||
}
|
||||
|
||||
3
go.mod
3
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -19,6 +20,7 @@ require (
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.23.0
|
||||
@@ -61,5 +63,4 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
)
|
||||
|
||||
15
go.sum
15
go.sum
@@ -45,6 +45,7 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -73,6 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -97,6 +103,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
||||
@@ -107,8 +115,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
@@ -163,7 +173,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsUpdateOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
|
||||
@@ -166,7 +167,46 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsListOfficial("larksuite/cli")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListGlobalSkills() *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(name string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", name)
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall("larksuite/cli", name)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListGlobal() *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "ls", "-g")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsInstall(source string, name string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-s", name, "-g", "-y")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
@@ -175,7 +215,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) {
|
||||
t.Fatal("VerifyBinary(empty output) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*Updater) *NpmResult
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "list official primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn --list",
|
||||
},
|
||||
{
|
||||
name: "list global",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
},
|
||||
want: "-y skills ls -g",
|
||||
},
|
||||
{
|
||||
name: "install skill primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall("https://open.feishu.cn", "lark-mail")
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "npx")
|
||||
logPath := filepath.Join(dir, "npx.log")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
result := tt.run(New())
|
||||
if result.Err != nil {
|
||||
t.Fatalf("command err = %v, want nil", result.Err)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) != tt.want {
|
||||
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
|
||||
r.Err = fmt.Errorf("primary failed")
|
||||
return r
|
||||
}
|
||||
r.Stdout.WriteString("lark-calendar\n")
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := updater.ListOfficialSkills()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
|
||||
}
|
||||
if !strings.Contains(called[1], "larksuite/cli --list") {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,29 @@
|
||||
|
||||
package skillscheck
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice
|
||||
// when the local stamp records a version that does not match
|
||||
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
|
||||
// zero network, zero subprocess — only a local stamp file read.
|
||||
import "strings"
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice when
|
||||
// the local skills state records a version that does not match currentVersion.
|
||||
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
|
||||
// subprocess — only a local state file read.
|
||||
//
|
||||
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
|
||||
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
|
||||
//
|
||||
// Failure modes (all → no notice, no nag):
|
||||
// - shouldSkip rule met
|
||||
// - ReadStamp returns an I/O error other than ENOENT
|
||||
// - Stamp matches currentVersion (in-sync)
|
||||
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
|
||||
// opt into drift tracking; npx-only installs are intentionally silent.
|
||||
func Init(currentVersion string) {
|
||||
// Clear any stale notice from a prior call so early returns below
|
||||
// (skip rules / read errors / cold start / in-sync) leave pending == nil
|
||||
// instead of preserving a stale value from a previous Init invocation.
|
||||
SetPending(nil)
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
}
|
||||
stamp, err := ReadStamp()
|
||||
if err != nil {
|
||||
// Fail closed — don't nag for a transient FS problem.
|
||||
version, ok := ReadSyncedVersion()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if stamp == "" {
|
||||
// Cold start: the stamp is written exclusively by `lark-cli update`
|
||||
// (runSkillsAndStamp). Users who installed skills via
|
||||
// `npx skills add larksuite/cli -g` have no stamp yet — they must
|
||||
// not be nagged with "skills not installed", since the on-disk
|
||||
// skills directory may already be fully populated.
|
||||
return
|
||||
}
|
||||
if stamp == currentVersion {
|
||||
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
|
||||
return
|
||||
}
|
||||
SetPending(&StaleNotice{
|
||||
Current: stamp, // guaranteed non-empty under the new contract
|
||||
Current: version,
|
||||
Target: currentVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ func resetPending(t *testing.T) {
|
||||
func TestInit_InSync_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
@@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
|
||||
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("v1.0.21")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
@@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
// Even with an empty config dir (no stamp), DEV version should skip
|
||||
// the check entirely and never emit a notice.
|
||||
Init("DEV")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
|
||||
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Make the stamp path a directory so vfs.ReadFile returns a
|
||||
// non-ENOENT I/O error.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
// Package skillscheck verifies that the locally installed lark-cli
|
||||
// skills are in sync with the running binary version, by comparing
|
||||
// the current binary version against a stamp file written when skills
|
||||
// are last synced (by `lark-cli update`). On mismatch it stores a
|
||||
// notice for injection into JSON envelopes via output.PendingNotice.
|
||||
// the current binary version against skills-state.json. On mismatch it
|
||||
// stores a notice for injection into JSON envelopes via output.PendingNotice.
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
@@ -26,8 +25,7 @@ type StaleNotice struct {
|
||||
// Message returns a single-line, AI-agent-parseable description of the
|
||||
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
|
||||
// non-empty because Init only emits a StaleNotice for the drift case
|
||||
// (stamp present and != binary version).
|
||||
// non-empty because Init only emits a StaleNotice for the drift case.
|
||||
func (s *StaleNotice) Message() string {
|
||||
return fmt.Sprintf(
|
||||
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const stampFile = "skills.stamp"
|
||||
|
||||
// stampPath returns ~/.lark-cli/skills.stamp.
|
||||
// Uses the BASE config dir (not workspace-aware) because skills install
|
||||
// globally via `npx -g`; per-workspace tracking would produce false
|
||||
// drift signals when switching workspaces.
|
||||
func stampPath() string {
|
||||
return filepath.Join(core.GetBaseConfigDir(), stampFile)
|
||||
}
|
||||
|
||||
// ReadStamp returns the version recorded in the stamp file. Returns
|
||||
// ("", nil) when the file does not exist (interpreted as "never synced").
|
||||
// Other I/O errors are returned as-is so callers can fail closed.
|
||||
func ReadStamp() (string, error) {
|
||||
data, err := vfs.ReadFile(stampPath())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// WriteStamp records `version` as the last successfully synced skills
|
||||
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
|
||||
// the base config directory if it does not exist.
|
||||
func WriteStamp(version string) error {
|
||||
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadStamp_Missing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
got, err := ReadStamp()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_Normal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_CreatesDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatalf("WriteStamp() = %v, want nil", err)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(got) != "1.0.21" {
|
||||
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_OverwritesExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(raw) != "1.0.21" {
|
||||
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
|
||||
// when the base config dir cannot be created (parent path is a regular file).
|
||||
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
blocker := filepath.Join(tmp, "blocker")
|
||||
// Create a regular file where MkdirAll wants to create a directory.
|
||||
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
|
||||
|
||||
if err := WriteStamp("1.0.21"); err == nil {
|
||||
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
|
||||
}
|
||||
}
|
||||
90
internal/skillscheck/state.go
Normal file
90
internal/skillscheck/state.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
stateFile = "skills-state.json"
|
||||
stateSchemaVersion = 1
|
||||
)
|
||||
|
||||
type SkillsState struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Version string `json:"version"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedSkills []string `json:"added_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func statePath() string {
|
||||
return filepath.Join(core.GetBaseConfigDir(), stateFile)
|
||||
}
|
||||
|
||||
func ReadState() (*SkillsState, bool, error) {
|
||||
data, err := vfs.ReadFile(statePath())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var state SkillsState
|
||||
if json.Unmarshal(data, &state) != nil {
|
||||
state = SkillsState{}
|
||||
}
|
||||
if state.SchemaVersion != stateSchemaVersion {
|
||||
return nil, false, nil
|
||||
}
|
||||
return &state, true, nil
|
||||
}
|
||||
|
||||
func WriteState(state SkillsState) error {
|
||||
state.SchemaVersion = stateSchemaVersion
|
||||
state.ensureNonNilSlices()
|
||||
|
||||
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func ReadSyncedVersion() (string, bool) {
|
||||
state, ok, err := ReadState()
|
||||
if err != nil || !ok || state.Version == "" {
|
||||
return "", false
|
||||
}
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
}
|
||||
if s.UpdatedSkills == nil {
|
||||
s.UpdatedSkills = []string{}
|
||||
}
|
||||
if s.AddedSkills == nil {
|
||||
s.AddedSkills = []string{}
|
||||
}
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
}
|
||||
153
internal/skillscheck/state_test.go
Normal file
153
internal/skillscheck/state_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadState_Missing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
state, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil for missing file", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ReadState() ok = true, want false for missing file")
|
||||
}
|
||||
if state != nil {
|
||||
t.Fatalf("ReadState() state = %#v, want nil for missing file", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadState_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
want := SkillsState{
|
||||
SchemaVersion: 1,
|
||||
Version: "1.2.3",
|
||||
OfficialSkills: []string{"lark-doc", "lark-im"},
|
||||
UpdatedSkills: []string{"lark-doc"},
|
||||
AddedSkills: []string{"lark-task"},
|
||||
SkippedDeletedSkills: []string{"custom-skill"},
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ReadState() ok = false, want true")
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("ReadState() state = nil, want state")
|
||||
}
|
||||
if !reflect.DeepEqual(*got, want) {
|
||||
t.Fatalf("ReadState() state = %#v, want %#v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadState_CorruptOrUnknownSchemaUnreadable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{name: "corrupt json", data: []byte(`{"schema_version":`)},
|
||||
{name: "unknown schema", data: []byte(`{"schema_version":2,"version":"1.2.3"}`)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, stateFile), tt.data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ReadState() ok = true, want false")
|
||||
}
|
||||
if state != nil {
|
||||
t.Fatalf("ReadState() state = %#v, want nil", state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
state := SkillsState{
|
||||
Version: "1.2.3",
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
t.Fatalf("WriteState() err = %v, want nil", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got SkillsState
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("written state is invalid JSON: %v", err)
|
||||
}
|
||||
if got.SchemaVersion != 1 {
|
||||
t.Fatalf("schema_version = %d, want 1", got.SchemaVersion)
|
||||
}
|
||||
if got.Version != state.Version {
|
||||
t.Fatalf("version = %q, want %q", got.Version, state.Version)
|
||||
}
|
||||
if got.OfficialSkills == nil {
|
||||
t.Fatal("official_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.UpdatedSkills == nil {
|
||||
t.Fatal("updated_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.AddedSkills == nil {
|
||||
t.Fatal("added_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.SkippedDeletedSkills == nil {
|
||||
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSyncedVersionFromState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
if got, ok := ReadSyncedVersion(); ok || got != "" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := ReadSyncedVersion(); ok || got != "" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
|
||||
}
|
||||
}
|
||||
265
internal/skillscheck/sync.go
Normal file
265
internal/skillscheck/sync.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
var skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
StateReadable bool
|
||||
Force bool
|
||||
}
|
||||
|
||||
type SyncPlan struct {
|
||||
Version string
|
||||
OfficialSkills []string
|
||||
ToUpdate []string
|
||||
Added []string
|
||||
SkippedDeleted []string
|
||||
}
|
||||
|
||||
func ParseSkillsList(text string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
token := strings.TrimSpace(line)
|
||||
token = strings.TrimPrefix(token, "-")
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || strings.Contains(token, " ") || strings.HasSuffix(token, ":") {
|
||||
continue
|
||||
}
|
||||
if !skillNamePattern.MatchString(token) {
|
||||
continue
|
||||
}
|
||||
if at := strings.Index(token, "@"); at > 0 {
|
||||
token = token[:at]
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: official,
|
||||
Added: []string{},
|
||||
SkippedDeleted: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
officialSet := toSet(official)
|
||||
localOfficial := intersection(input.LocalSkills, officialSet)
|
||||
|
||||
previousOfficial := []string{}
|
||||
if input.StateReadable && input.PreviousState != nil {
|
||||
previousOfficial = input.PreviousState.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
|
||||
newOfficial := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
newOfficial = append(newOfficial, skill)
|
||||
}
|
||||
}
|
||||
|
||||
updateSet := toSet(localOfficial)
|
||||
for _, skill := range newOfficial {
|
||||
updateSet[skill] = true
|
||||
}
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: uniqueSorted(newOfficial),
|
||||
SkippedDeleted: skipped,
|
||||
}
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(name string) *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Action string
|
||||
Official []string
|
||||
Updated []string
|
||||
Added []string
|
||||
SkippedDeleted []string
|
||||
Failed []string
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
|
||||
officialResult := opts.Runner.ListOfficialSkills()
|
||||
if officialResult == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: empty result")}
|
||||
}
|
||||
if officialResult.Err != nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: %w", officialResult.Err), Detail: resultDetail(officialResult)}
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
|
||||
localResult := opts.Runner.ListGlobalSkills()
|
||||
if localResult == nil {
|
||||
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: empty result")}
|
||||
}
|
||||
if localResult.Err != nil {
|
||||
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: %w", localResult.Err), Detail: resultDetail(localResult)}
|
||||
}
|
||||
local := ParseSkillsList(localResult.Stdout.String())
|
||||
|
||||
previous, readable, err := ReadState()
|
||||
if err != nil {
|
||||
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to read skills state: %w", err)}
|
||||
}
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
Official: plan.OfficialSkills,
|
||||
Updated: plan.ToUpdate,
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
}
|
||||
|
||||
failed := []string{}
|
||||
var details []string
|
||||
for _, skill := range plan.ToUpdate {
|
||||
installResult := opts.Runner.InstallSkill(skill)
|
||||
if installResult == nil {
|
||||
failed = append(failed, skill)
|
||||
details = append(details, skill+": empty result")
|
||||
continue
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
failed = append(failed, skill)
|
||||
details = append(details, skill+": "+resultDetail(installResult))
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
result.Action = "failed"
|
||||
result.Failed = failed
|
||||
result.Err = fmt.Errorf("%d skill(s) failed", len(failed))
|
||||
result.Detail = strings.Join(details, "\n")
|
||||
return result
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
|
||||
parts = append(parts, output)
|
||||
}
|
||||
if result.Err != nil {
|
||||
parts = append(parts, result.Err.Error())
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func uniqueSorted(values []string) []string {
|
||||
return sortedKeys(toSet(values))
|
||||
}
|
||||
|
||||
func toSet(values []string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intersection(values []string, allowed map[string]bool) []string {
|
||||
out := map[string]bool{}
|
||||
for _, value := range values {
|
||||
if allowed[value] {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(out)
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]bool) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
222
internal/skillscheck/sync_test.go
Normal file
222
internal/skillscheck/sync_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
func TestParseSkillsList(t *testing.T) {
|
||||
input := `Installed skills:
|
||||
- lark-calendar
|
||||
- lark-mail
|
||||
lark-im
|
||||
custom-skill
|
||||
lark-base@1.0.0
|
||||
lark-cli-harness:dev@0.1.0
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-cli-harness:dev", "lark-im", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar", "lark-custom"},
|
||||
PreviousState: previous,
|
||||
StateReadable: true,
|
||||
Force: false,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{"lark-new"})
|
||||
assertStrings(t, got.SkippedDeleted, []string{"lark-mail"})
|
||||
}
|
||||
|
||||
func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) {
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar"},
|
||||
StateReadable: false,
|
||||
Force: false,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar"},
|
||||
PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}},
|
||||
StateReadable: true,
|
||||
Force: true,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{})
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr map[string]error
|
||||
installed []string
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
r.Err = f.officialErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSkill(name string) *selfupdate.NpmResult {
|
||||
f.installed = append(f.installed, name)
|
||||
r := &selfupdate.NpmResult{}
|
||||
if f.installErr != nil {
|
||||
r.Err = f.installErr[name]
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.30",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "lark-calendar\nlark-mail\nlark-new\n",
|
||||
globalOut: "lark-calendar\nlark-custom\n",
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Runner: runner,
|
||||
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-new"})
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, state.AddedSkills, []string{"lark-new"})
|
||||
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListFailureDoesNotInstallOrWriteState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{officialErr: fmt.Errorf("list failed")}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list official skills") {
|
||||
t.Fatalf("SyncSkills() err = %v, want official list failure", result.Err)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want none", runner.installed)
|
||||
}
|
||||
if _, readable, err := ReadState(); err != nil || readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalListFailureDoesNotInstallOrWriteState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "lark-calendar\nlark-mail\n",
|
||||
globalErr: fmt.Errorf("global list failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list installed skills") {
|
||||
t.Fatalf("SyncSkills() err = %v, want installed list failure", result.Err)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want none", runner.installed)
|
||||
}
|
||||
if _, readable, err := ReadState(); err != nil || readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureContinuesAndDoesNotWriteState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "lark-calendar\nlark-mail\n",
|
||||
globalOut: "lark-calendar\nlark-mail\n",
|
||||
installErr: map[string]error{"lark-calendar": fmt.Errorf("boom")},
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "1 skill(s) failed") {
|
||||
t.Fatalf("SyncSkills() err = %v, want install failure", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Failed, []string{"lark-calendar"})
|
||||
if !strings.Contains(result.Detail, "boom") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want install error text", result.Detail)
|
||||
}
|
||||
if _, readable, err := ReadState(); err != nil || readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want no success state", readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_NilRunnerFails(t *testing.T) {
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -10,6 +10,8 @@ const p = require("@clack/prompts");
|
||||
const PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
const SKILLS_REPO_FALLBACK = "larksuite/cli";
|
||||
const CONFIG_DIR = process.env.LARKSUITE_CLI_CONFIG_DIR || path.join(process.env.HOME || process.env.USERPROFILE || "", ".lark-cli");
|
||||
const SKILLS_STATE_FILE = path.join(CONFIG_DIR, "skills-state.json");
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -236,7 +238,7 @@ async function stepInstallGlobally(msg) {
|
||||
|
||||
if (installedVer && !needsUpgrade) {
|
||||
p.log.info(fmt(msg.step1Skip, installedVer));
|
||||
return false;
|
||||
return installedVer;
|
||||
}
|
||||
|
||||
const s = p.spinner();
|
||||
@@ -248,41 +250,111 @@ async function stepInstallGlobally(msg) {
|
||||
try {
|
||||
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
|
||||
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
|
||||
return needsUpgrade;
|
||||
return latestVer || getGloballyInstalledVersion() || installedVer || null;
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step1Fail, PKG));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function skillsAlreadyInstalled() {
|
||||
function parseSkillsList(text) {
|
||||
const seen = new Set();
|
||||
for (const rawLine of text.split("\n")) {
|
||||
let token = rawLine.trim();
|
||||
if (token.startsWith("-")) token = token.slice(1).trim();
|
||||
if (!token || token.includes(" ") || token.endsWith(":")) continue;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_:-]*(?:@\S+)?$/.test(token)) continue;
|
||||
const at = token.indexOf("@");
|
||||
if (at > 0) token = token.slice(0, at);
|
||||
seen.add(token);
|
||||
}
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
function readSkillsState() {
|
||||
try {
|
||||
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
return /^lark-/m.test(out.toString());
|
||||
const state = JSON.parse(fs.readFileSync(SKILLS_STATE_FILE, "utf8"));
|
||||
if (state.schema_version !== 1 || !Array.isArray(state.official_skills)) return null;
|
||||
return state;
|
||||
} catch (_) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stepInstallSkills(msg) {
|
||||
function writeSkillsState(version, official, updated, added, skipped) {
|
||||
if (!CONFIG_DIR) return;
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(SKILLS_STATE_FILE, JSON.stringify({
|
||||
schema_version: 1,
|
||||
version,
|
||||
official_skills: official,
|
||||
updated_skills: updated,
|
||||
added_skills: added,
|
||||
skipped_deleted_skills: skipped,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, null, 2) + "\n");
|
||||
}
|
||||
|
||||
async function listOfficialSkills() {
|
||||
try {
|
||||
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "--list"], { timeout: 120000 }));
|
||||
} catch (_) {
|
||||
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "--list"], { timeout: 120000 }));
|
||||
}
|
||||
}
|
||||
|
||||
async function listGlobalSkills() {
|
||||
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], { timeout: 120000 }));
|
||||
}
|
||||
|
||||
function planSkillsSync(version, official, local, previousState) {
|
||||
const officialSet = new Set(official);
|
||||
const previousSet = new Set(previousState ? previousState.official_skills : []);
|
||||
const localOfficial = local.filter((skill) => officialSet.has(skill));
|
||||
const added = official.filter((skill) => !previousSet.has(skill));
|
||||
const updateSet = new Set([...localOfficial, ...added]);
|
||||
const updated = official.filter((skill) => updateSet.has(skill));
|
||||
return {
|
||||
version,
|
||||
official,
|
||||
updated,
|
||||
added,
|
||||
skipped: official.filter((skill) => !updateSet.has(skill)),
|
||||
};
|
||||
}
|
||||
|
||||
async function installSkill(name) {
|
||||
try {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-s", name, "-g", "-y"], { timeout: 120000 });
|
||||
} catch (_) {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-s", name, "-g", "-y"], { timeout: 120000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function stepInstallSkills(msg, cliVersion) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step2Spinner);
|
||||
try {
|
||||
if (await skillsAlreadyInstalled()) {
|
||||
const official = await listOfficialSkills();
|
||||
const local = await listGlobalSkills();
|
||||
const plan = planSkillsSync(cliVersion || "unknown", official, local, readSkillsState());
|
||||
if (plan.updated.length === 0) {
|
||||
writeSkillsState(plan.version, plan.official, plan.updated, plan.added, plan.skipped);
|
||||
s.stop(msg.step2Skip);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (_) {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
const failed = [];
|
||||
for (const skill of plan.updated) {
|
||||
try {
|
||||
await installSkill(skill);
|
||||
} catch (_) {
|
||||
failed.push(skill);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
throw new Error(`${failed.length} skill(s) failed: ${failed.join(", ")}`);
|
||||
}
|
||||
writeSkillsState(plan.version, plan.official, plan.updated, plan.added, plan.skipped);
|
||||
s.stop(msg.step2Done);
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
|
||||
@@ -361,15 +433,15 @@ async function main() {
|
||||
|
||||
if (isInteractive) {
|
||||
p.intro(msg.setup);
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
const cliVersion = await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg, cliVersion);
|
||||
await stepConfigInit(msg, lang);
|
||||
await stepAuthLogin(msg);
|
||||
p.outro(msg.done);
|
||||
} else {
|
||||
console.log(msg.setup);
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
const cliVersion = await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg, cliVersion);
|
||||
console.log(msg.nonTtyHint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
44
shortcuts/base/base_form_detail.go
Normal file
44
shortcuts/base/base_form_detail.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseFormDetail = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-detail",
|
||||
Description: "Get form detail by share token",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:form:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "share-token", Desc: "Form share token (share_token)", Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/tables/forms/detail").
|
||||
Body(map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", "tables", "forms", "detail"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
334
shortcuts/base/base_form_submit.go
Normal file
334
shortcuts/base/base_form_submit.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadAttachConcurrency = 5
|
||||
)
|
||||
|
||||
var BaseFormSubmit = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-submit",
|
||||
Description: "Submit a form (fill and submit form data)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true},
|
||||
{Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"},
|
||||
{Name: "json", Desc: `JSON object containing "fields" (field values) and "attachments" (attachment file paths). Example: '{"fields":{"Rating":5,"Review":"Good"},"attachments":{"Attachment":["./a.pdf","./b.png"]}}'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
||||
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
||||
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFormSubmit(runtime)
|
||||
},
|
||||
DryRun: dryRunFormSubmit,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeFormSubmit(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func validateFormSubmit(runtime *common.RuntimeContext) error {
|
||||
// 校验 --json 结构:提取 "fields" 和 "attachments"
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, _ := raw["fields"].(map[string]interface{})
|
||||
attachments, hasAttachments := raw["attachments"]
|
||||
|
||||
if !hasAttachments && fields == nil {
|
||||
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
}
|
||||
|
||||
if hasAttachments {
|
||||
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
||||
if runtime.Str("base-token") == "" {
|
||||
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
}
|
||||
|
||||
attMap, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
}
|
||||
for fieldName, value := range attMap {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
for i, item := range paths {
|
||||
if _, ok := item.(string); !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseFormSubmitJSON 将 --json 解析为字段和附件映射。
|
||||
func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fields, _ := raw["fields"].(map[string]interface{})
|
||||
if fields == nil {
|
||||
fields = make(map[string]interface{})
|
||||
}
|
||||
|
||||
var attMap map[string][]string
|
||||
if attachments, ok := raw["attachments"]; ok {
|
||||
attObj, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
}
|
||||
if len(attObj) > 0 {
|
||||
attMap = make(map[string][]string, len(attObj))
|
||||
for fieldName, value := range attObj {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
filePaths := make([]string, 0, len(paths))
|
||||
for _, item := range paths {
|
||||
if s, ok := item.(string); ok {
|
||||
filePaths = append(filePaths, s)
|
||||
} else {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
}
|
||||
}
|
||||
if len(filePaths) > 0 {
|
||||
attMap[fieldName] = filePaths
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields, attMap, nil
|
||||
}
|
||||
|
||||
func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
|
||||
if len(attachmentMap) > 0 {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Form submit with attachments: upload local files per field → merge with fields → submit")
|
||||
|
||||
for fieldName, filePaths := range attachmentMap {
|
||||
for _, p := range filePaths {
|
||||
fileName := filepath.Base(p)
|
||||
dry = dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)).
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseFormAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"extra": baseFormAttachmentExtra(runtime.Str("share-token")),
|
||||
"file": "@" + p,
|
||||
"size": "<file_size>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit").
|
||||
Body(body).
|
||||
Desc("Submit form with uploaded attachment tokens merged with fields")
|
||||
return dry
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/tables/forms/submit").
|
||||
Body(body)
|
||||
}
|
||||
|
||||
func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
"content": content,
|
||||
}
|
||||
}
|
||||
|
||||
func executeFormSubmit(runtime *common.RuntimeContext) error {
|
||||
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上传附件并合并到字段中
|
||||
if len(attachmentMap) > 0 {
|
||||
baseToken := runtime.Str("base-token")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
}
|
||||
|
||||
// Step 1: 收集所有唯一路径(跨字段去重)
|
||||
allPaths := collectUniquePaths(attachmentMap)
|
||||
if len(allPaths) == 0 {
|
||||
return common.FlagErrorf("attachments in --json contains no valid file paths")
|
||||
}
|
||||
|
||||
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
||||
sizeMap := make(map[string]int64, len(allPaths))
|
||||
for _, filePath := range allPaths {
|
||||
if _, err := validate.SafeInputPath(filePath); err != nil {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
||||
}
|
||||
sizeMap[filePath] = fileInfo.Size()
|
||||
}
|
||||
|
||||
// Step 3: 并行上传,构建路径 → 附件结果映射
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths))
|
||||
resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseFormAttachmentUploadTarget(baseToken, runtime.Str("share-token")), sizeMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: 根据共享结果映射,按字段组装单元格
|
||||
for fieldName, filePaths := range attachmentMap {
|
||||
cell := make([]interface{}, 0, len(filePaths))
|
||||
for _, p := range filePaths {
|
||||
if att, ok := resultMap[p]; ok {
|
||||
cell = append(cell, att)
|
||||
}
|
||||
}
|
||||
fields[fieldName] = cell
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap))
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", "tables", "forms", "submit"),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。
|
||||
func collectUniquePaths(attachmentMap map[string][]string) []string {
|
||||
seen := make(map[string]bool, len(attachmentMap)*4)
|
||||
var order []string
|
||||
for _, filePaths := range attachmentMap {
|
||||
for _, p := range filePaths {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
order = append(order, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func baseFormAttachmentUploadTarget(baseToken, shareToken string) baseAttachmentUploadTarget {
|
||||
return baseAttachmentUploadTarget{
|
||||
ParentType: baseFormAttachmentParentType,
|
||||
ParentNode: baseToken,
|
||||
Extra: baseFormAttachmentExtra(shareToken),
|
||||
}
|
||||
}
|
||||
|
||||
func baseFormAttachmentExtra(shareToken string) string {
|
||||
extra, err := json.Marshal(map[string]string{"share_token": shareToken})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(extra)
|
||||
}
|
||||
|
||||
// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。
|
||||
func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, target baseAttachmentUploadTarget, sizeMap map[string]int64) (map[string]interface{}, error) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
resultMap = make(map[string]interface{}, len(paths))
|
||||
)
|
||||
|
||||
g, _ := errgroup.WithContext(runtime.Ctx())
|
||||
g.SetLimit(uploadAttachConcurrency) // 限制并发数
|
||||
|
||||
for _, filePath := range paths {
|
||||
fp := filePath // 捕获循环变量
|
||||
g.Go(func() error {
|
||||
fileName := filepath.Base(fp)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName)
|
||||
|
||||
att, err := uploadSingleAttachment(runtime, fp, fileName, sizeMap[fp], target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
resultMap[fp] = att
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
// uploadSingleAttachment 上传单个文件,返回附件单元格项。
|
||||
// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。
|
||||
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
||||
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,27 +8,44 @@ 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"
|
||||
baseFormAttachmentParentType = "bitable_tmp_point"
|
||||
baseAttachmentMaxBatchSize = 50
|
||||
baseAttachmentGetMaxRecords = 10
|
||||
)
|
||||
|
||||
type baseAttachmentUploadTarget struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
Extra string
|
||||
}
|
||||
|
||||
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 +54,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 +154,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 +162,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 +179,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 +269,175 @@ 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, fileInfo.Size(), baseAttachmentUploadTarget{
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: runtime.Str("base-token"),
|
||||
})
|
||||
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,84 +449,53 @@ 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 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")
|
||||
}
|
||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
}
|
||||
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
|
||||
}
|
||||
attachments, _ := data["attachments"].(map[string]interface{})
|
||||
if attachments == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return attachments, 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
|
||||
}
|
||||
current, exists := fields[fieldName]
|
||||
if !exists || util.IsNil(current) {
|
||||
return []interface{}{uploaded}, nil
|
||||
}
|
||||
items, ok := current.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current)
|
||||
}
|
||||
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))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (map[string]interface{}, error) {
|
||||
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
parentNode := target.ParentNode
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentType: target.ParentType,
|
||||
ParentNode: &parentNode,
|
||||
Extra: target.Extra,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: parentNode,
|
||||
ParentType: target.ParentType,
|
||||
ParentNode: target.ParentNode,
|
||||
Extra: target.Extra,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
@@ -280,15 +503,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 +570,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,
|
||||
@@ -68,10 +70,12 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseFormsList,
|
||||
BaseFormUpdate,
|
||||
BaseFormGet,
|
||||
BaseFormDetail,
|
||||
BaseFormQuestionsCreate,
|
||||
BaseFormQuestionsDelete,
|
||||
BaseFormQuestionsUpdate,
|
||||
BaseFormQuestionsList,
|
||||
BaseFormSubmit,
|
||||
BaseDashboardList,
|
||||
BaseDashboardGet,
|
||||
BaseDashboardCreate,
|
||||
|
||||
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{
|
||||
|
||||
@@ -31,6 +31,7 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
@@ -38,6 +39,7 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -46,11 +48,15 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
@@ -76,6 +82,7 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
|
||||
@@ -51,6 +51,7 @@ type driveImportSpec struct {
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string // existing bitable token to import data into (only for type=bitable)
|
||||
}
|
||||
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
@@ -67,7 +68,7 @@ func (s driveImportSpec) TargetFileName() string {
|
||||
|
||||
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
|
||||
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
body := map[string]interface{}{
|
||||
"file_extension": s.FileExtension(),
|
||||
"file_token": fileToken,
|
||||
"type": s.DocType,
|
||||
@@ -79,6 +80,12 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
|
||||
"mount_key": s.FolderToken,
|
||||
},
|
||||
}
|
||||
|
||||
if s.DocType == "bitable" && s.TargetToken != "" {
|
||||
body["token"] = s.TargetToken
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// uploadMediaForImport uploads the source file to the temporary import media
|
||||
@@ -232,6 +239,15 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.TargetToken) != "" {
|
||||
if spec.DocType != "bitable" {
|
||||
return output.ErrValidation("--target-token is only supported when --type is bitable")
|
||||
}
|
||||
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,19 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
|
||||
wantErr: "unsupported file extension",
|
||||
},
|
||||
{
|
||||
name: "target-token rejected for non-bitable type",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "sheet", TargetToken: "bascnxxx"},
|
||||
wantErr: "--target-token is only supported when --type is bitable",
|
||||
},
|
||||
{
|
||||
name: "target-token accepted for bitable",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable", TargetToken: "bascnxxx"},
|
||||
},
|
||||
{
|
||||
name: "target-token empty for bitable still ok",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -84,6 +84,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -148,6 +149,7 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -197,6 +199,7 @@ func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -250,6 +253,7 @@ func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -296,6 +300,7 @@ func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -366,6 +371,165 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyWithTargetToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/data.xlsx",
|
||||
DocType: "bitable",
|
||||
TargetToken: "bascnxxxxx",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
|
||||
// point stays the same as default (mount_type=1)
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("mount_type = %v (%T), want 1", mt, mt)
|
||||
}
|
||||
|
||||
// token is injected at body top-level
|
||||
if tt, _ := body["token"].(string); tt != "bascnxxxxx" {
|
||||
t.Fatalf("token = %q, want %q", tt, "bascnxxxxx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyTargetTokenIgnoredForNonBitable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/data.xlsx",
|
||||
DocType: "sheet",
|
||||
TargetToken: "bascnxxxxx",
|
||||
FolderToken: "fld_test",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
|
||||
// Non-bitable should use default folder mount (type=1), ignoring TargetToken
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("mount_type = %v (%T), want 1 (folder mount)", mt, mt)
|
||||
}
|
||||
if _, exists := point["target_token"]; exists {
|
||||
t.Fatal("target_token should not be present for non-bitable type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunWithTargetToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "bitable"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-token", "bascntarget123"); err != nil {
|
||||
t.Fatalf("set --target-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.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"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(got.API))
|
||||
}
|
||||
|
||||
// The import task body (API[1]) should contain target_token in point
|
||||
importTaskBody := got.API[1].Body
|
||||
point, ok := importTaskBody["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", importTaskBody["point"])
|
||||
}
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("dry-run mount_type = %v (%T), want 1 (unchanged)", mt, mt)
|
||||
}
|
||||
if tt, _ := importTaskBody["token"].(string); tt != "bascntarget123" {
|
||||
t.Fatalf("dry-run token = %q, want %q", tt, "bascntarget123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunTargetTokenRejectedForSheet(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "sheet"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-token", "bascnxxx"); err != nil {
|
||||
t.Fatalf("set --target-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.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 {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "--target-token is only supported when --type is bitable") {
|
||||
t.Fatalf("dry-run error = %q, want target-token validation error", got.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// driveImportMockEnv mounts the three stubs needed for a full +import run:
|
||||
// media upload_all -> import_tasks (create) -> import_tasks/<ticket> (poll).
|
||||
// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.
|
||||
|
||||
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
|
||||
}
|
||||
@@ -77,8 +77,8 @@ var DriveSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I created (uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated creator open_ids; mutually exclusive with --mine"},
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
|
||||
{Name: "edited-until", Desc: "end of [my edited] time window"},
|
||||
@@ -108,7 +108,7 @@ var DriveSearch = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
|
||||
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
|
||||
"Use --mine for a quick \"docs I created\" filter. For other people, use --creator-ids ou_xxx,ou_yyy.",
|
||||
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
|
||||
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -24,10 +24,16 @@ import (
|
||||
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
|
||||
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
|
||||
|
||||
const (
|
||||
markdownUploadParentTypeExplorer = "explorer"
|
||||
markdownUploadParentTypeWiki = "wiki"
|
||||
)
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
FolderToken string
|
||||
WikiToken string
|
||||
FilePath string
|
||||
Content string
|
||||
ContentSet bool
|
||||
@@ -45,6 +51,25 @@ type markdownMultipartSession struct {
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
type markdownUploadTarget struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
}
|
||||
|
||||
func (spec markdownUploadSpec) Target() markdownUploadTarget {
|
||||
if spec.WikiToken != "" {
|
||||
return markdownUploadTarget{
|
||||
ParentType: markdownUploadParentTypeWiki,
|
||||
ParentNode: spec.WikiToken,
|
||||
}
|
||||
}
|
||||
// An empty explorer parent node uploads to the user's Drive root folder.
|
||||
return markdownUploadTarget{
|
||||
ParentType: markdownUploadParentTypeExplorer,
|
||||
ParentNode: spec.FolderToken,
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
@@ -53,14 +78,32 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
}
|
||||
|
||||
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
|
||||
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
|
||||
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
|
||||
}
|
||||
targets := 0
|
||||
if spec.FolderToken != "" {
|
||||
targets++
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
targets++
|
||||
}
|
||||
if targets > 1 {
|
||||
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
@@ -92,6 +135,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string) bool {
|
||||
return runtime.Changed(flagName) && strings.TrimSpace(runtime.Str(flagName)) == ""
|
||||
}
|
||||
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
@@ -137,11 +184,19 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
return nil, wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func wrapMarkdownDownloadError(err error) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
|
||||
func validateNonEmptyMarkdownSize(size int64) error {
|
||||
if size == 0 {
|
||||
return output.ErrValidation("%s", markdownEmptyContentError)
|
||||
@@ -170,6 +225,24 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
req.QueryParams = larkcore.QueryParams{
|
||||
"version": []string{strings.TrimSpace(version)},
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
@@ -179,12 +252,13 @@ func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
|
||||
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
target := spec.Target()
|
||||
|
||||
if !multipart {
|
||||
body := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
}
|
||||
@@ -205,8 +279,8 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
|
||||
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
@@ -241,6 +315,7 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
|
||||
|
||||
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
target := spec.Target()
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = finalMarkdownFileName(spec)
|
||||
}
|
||||
@@ -267,8 +342,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
|
||||
Desc("[2] Overwrite file contents with multipart/form-data upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
"file_token": spec.FileToken,
|
||||
@@ -280,8 +355,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
|
||||
Desc("[2] Initialize multipart overwrite upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
@@ -326,10 +401,11 @@ func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUpload
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "explorer")
|
||||
fd.AddField("parent_node", spec.FolderToken)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
@@ -357,10 +433,11 @@ func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSp
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
|
||||
@@ -20,15 +20,21 @@ var MarkdownCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder; mutually exclusive with --wiki-token)"},
|
||||
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
|
||||
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
|
||||
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -39,6 +45,7 @@ var MarkdownCreate = common.Shortcut{
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -54,6 +61,7 @@ var MarkdownCreate = common.Shortcut{
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -79,8 +87,10 @@ var MarkdownCreate = common.Shortcut{
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
if target := spec.Target(); target.ParentType == markdownUploadParentTypeExplorer {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
|
||||
540
shortcuts/markdown/markdown_diff.go
Normal file
540
shortcuts/markdown/markdown_diff.go
Normal file
@@ -0,0 +1,540 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
markdownDiffModeRemoteVsRemote = "remote_vs_remote"
|
||||
markdownDiffModeRemoteVsLocal = "remote_vs_local"
|
||||
markdownDiffMaxContentBytes = 10 * 1024 * 1024
|
||||
markdownDiffTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var markdownDiffVersionRe = regexp.MustCompile(`^\d{1,19}$`)
|
||||
|
||||
type markdownDiffSpec struct {
|
||||
FileToken string
|
||||
FromVersion string
|
||||
ToVersion string
|
||||
FilePath string
|
||||
ContextLines int
|
||||
Format string
|
||||
}
|
||||
|
||||
type markdownDiffHunk struct {
|
||||
Header string `json:"header"`
|
||||
OldStart int `json:"old_start"`
|
||||
OldLines int `json:"old_lines"`
|
||||
NewStart int `json:"new_start"`
|
||||
NewLines int `json:"new_lines"`
|
||||
}
|
||||
|
||||
type markdownDiffLineKind int
|
||||
|
||||
const (
|
||||
markdownDiffLineEqual markdownDiffLineKind = iota
|
||||
markdownDiffLineDelete
|
||||
markdownDiffLineInsert
|
||||
)
|
||||
|
||||
type markdownDiffLineOp struct {
|
||||
Kind markdownDiffLineKind
|
||||
Content string
|
||||
}
|
||||
|
||||
type markdownDiffHunkRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FromVersion != "" {
|
||||
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
if err := validateMarkdownDiffVersionValue(spec.ToVersion, "--to-version"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.FilePath != "" {
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ContextLines < 0 {
|
||||
return output.ErrValidation("--context-lines must be >= 0")
|
||||
}
|
||||
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
|
||||
return output.ErrValidation("markdown +diff only supports --format json or pretty")
|
||||
}
|
||||
if spec.FilePath == "" {
|
||||
if spec.FromVersion == "" && spec.ToVersion == "" {
|
||||
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
|
||||
}
|
||||
if spec.FromVersion == "" && spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version requires --from-version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version is not supported together with --file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownDiffVersionValue(value, flagName string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return output.ErrValidation("%s cannot be empty", flagName)
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return output.ErrValidation("%s must be a numeric version string", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownDiffMode(spec markdownDiffSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return markdownDiffModeRemoteVsLocal
|
||||
}
|
||||
return markdownDiffModeRemoteVsRemote
|
||||
}
|
||||
|
||||
func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().Desc("Download the requested Markdown content, compute a unified diff locally, and print the result without modifying the remote file")
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
if spec.FromVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the specified remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
}
|
||||
dry.Set("local_file", spec.FilePath)
|
||||
dry.Set("mode", markdownDiffModeRemoteVsLocal)
|
||||
default:
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the base remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
if spec.ToVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the target remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.ToVersion})
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
}
|
||||
dry.Set("mode", markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
dry.Set("context_lines", spec.ContextLines)
|
||||
return dry
|
||||
}
|
||||
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(resp.Body, "remote Markdown content")
|
||||
if err != nil {
|
||||
return "", "", wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return fileName, string(payload), nil
|
||||
}
|
||||
|
||||
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
|
||||
payload, err := io.ReadAll(io.LimitReader(r, markdownDiffMaxContentBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) > markdownDiffMaxContentBytes {
|
||||
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func splitMarkdownDiffLines(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func markdownDiffLineOps(fromContent, toContent string) []markdownDiffLineOp {
|
||||
dmp := diffmatchpatch.New()
|
||||
dmp.DiffTimeout = markdownDiffTimeout
|
||||
before, after, lineArray := dmp.DiffLinesToRunes(fromContent, toContent)
|
||||
diffs := dmp.DiffMainRunes(before, after, false)
|
||||
// Keep the diff line-based. Running cleanup after hydrating real text
|
||||
// would re-split replacements into word-level edits.
|
||||
diffs = dmp.DiffCharsToLines(diffs, lineArray)
|
||||
|
||||
ops := make([]markdownDiffLineOp, 0, len(diffs))
|
||||
for _, diff := range diffs {
|
||||
lines := splitMarkdownDiffLines(diff.Text)
|
||||
for _, line := range lines {
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffDelete:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineDelete, Content: line})
|
||||
case diffmatchpatch.DiffInsert:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineInsert, Content: line})
|
||||
default:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineEqual, Content: line})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func markdownDiffSummary(ops []markdownDiffLineOp) (bool, int, int) {
|
||||
added := 0
|
||||
deleted := 0
|
||||
changed := false
|
||||
for _, op := range ops {
|
||||
switch op.Kind {
|
||||
case markdownDiffLineDelete:
|
||||
changed = true
|
||||
deleted++
|
||||
case markdownDiffLineInsert:
|
||||
changed = true
|
||||
added++
|
||||
}
|
||||
}
|
||||
return changed, added, deleted
|
||||
}
|
||||
|
||||
func markdownDiffHunkRanges(ops []markdownDiffLineOp, contextLines int) []markdownDiffHunkRange {
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
changedLines := make([]int, 0)
|
||||
for i, op := range ops {
|
||||
if op.Kind != markdownDiffLineEqual {
|
||||
changedLines = append(changedLines, i)
|
||||
}
|
||||
}
|
||||
if len(changedLines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ranges := make([]markdownDiffHunkRange, 0, len(changedLines))
|
||||
current := markdownDiffHunkRange{
|
||||
Start: max(0, changedLines[0]-contextLines),
|
||||
End: min(len(ops), changedLines[0]+contextLines+1),
|
||||
}
|
||||
for _, idx := range changedLines[1:] {
|
||||
next := markdownDiffHunkRange{
|
||||
Start: max(0, idx-contextLines),
|
||||
End: min(len(ops), idx+contextLines+1),
|
||||
}
|
||||
if next.Start <= current.End {
|
||||
if next.End > current.End {
|
||||
current.End = next.End
|
||||
}
|
||||
continue
|
||||
}
|
||||
ranges = append(ranges, current)
|
||||
current = next
|
||||
}
|
||||
ranges = append(ranges, current)
|
||||
return ranges
|
||||
}
|
||||
|
||||
func markdownDiffHunkAt(ops []markdownDiffLineOp, r markdownDiffHunkRange) markdownDiffHunk {
|
||||
oldBefore := 0
|
||||
newBefore := 0
|
||||
for _, op := range ops[:r.Start] {
|
||||
if op.Kind != markdownDiffLineInsert {
|
||||
oldBefore++
|
||||
}
|
||||
if op.Kind != markdownDiffLineDelete {
|
||||
newBefore++
|
||||
}
|
||||
}
|
||||
|
||||
oldLines := 0
|
||||
newLines := 0
|
||||
for _, op := range ops[r.Start:r.End] {
|
||||
if op.Kind != markdownDiffLineInsert {
|
||||
oldLines++
|
||||
}
|
||||
if op.Kind != markdownDiffLineDelete {
|
||||
newLines++
|
||||
}
|
||||
}
|
||||
|
||||
oldStart := oldBefore + 1
|
||||
newStart := newBefore + 1
|
||||
if oldLines == 0 {
|
||||
oldStart = oldBefore
|
||||
}
|
||||
if newLines == 0 {
|
||||
newStart = newBefore
|
||||
}
|
||||
|
||||
return markdownDiffHunk{
|
||||
Header: fmt.Sprintf("@@ -%d,%d +%d,%d @@", oldStart, oldLines, newStart, newLines),
|
||||
OldStart: oldStart,
|
||||
OldLines: oldLines,
|
||||
NewStart: newStart,
|
||||
NewLines: newLines,
|
||||
}
|
||||
}
|
||||
|
||||
func buildMarkdownUnifiedDiff(fromLabel, toLabel string, ops []markdownDiffLineOp, ranges []markdownDiffHunkRange) string {
|
||||
if len(ranges) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "--- %s\n", fromLabel)
|
||||
fmt.Fprintf(&b, "+++ %s\n", toLabel)
|
||||
for _, r := range ranges {
|
||||
hunk := markdownDiffHunkAt(ops, r)
|
||||
b.WriteString(hunk.Header)
|
||||
b.WriteByte('\n')
|
||||
for _, op := range ops[r.Start:r.End] {
|
||||
prefix := ' '
|
||||
switch op.Kind {
|
||||
case markdownDiffLineDelete:
|
||||
prefix = '-'
|
||||
case markdownDiffLineInsert:
|
||||
prefix = '+'
|
||||
}
|
||||
b.WriteByte(byte(prefix))
|
||||
b.WriteString(op.Content)
|
||||
if !strings.HasSuffix(op.Content, "\n") {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(`\ No newline at end of file`)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent string, contextLines int) (string, bool, int, int, []markdownDiffHunk) {
|
||||
ops := markdownDiffLineOps(fromContent, toContent)
|
||||
changed, added, deleted := markdownDiffSummary(ops)
|
||||
ranges := markdownDiffHunkRanges(ops, contextLines)
|
||||
hunks := make([]markdownDiffHunk, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
hunks = append(hunks, markdownDiffHunkAt(ops, r))
|
||||
}
|
||||
return buildMarkdownUnifiedDiff(fromLabel, toLabel, ops, ranges), changed, added, deleted, hunks
|
||||
}
|
||||
|
||||
func colorizeUnifiedDiff(diffText string) string {
|
||||
if diffText == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.SplitAfter(diffText, "\n")
|
||||
var b strings.Builder
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimRight(line, "\n")
|
||||
suffix := ""
|
||||
if strings.HasSuffix(line, "\n") {
|
||||
suffix = "\n"
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "@@"):
|
||||
b.WriteString(output.Cyan)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "+++"), strings.HasPrefix(trimmed, "---"):
|
||||
b.WriteString(output.Bold)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"):
|
||||
b.WriteString(output.Green)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"):
|
||||
b.WriteString(output.Red)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
default:
|
||||
b.WriteString(trimmed)
|
||||
}
|
||||
b.WriteString(suffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownDiff(w io.Writer, data map[string]interface{}) {
|
||||
if !common.GetBool(data, "changed") {
|
||||
io.WriteString(w, "No differences.\n")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, colorizeUnifiedDiff(common.GetString(data, "diff")))
|
||||
}
|
||||
|
||||
var MarkdownDiff = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+diff",
|
||||
Description: "Compare remote Markdown versions or compare remote Markdown against a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "target Markdown file token", Required: true},
|
||||
{Name: "from-version", Desc: "base remote version; when --to-version is omitted, compare this version to the latest remote version"},
|
||||
{Name: "to-version", Desc: "target remote version; requires --from-version"},
|
||||
{Name: "file", Desc: "local .md file path to compare against the remote content"},
|
||||
{Name: "context-lines", Desc: "number of unchanged context lines to include around each diff hunk", Type: "int", Default: "3"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
Format: runtime.Format,
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return markdownDiffDryRun(markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
}
|
||||
|
||||
var (
|
||||
fromLabel string
|
||||
toLabel string
|
||||
fromContent string
|
||||
toContent string
|
||||
err error
|
||||
)
|
||||
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
fromLabel = "a/" + spec.FileToken
|
||||
if spec.FromVersion != "" {
|
||||
fromLabel += "@version:" + spec.FromVersion
|
||||
} else {
|
||||
fromLabel += "@latest"
|
||||
}
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toLabel = "b/" + spec.FilePath
|
||||
toContent, err = readMarkdownLocalFile(runtime, spec.FilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.ToVersion != "" {
|
||||
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
|
||||
} else {
|
||||
toLabel = "b/" + spec.FileToken + "@latest"
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
diffText, changed, addedLines, deletedLines, hunks := summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent, spec.ContextLines)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"changed": changed,
|
||||
"mode": markdownDiffMode(spec),
|
||||
"file_token": spec.FileToken,
|
||||
"from_version": spec.FromVersion,
|
||||
"to_version": spec.ToVersion,
|
||||
"from_label": fromLabel,
|
||||
"to_label": toLabel,
|
||||
"added_lines": addedLines,
|
||||
"deleted_lines": deletedLines,
|
||||
"context_lines": spec.ContextLines,
|
||||
"hunks": hunks,
|
||||
"diff": diffText,
|
||||
}
|
||||
if spec.FilePath != "" {
|
||||
out["local_file"] = spec.FilePath
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownDiff(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
379
shortcuts/markdown/markdown_diff_test.go
Normal file
379
shortcuts/markdown/markdown_diff_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestMarkdownDiffRejectsUnsupportedFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--format", "table",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supports --format json or pretty") {
|
||||
t.Fatalf("expected format validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--to-version", "7633658129540910628",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--to-version requires --from-version") {
|
||||
t.Fatalf("expected version validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", "7633658129540910628",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Changed bool `json:"changed"`
|
||||
Mode string `json:"mode"`
|
||||
FromVersion string `json:"from_version"`
|
||||
ToVersion string `json:"to_version"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Diff string `json:"diff"`
|
||||
Hunks []markdownDiffHunk `json:"hunks"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got false: %s", stdout.String())
|
||||
}
|
||||
if !env.Data.Changed {
|
||||
t.Fatalf("expected changed=true: %s", stdout.String())
|
||||
}
|
||||
if env.Data.Mode != markdownDiffModeRemoteVsRemote {
|
||||
t.Fatalf("mode = %q, want %q", env.Data.Mode, markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
if env.Data.FromVersion != "7633658129540910621" || env.Data.ToVersion != "7633658129540910628" {
|
||||
t.Fatalf("versions = %q -> %q", env.Data.FromVersion, env.Data.ToVersion)
|
||||
}
|
||||
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 1 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 2/1", env.Data.AddedLines, env.Data.DeletedLines)
|
||||
}
|
||||
if len(env.Data.Hunks) != 1 {
|
||||
t.Fatalf("len(hunks) = %d, want 1", len(env.Data.Hunks))
|
||||
}
|
||||
if !strings.Contains(env.Data.Diff, "@@") || !strings.Contains(env.Data.Diff, "+- gamma") {
|
||||
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\nhello old\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", []byte("# Title\n\nhello new\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "@@") {
|
||||
t.Fatalf("pretty output missing hunk header: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), output.Red+"-hello old"+output.Reset) {
|
||||
t.Fatalf("pretty output missing removed line color: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), output.Green+"+hello new"+output.Reset) {
|
||||
t.Fatalf("pretty output missing added line color: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", []byte("# Title\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "remote Markdown content exceeds 10.0 MB markdown +diff content limit") {
|
||||
t.Fatalf("expected remote content size error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "local Markdown file exceeds 10.0 MB markdown +diff content limit") {
|
||||
t.Fatalf("expected local content size error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
|
||||
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
|
||||
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
|
||||
}
|
||||
|
||||
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
|
||||
t.Fatalf("wrapped error = %q", got.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffIncludesNoNewlineMarker(t *testing.T) {
|
||||
diffText, changed, added, deleted, hunks := summarizeMarkdownDiff(
|
||||
"a/test.md",
|
||||
"b/test.md",
|
||||
"# Title\n\nhello old",
|
||||
"# Title\n\nhello new",
|
||||
3,
|
||||
)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if added != 1 || deleted != 1 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 1/1", added, deleted)
|
||||
}
|
||||
if len(hunks) != 1 {
|
||||
t.Fatalf("len(hunks) = %d, want 1", len(hunks))
|
||||
}
|
||||
if strings.Count(diffText, "\\ No newline at end of file") != 2 {
|
||||
t.Fatalf("diff should contain two no-newline markers: %q", diffText)
|
||||
}
|
||||
if !strings.Contains(diffText, "-hello old\n\\ No newline at end of file\n+hello new\n\\ No newline at end of file\n") {
|
||||
t.Fatalf("diff missing expected no-newline marker sequence: %q", diffText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", "7633658129540910628",
|
||||
"--context-lines", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks []markdownDiffHunk `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK || !env.Data.Changed {
|
||||
t.Fatalf("expected changed=true: %s", stdout.String())
|
||||
}
|
||||
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 2 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 2/2", env.Data.AddedLines, env.Data.DeletedLines)
|
||||
}
|
||||
if len(env.Data.Hunks) != 2 {
|
||||
t.Fatalf("len(hunks) = %d, want 2", len(env.Data.Hunks))
|
||||
}
|
||||
if !strings.Contains(env.Data.Diff, "-line2") || !strings.Contains(env.Data.Diff, "+line5 changed") {
|
||||
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffNoChangesPretty(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "No differences." {
|
||||
t.Fatalf("pretty no-change output = %q, want %q", got, "No differences.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
localPath := filepath.Join(".", "local.md")
|
||||
if err := os.WriteFile(localPath, []byte("# local\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", localPath,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
|
||||
t.Fatalf("dry-run missing download call: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
|
||||
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
|
||||
want := []string{"+create", "+diff", "+fetch", "+patch", "+overwrite"}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
|
||||
@@ -269,6 +269,27 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
|
||||
},
|
||||
want: "--folder-token cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "wiki token cannot be empty",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token=",
|
||||
},
|
||||
want: "--wiki-token cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "folder and wiki tokens are mutually exclusive",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "fld_target",
|
||||
"--wiki-token", "wikcn_target",
|
||||
},
|
||||
want: "--folder-token and --wiki-token are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "folder token must be valid",
|
||||
args: []string{
|
||||
@@ -279,6 +300,16 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
|
||||
},
|
||||
want: "--folder-token",
|
||||
},
|
||||
{
|
||||
name: "wiki token must be valid",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "../bad",
|
||||
},
|
||||
want: "--wiki-token",
|
||||
},
|
||||
{
|
||||
name: "content mode still validates markdown file name",
|
||||
args: []string{
|
||||
@@ -377,6 +408,29 @@ func TestMarkdownCreateDryRunWithInlineContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "wikcn_markdown_dryrun_target",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"parent_type": "wiki"`) {
|
||||
t.Fatalf("dry-run missing wiki parent_type: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"parent_node": "wikcn_markdown_dryrun_target"`) {
|
||||
t.Fatalf("dry-run missing wiki parent_node: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
@@ -472,6 +526,43 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateSuccessUploadAllToWikiOmitsURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_create_wiki",
|
||||
"version": "1002",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
"--wiki-token", "wikcn_markdown_create_target",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["parent_type"]; got != markdownUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %q, want %q", got, markdownUploadParentTypeWiki)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "wikcn_markdown_create_target" {
|
||||
t.Fatalf("parent_node = %q, want %q", got, "wikcn_markdown_create_target")
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"url":`) {
|
||||
t.Fatalf("stdout should omit url for wiki-hosted markdown files: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -588,6 +679,81 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_markdown_wiki_ok",
|
||||
"block_size": float64(markdownSinglePartSizeLimit),
|
||||
"block_num": float64(2),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
uploadPartStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadPartStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_multipart_wiki",
|
||||
"version": "1005",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
fh, err := os.Create("large.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(markdownSinglePartSizeLimit + 1); err != nil {
|
||||
fh.Close()
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--file", "large.md",
|
||||
"--wiki-token", "wikcn_markdown_multipart_target",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(prepareStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode upload_prepare body: %v\nraw=%s", err, string(prepareStub.CapturedBody))
|
||||
}
|
||||
if got := body["parent_type"]; got != markdownUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %#v, want %q", got, markdownUploadParentTypeWiki)
|
||||
}
|
||||
if got := body["parent_node"]; got != "wikcn_markdown_multipart_target" {
|
||||
t.Fatalf("parent_node = %#v, want %q", got, "wikcn_markdown_multipart_target")
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"url":`) {
|
||||
t.Fatalf("stdout should omit url for wiki-hosted multipart markdown files: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateFailsWhenMultipartPlanIsTooSmall(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownDiff,
|
||||
MarkdownFetch,
|
||||
MarkdownPatch,
|
||||
MarkdownOverwrite,
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"markdown", "+create"},
|
||||
{"markdown", "+diff"},
|
||||
{"markdown", "+fetch"},
|
||||
{"markdown", "+overwrite"},
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -109,10 +109,5 @@ Drive Folder (云空间文件夹)
|
||||
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
|
||||
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
|
||||
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
|
||||
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
|
||||
- `docs +search` 不是只搜文档 / Wiki;结果里会直接返回 `SHEET` 等云空间对象。
|
||||
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
|
||||
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”,**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
|
||||
|
||||
## 补充说明
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
|
||||
@@ -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"}'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
|
||||
## 快速决策
|
||||
- 按标题或关键词找云空间里的表格文件,先用 `lark-cli docs +search`。
|
||||
- `docs +search` 会直接返回 `SHEET` 结果,不要把它误解成只能搜文档 / Wiki。
|
||||
- 已知 spreadsheet URL / token 后,再进入 `sheets +info`、`sheets +read`、`sheets +find` 等对象内部操作。
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
|
||||
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时,必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
|
||||
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;如需先解析 Wiki 链接,可先调用 `lark-cli wiki ...`。
|
||||
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;解析 Wiki 链接使用 `lark-cli wiki +node-get`。
|
||||
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
|
||||
## 1. 何时使用本 Skill
|
||||
@@ -39,11 +39,12 @@ metadata:
|
||||
### 1.2 前置约束
|
||||
|
||||
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令;如果输入是 Wiki 链接,可先调用 `lark-cli wiki spaces get_node` 解析真实 token。
|
||||
3. 定位到命令后,先读该命令对应的 reference,再执行命令。
|
||||
4. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
6. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` 搜索 `BITABLE` 资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md):标题精确匹配、限定创建者/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
|
||||
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。
|
||||
3. 如果输入是 Wiki 链接或 Wiki token,并且用户想读取/操作其中的 Base,先执行 `lark-cli wiki +node-get --token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。
|
||||
4. 定位到命令后,先读该命令对应的 reference,再执行命令。
|
||||
5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
7. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli drive +search --query <keyword> --doc-types bitable` 搜索 Base / 多维表格资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md):标题精确匹配、限定 owner(`--mine` / `--creator-ids`,owner 语义非"最初创建人")/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
|
||||
|
||||
## 2. 模块与命令导航
|
||||
|
||||
@@ -69,7 +70,7 @@ metadata:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
|
||||
| `lark-cli drive +search --query <keyword> --doc-types bitable` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
|
||||
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;`--folder-token`、`--time-zone` 都是可选项 |
|
||||
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
|
||||
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;复制成功后应主动返回新 Base 标识信息 |
|
||||
@@ -107,8 +108,9 @@ metadata:
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
| `+record-upload-attachment` | 给已有记录上传一个或多个附件 | 看 `lark-cli base +record-upload-attachment --help` | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值;不支持 `--name` |
|
||||
| `+record-download-attachment` | 下载一个或多个 Base 附件到本地 | 看 `lark-cli base +record-download-attachment --help` | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| `+record-remove-attachment` | 删除附件字段里的一个或多个附件 | 看 `lark-cli base +record-remove-attachment --help` | 删除操作;确认目标后带 `--yes` |
|
||||
| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
|
||||
@@ -187,6 +189,8 @@ metadata:
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+form-list / +form-get` | 列出表单,或获取单个表单 | [`lark-base-form-list.md`](references/lark-base-form-list.md)、[`lark-base-form-get.md`](references/lark-base-form-get.md) | `+form-list` 可用来获取 `form-id`;`+form-get` 适合查看已有表单配置 |
|
||||
| `+form-detail` | 通过表单分享链接获取表单详情(含题目列表、字段类型、校验规则) | [`lark-base-form-detail.md`](references/lark-base-form-detail.md) | 只读;仅需 `--share-token`(从分享链接提取),不需要 base-token/table-id/form-id;返回的 `questions` 可直接用于 `+form-submit` 构造参数 |
|
||||
| `+form-submit` | 通过表单分享链接填写并提交表单(支持普通字段 + 附件上传) | [`lark-base-form-submit.md`](references/lark-base-form-submit.md) | 写入操作;仅支持 share_token 模式;**当 `--json` 包含 attachments 时必须额外提供 `--base-token`**(附件上传到 Base Drive Media 需要);附件通过 `--json.attachments` 传入本地路径,CLI 自动并行上传 |
|
||||
| `+form-create / +form-update / +form-delete` | 创建、更新或删除表单 | [`lark-base-form-create.md`](references/lark-base-form-create.md)、[`lark-base-form-update.md`](references/lark-base-form-update.md)、[`lark-base-form-delete.md`](references/lark-base-form-delete.md) | 创建后可继续进入表单问题相关操作;更新或删除前先确认目标表单 |
|
||||
| `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 |
|
||||
| `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`lark-base-form-questions-create.md`](references/lark-base-form-questions-create.md)、[`lark-base-form-questions-update.md`](references/lark-base-form-questions-update.md)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 |
|
||||
@@ -211,7 +215,7 @@ metadata:
|
||||
| 字段类型 | 含义 | 能否直接作为 `+record-upsert / +record-batch-create / +record-batch-update` 写入目标 | 说明 |
|
||||
|----------|------|-----------------------------------------------------------|------|
|
||||
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `lark-cli docs +media-download` |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
|
||||
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
|
||||
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
|
||||
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
|
||||
@@ -225,7 +229,7 @@ metadata:
|
||||
| 用户明确要求 lookup,或天然是固定查找配置 | `lookup` 字段 | 不要默认先上 lookup;先判断 formula 是否更合适 |
|
||||
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
|
||||
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| 下载记录里的附件文件 | `lark-cli docs +media-download --token <file_token> --output <path>` | `file_token` 从 `+record-get` 返回的附件字段里取;用法见 [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) |
|
||||
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
@@ -253,11 +257,17 @@ metadata:
|
||||
| 输入类型 | 正确处理方式 | 说明 |
|
||||
|---------|--------------|------|
|
||||
| 直接 Base 链接 `/base/{token}` | 直接提取 token 作为 `--base-token` | 不要把完整 URL 直接作为 `--base-token` |
|
||||
| Wiki 链接 `/wiki/{token}` | 先 `wiki.spaces.get_node`,再取 `node.obj_token` | 不要把 `wiki_token` 直接当 `--base-token` |
|
||||
| Wiki 链接 `/wiki/{token}` | 先用下方 fast path 解析 `data.obj_token` | 不要把 `wiki_token` 直接当 `--base-token`;如果这一步失败,再看 [`lark-wiki-node-get.md`](../lark-wiki/references/lark-wiki-node-get.md) |
|
||||
| URL 中的 `?table={id}` | 先按前缀判断对象类型 | `tbl` 开头表示数据表 `table-id`,可作为 `--table-id`;`blk` 开头表示仪表盘 `dashboard-ID`;`wkf` 开头表示 `workflow-ID`;`ldx` 开头表示内嵌文档,不要一律当成 `--table-id` |
|
||||
| URL 中的 `?view={id}` | 提取为 `--view-id` | 适合直接定位视图 |
|
||||
|
||||
| `lark-cli wiki spaces get_node` 返回的 `obj_type` | 后续路线 | 说明 |
|
||||
Wiki Base fast path:
|
||||
|
||||
```bash
|
||||
BASE_TOKEN="$(lark-cli wiki +node-get --as user --token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
|
||||
```
|
||||
|
||||
| `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 |
|
||||
|-----------------------------------------------|----------|------|
|
||||
| `bitable` | 优先走 `lark-cli base +...` | 如果 shortcut 不覆盖,再用 `lark-cli base <resource> <method>`;不要改走 `lark-cli api /open-apis/bitable/v1/...` |
|
||||
| `docx` | 转到文档 / Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
@@ -340,7 +350,7 @@ lark-cli auth login --domain base
|
||||
| `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` |
|
||||
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
|
||||
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
|
||||
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki spaces get_node` 取真实 `obj_token`;当 `obj_type=bitable` 时,用 `node.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
|
||||
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
|
||||
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
|
||||
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
|
||||
| `ignored_fields` / `READONLY` | 只读字段被当成可写字段,常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 |
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
|
||||
### 2.9 attachment(不作为普通 CellValue 写入)
|
||||
|
||||
用户要把本地文件加到记录里时,必须使用 `lark-cli base +record-upload-attachment --file <path>` 上传到已有记录。不能用普通记录操作接口来上传附件。
|
||||
|
||||
`+record-get` 返回的附件字段单元格包含 `file_token` 和文件名,可以把 `file_token` 交给 `lark-cli docs +media-download` 进行附件下载。
|
||||
- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id <record_id> --field-id <field_id> --file <path>`;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口写附件值。
|
||||
- 删除附件:使用 `lark-cli base +record-remove-attachment --record-id <record_id> --field-id <field_id> --file-token <file_token> --yes`;可重复 `--file-token` 一次删除同一单元格里的多个附件。
|
||||
- 下载附件:使用 `lark-cli base +record-download-attachment --record-id <record_id> --file-token <file_token> --output <dir>`;不传 `--file-token` 时下载整行所有附件,也可重复 `--file-token` 只下载指定附件。Base 附件必须用这个命令下载,用其他下载入口可能失败。
|
||||
|
||||
## 3. 只读字段(不要写)
|
||||
|
||||
|
||||
319
skills/lark-base/references/lark-base-form-detail.md
Normal file
319
skills/lark-base/references/lark-base-form-detail.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# base +form-detail
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过表单分享 Token 获取表单详情(含表单元信息、题目详情)。只读操作,不修改任何数据。
|
||||
|
||||
与 `+form-get` 的区别:`+form-get` 需要 `base-token` + `table-id` + `form-id`(从 Base 内部获取);`+form-detail` 仅需 `share-token`(从分享链接获取,无需知道 Base/表信息)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 share_token 获取表单详情
|
||||
lark-cli base +form-detail \
|
||||
--share-token <share_token>
|
||||
|
||||
# 以 pretty 格式展示(适合阅读 questions 结构)
|
||||
lark-cli base +form-detail \
|
||||
--share-token <share_token> \
|
||||
--format pretty
|
||||
|
||||
# 使用 jq 过滤只看题目列表
|
||||
lark-cli base +form-detail \
|
||||
--share-token <share_token> \
|
||||
--jq '.data.questions'
|
||||
|
||||
# 预览 API 调用(不执行)
|
||||
lark-cli base +form-detail \
|
||||
--share-token <share_token> \
|
||||
--dry-run
|
||||
|
||||
# 使用应用身份(bot)
|
||||
lark-cli base +form-detail \
|
||||
--share-token <share_token> \
|
||||
--as bot
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--share-token <token>` | 是 | 表单分享 Token(从表单分享链接中提取) |
|
||||
| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv |
|
||||
| `--as` | 否 | 身份:user(默认)\| bot |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
| `--jq <expr>` | 否 | 用 jq 表达式过滤 JSON 输出 |
|
||||
|
||||
### 从分享链接提取 share-token
|
||||
|
||||
用户提供形如以下格式的表单分享链接时:
|
||||
|
||||
```text
|
||||
https://bitable-test.feishu-boe.cn/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
|
||||
```
|
||||
|
||||
**提取方式:** 取 URL 路径最后一段作为 `--share-token`。
|
||||
|
||||
以上述链接为例:
|
||||
|
||||
- `share-token` = `shrbcvST8eZy0vk8zjVZ1CAXNye`
|
||||
|
||||
```bash
|
||||
lark-cli base +form-detail \
|
||||
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `base_token` | string | 所属多维表格 Base token |
|
||||
| `name` | string | 表单名称 |
|
||||
| `description` | string | 表单描述 |
|
||||
| `questions[]` | array | 题目列表(含 id / title / type / required / description / filter) |
|
||||
|
||||
### questions 中每个题目的字段
|
||||
|
||||
#### 固定字段(所有题目共有)
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `id` | string | 是 | 题目标识(对应 field_id) |
|
||||
| `title` | string | 是 | 题目标题 |
|
||||
| `type` | string | 是 | 字段类型(见下方类型对照表,与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 对齐) |
|
||||
| `required` | bool | 是 | 是否必填 |
|
||||
| `description` | string | 否 | 题目描述 |
|
||||
| `filter` | object | 否 | 题目显示条件(详见下方 filter 结构说明) |
|
||||
|
||||
#### 动态字段(按 type 不同而不同,直接平铺在 question 中)
|
||||
|
||||
除上述固定字段外,每种 `type` 还会携带该类型特有的配置字段(与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的「常见补充字段」对应),例如:
|
||||
|
||||
- **text** → `style`(含 `style.type`: plain / phone / url / email / barcode)
|
||||
- **number** → `style`(含 `style.type`: plain / currency / progress / rating 及其子配置)
|
||||
- **select** → `multiple`(bool)、`options`(选项列表)或 `dynamic_options_source`
|
||||
- **datetime / created_at / updated_at** → `style.format`
|
||||
- **user / group_chat** → `multiple`
|
||||
- **link** → `link_table`、`bidirectional`、`bidirectional_link_field_name`
|
||||
- **formula** → `expression`
|
||||
- **lookup** → `from`、`select`、`where`、`aggregate`
|
||||
- **auto_number** → `style.rules`
|
||||
- **attachment / location / checkbox / stage / created_by / updated_by** → 无额外动态字段
|
||||
|
||||
### filter 结构说明
|
||||
|
||||
`filter` 控制题目在表单中的显示/隐藏逻辑,由 `conjunction`(逻辑关系)和 `conditions`(条件列表)组成。
|
||||
|
||||
以下以一个「活动报名」表单为例,其中「紧急联系人」题目的 filter 配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"conjunction": "and",
|
||||
"conditions": [
|
||||
{"field_name": "是否携带家属", "operator": "is", "value": ["是"]},
|
||||
{"field_name": "参与人数", "operator": "isGreater", "value": [1]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 以上述 JSON 为例:当题目「是否携带家属」的值为「是」**并且**题目「参与人数」大于 1 时,「紧急联系人」才会展示(`conjunction: "and"` 表示全部条件需同时满足;若为 `"or"` 则任一条件满足即显示)。
|
||||
|
||||
另一个常见场景——用 `or` 控制可选填的补充信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"conjunction": "or",
|
||||
"conditions": [
|
||||
{"field_name": "满意度评分", "operator": "isLessEqual", "value": [3]},
|
||||
{"field_name": "是否愿意回访", "operator": "is", "value": ["是"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 即:评分 ≤ 3 **或** 愿意接受回访时,才展示「改进建议」文本框。
|
||||
|
||||
#### filter 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `conjunction` | string | 条件间逻辑关系:`and`(全部满足) / `or`(任一满足) |
|
||||
| `conditions[]` | array | 条件列表 |
|
||||
|
||||
#### conditions 中每个条件项的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `field_name` | string | 所依赖的题目标题(引用其他题目的 title) |
|
||||
| `operator` | string | 过滤操作符(见下方 operator 可选值) |
|
||||
| `value` | array | 过滤值数组(部分 operator 不需要,如 `isEmpty` / `isNotEmpty`) |
|
||||
|
||||
#### operator 可选值
|
||||
|
||||
| operator | 含义 | 适用类型 |
|
||||
|----------|------|----------|
|
||||
| `is` | 等于 | 除附件外全部 |
|
||||
| `isNot` | 不等于 | 除附件外全部 |
|
||||
| `contains` | 包含 | 文本、选项、人员、群聊、地理位置 |
|
||||
| `doesNotContain` | 不包含 | 文本、选项、人员、群聊、地理位置 |
|
||||
| `isEmpty` | 为空 | 全部 |
|
||||
| `isNotEmpty` | 不为空 | 全部 |
|
||||
| `isGreater` | 大于 | 数字、日期时间 |
|
||||
| `isGreaterEqual` | 大于等于 | 数字、日期时间 |
|
||||
| `isLess` | 小于 | 数字、日期时间 |
|
||||
| `isLessEqual` | 小于等于 | 数字、日期时间 |
|
||||
|
||||
> **附件(attachment)特殊说明:** 仅支持 `isEmpty` 和 `isNotEmpty`,不支持 `is` / `isNot` / `contains` 及比较操作符。
|
||||
|
||||
#### value 的格式(按所依赖题目的类型区分)
|
||||
|
||||
| 所依赖题目类型 | value 格式 | 示例 |
|
||||
|----------------|-----------|------|
|
||||
| 文本类(text / phone / email / url 等) | 字符串数组 | `["1", "2"]` |
|
||||
| 数字类(number) | 数字数组 | `[1, 2]` |
|
||||
| 选项类(select / multi_select) | 选项名称数组 | `["选项A", "选项B"]` |
|
||||
| 人员类(user) | open_id 数组 | `["ou_d57864434a537020cf7a4a681d393e2d"]` |
|
||||
| 群聊类(group_chat) | open_id 数组 | `["oc_f62478de5cc958583191e778db972603"]` |
|
||||
| 地理位置(location) | 地点名称数组 | `["北京总部"]` |
|
||||
| 日期时间类(datetime) | 时间字符串数组,固定格式 `yyyy-MM-dd HH:mm:ss` | `["2026-05-07 14:30:00"]` |
|
||||
| 关联(link / duplexlink) | 记录 ID 数组 | `["recxxxxxxx", "recyyyyyyy"]` |
|
||||
|
||||
### type 可选值
|
||||
|
||||
与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的字段类型完全对齐。
|
||||
|
||||
| type 值 | 含义 | 常见动态字段 |
|
||||
|----------|------|-------------|
|
||||
| `text` | 文本(含电话/邮箱/链接/条码等子类型) | `style` |
|
||||
| `number` | 数字(含货币/进度/评分等子类型) | `style` |
|
||||
| `select` | 选项(单选/多选由 `multiple` 区分) | `multiple`、`options` / `dynamic_options_source` |
|
||||
| `datetime` | 日期时间 | `style.format` |
|
||||
| `user` | 人员 | `multiple` |
|
||||
| `group_chat` | 群组 | `multiple` |
|
||||
| `attachment` | 附件 | 无 |
|
||||
| `location` | 地理位置 | 无 |
|
||||
| `checkbox` | 复选框 | 无 |
|
||||
| `link` | 关联 | `link_table`、`bidirectional`、`bidirectional_link_field_name` |
|
||||
| `formula` | 公式 | `expression` |
|
||||
| `lookup` | 引用 | `from`、`select`、`where`、`aggregate` |
|
||||
| `auto_number` | 自动编号 | `style.rules` |
|
||||
| `created_at` | 创建时间 | `style.format` |
|
||||
| `updated_at` | 更新时间 | `style.format` |
|
||||
| `created_by` | 创建人 | 无 |
|
||||
| `updated_by` | 更新人 | 无 |
|
||||
| `stage` | 阶段 | 无 |
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"base_token": "DBALKJKLHDLJ",
|
||||
"name": "2026 年度技术大会报名",
|
||||
"description": "请填写参会信息,带 * 为必填项",
|
||||
"questions": [
|
||||
{
|
||||
"id": "fldzaYFpb6",
|
||||
"required": true,
|
||||
"title": "姓名",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"id": "fldCoBpOlx",
|
||||
"required": true,
|
||||
"title": "手机号",
|
||||
"type": "text",
|
||||
"style": { "type": "phone" }
|
||||
},
|
||||
{
|
||||
"id": "fldmmhZFCs",
|
||||
"required": false,
|
||||
"title": "公司邮箱",
|
||||
"type": "text",
|
||||
"style": { "type": "email" }
|
||||
},
|
||||
{
|
||||
"id": "fldhqmqCj8",
|
||||
"required": true,
|
||||
"title": "参会日期",
|
||||
"type": "datetime",
|
||||
"style": { "format": "yyyy-MM-dd" }
|
||||
},
|
||||
{
|
||||
"id": "fldlyRrfrN",
|
||||
"required": true,
|
||||
"title": "参与人数",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"id": "fldRakYky3",
|
||||
"required": false,
|
||||
"title": "是否携带家属",
|
||||
"type": "select",
|
||||
"multiple": false,
|
||||
"options": [
|
||||
{ "name": "是", "hue": "Green", "lightness": "Lighter" },
|
||||
{ "name": "否", "hue": "Gray", "lightness": "Lighter" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fldyrOO0X4",
|
||||
"required": false,
|
||||
"title": "紧急联系人",
|
||||
"type": "text",
|
||||
"filter": {
|
||||
"conjunction": "and",
|
||||
"conditions": [
|
||||
{"field_name": "是否携带家属", "operator": "is", "value": ["是"]},
|
||||
{"field_name": "参与人数", "operator": "isGreater", "value": [1]}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fldM9AsRc2",
|
||||
"required": false,
|
||||
"title": "上传简历",
|
||||
"type": "attachment",
|
||||
"filter": {
|
||||
"conjunction": "or",
|
||||
"conditions": [
|
||||
{"field_name": "是否携带家属", "operator": "isNotEmpty"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fldN7PsWx1",
|
||||
"required": true,
|
||||
"title": "所属部门",
|
||||
"type": "user",
|
||||
"multiple": false
|
||||
},
|
||||
{
|
||||
"id": "fldKq3mTz8",
|
||||
"required": true,
|
||||
"title": "参会主题",
|
||||
"type": "select",
|
||||
"multiple": true,
|
||||
"options": [
|
||||
{ "name": "AI 与大模型", "hue": "Purple", "lightness": "Lighter" },
|
||||
{ "name": "云原生", "hue": "Blue", "lightness": "Lighter" },
|
||||
{ "name": "工程效能", "hue": "Orange", "lightness": "Lighter" },
|
||||
{ "name": "前端技术", "hue": "Carmine", "lightness": "Lighter" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 提示
|
||||
|
||||
- `share_token` 从表单分享链接中提取,格式通常为 `shr` + 随机字符串(如 `shrbcvST8eZy0vk8zjVZ1CAXNye`)
|
||||
- 返回的 `questions` 列表可直接用于构造 `+form-submit` 的 `--json.fields` 参数
|
||||
- `questions[].title` 对应题目标题,可用于 `+form-submit` 的字段名映射
|
||||
- 如果需要通过 Base 内部路径操作表单,使用 `+form-get`(需要 base-token / table-id / form-id)
|
||||
- 权限要求:`base:form:read`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
- [lark-base-form-submit](lark-base-form-submit.md) — 获取详情后可用 submit 填写提交
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user