Compare commits

..

8 Commits

Author SHA1 Message Date
wangweiming-01
c02a38f077 feat: support wiki node target in markdown +create (#883)
Change-Id: Idb89464344599571cda3d27d136727553dcf0e7e
2026-05-20 17:03:32 +08:00
zhangheng023
3a3fc31d0b feat: add incremental skills sync (#965)
* feat: add incremental skills sync

* fix: address skills sync review feedback
2026-05-20 16:27:07 +08:00
wangweiming-01
8c73f49e91 docs: add media-preview reference (#990)
Change-Id: I5ba1991874e262fb98f3421e61503b58bb71d861
2026-05-20 15:59:39 +08:00
liujinkun2025
9272b9da99 docs(skills): migrate docs +search to drive +search and fix creator_ids owner semantic (#951)
docs +search is in maintenance and will be removed; cloud-space resource
discovery is consolidated onto drive +search. Two related doc/help fixes:

1. Redirect guidance: docs +search -> drive +search
   - skill-template/domains/{doc,sheets}.md
   - lark-base/SKILL.md: --filter '{"doc_types":["BITABLE"]}' -> --doc-types bitable
   - lark-sheets/SKILL.md: body + frontmatter description, add drive-search ref link
   Same server API, equivalent capability; only flattens the entry from
   nested --filter JSON to flags. reference links repointed to lark-drive.

2. Fix creator_ids/--mine semantic: creator -> owner
   The server matches creator_ids (incl. --mine / --creator-ids) by owner
   (document owner), not original creator, despite the OpenAPI field name.
   - shortcuts/drive/drive_search.go: --help Desc and Tip
   - lark-drive/references/lark-drive-search.md: identity section, params, rules, examples
   - lark-drive/SKILL.md: top-level guidance
   - lark-doc/references/lark-doc-search.md: creator_ids usage note (now self-consistent)
   Wire field name creator_ids kept (aligned with the server).

Docs/help strings only, no logic change; gofmt / go vet / package build pass.

Change-Id: If3ebf5a247b7e38b58050c677dc888a310f1c6b6
2026-05-20 15:08:50 +08:00
wangweiming-01
27a5eeddcc docs: prefer local comments for drive reviews (#981)
* docs: prefer local comments for drive reviews

Change-Id: Ie2eaa54320cd2612b66b2d617750d23b950e38db

* docs: align drive comment fallback guidance

Change-Id: Ia7512babe3656b57374c86068198c8192871ff81
2026-05-20 14:32:18 +08:00
zgz2048
0c4eadd41e docs: add wiki base fast path (#982) 2026-05-20 14:31:45 +08:00
yballul-bytedance
69c34481f5 feat: Product CLI 4no-meego (#759)
Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d
2026-05-20 14:02:03 +08:00
wangweiming-01
fa45e1c7e4 feat: add markdown +diff shortcut (#876)
* feat: add markdown +diff shortcut

Change-Id: I7da27889517707ac6f1d5e8c429e4bdfb49fdcf8

* fix: harden markdown diff downloads

Change-Id: I0020e14ebee780617d790836af1368db851b8cf1

* refactor: address markdown diff review feedback

Change-Id: I0ddb852218ec4784c0f9491896796c3007f04122
2026-05-20 12:20:51 +08:00
90 changed files with 5052 additions and 5034 deletions

2
.gitignore vendored
View File

@@ -42,3 +42,5 @@ app.log
/server-demo
.tmp/
cover*.out
lark-env.sh

View File

@@ -15,7 +15,6 @@ import (
cmdevent "github.com/larksuite/cli/cmd/event"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/sec"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
@@ -134,7 +133,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(sec.NewCmdSec(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

View File

@@ -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)
}

View File

@@ -1,251 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// NewCmdSecConfig is the parent for `lark-cli sec config <verb>`. Currently
// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here.
func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage lark-sec-cli daemon configuration",
}
cmd.AddCommand(NewCmdSecConfigInit(f, nil))
return cmd
}
// ConfigInitOptions holds inputs for `lark-cli sec config init`.
type ConfigInitOptions struct {
Factory *cmdutil.Factory
AppID string
AppSecret string
Brand string
Yes bool // skip the interactive form when all required values are provided
}
// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and
// registers them with the running lark-sec-cli daemon's admin endpoint. The
// daemon stashes the secret in the OS keychain and switches into sidecar mode
// for SEC_AUTH credential isolation.
func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{
Use: "init",
Short: "Register a Lark App with the running lark-sec-cli daemon",
Long: `Register an App ID / App Secret with the lark-sec-cli daemon.
The daemon must already be running (start it with "lark-cli sec run"). The
registration POSTs to /_sec/api/v1/register-app on the local proxy port,
HMAC-signed with the daemon's proxy.key.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runConfigInit(cmd, opts)
},
}
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)")
cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark")
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided")
return cmd
}
// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json.
// It's the single contract between lark-cli and lark-sec-cli at runtime —
// we don't reach into lark-sec-cli internals, only what it chooses to publish.
type secBridge struct {
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
CA string `json:"LARKSUITE_CLI_SEC_CA"`
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
}
func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error {
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir())
bridge, err := loadBridge()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing",
fmt.Sprintf("daemon bridge file unreadable: %v", err),
"Start the daemon first: `lark-cli sec run`.")
}
tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth)
if !bridge.Enable || bridge.Proxy == "" {
return output.ErrWithHint(output.ExitValidation, "sec_not_running",
"lark-sec-cli is not advertising an active proxy",
"Run `lark-cli sec run` to start it.")
}
// The HMAC key sits next to the CA in the daemon's config dir. Deriving
// from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's
// install location — if the daemon ever moves, the bridge follows and we
// follow with it.
tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA)
hmacKey, err := readHMACKey(bridge.CA)
if err != nil {
return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err)
}
if err := promptForMissing(opts); err != nil {
return err
}
tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand)
if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil {
return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err)
}
output.PrintSuccess(errOut,
fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand))
return nil
}
// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir.
func loadBridge() (*secBridge, error) {
path := filepath.Join(core.GetConfigDir(), "sec_config.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var b secBridge
if err := json.Unmarshal(data, &b); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &b, nil
}
// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key
// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw
// 32-byte blob (older daemon variants), we use it as-is.
func readHMACKey(caPath string) ([]byte, error) {
if caPath == "" {
return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key")
}
keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key")
raw, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
raw = bytes.TrimSpace(raw)
if len(raw) == 64 {
if decoded, err := hex.DecodeString(string(raw)); err == nil {
return decoded, nil
}
}
return raw, nil
}
// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't
// provide via flags. --yes refuses to prompt; that's caller error if any are
// still missing at that point.
func promptForMissing(opts *ConfigInitOptions) error {
if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" {
return nil
}
if opts.Yes {
return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand")
}
groups := []*huh.Group{}
if opts.AppID == "" {
groups = append(groups, huh.NewGroup(
huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID),
))
}
if opts.AppSecret == "" {
groups = append(groups, huh.NewGroup(
huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret),
))
}
if opts.Brand == "" {
opts.Brand = "feishu"
groups = append(groups, huh.NewGroup(
huh.NewSelect[string]().Title("Brand").Options(
huh.NewOption("Feishu (cn)", "feishu"),
huh.NewOption("Lark (intl)", "lark"),
).Value(&opts.Brand),
))
}
if len(groups) == 0 {
return nil
}
form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return err
}
return nil
}
// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme.
// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per
// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC.
func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error {
const path = "/_sec/api/v1/register-app"
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
"brand": brand,
})
if err != nil {
return err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
bodyHash := sha256.Sum256(body)
canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts
mac := hmac.New(sha256.New, hmacKey)
mac.Write([]byte(canonical))
sig := hex.EncodeToString(mac.Sum(nil))
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Lark-Admin-Signature", sig)
req.Header.Set("X-Lark-Admin-Timestamp", ts)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
intsec "github.com/larksuite/cli/internal/sec"
)
// installer wires up an internal/sec.Installer using the Factory's HTTP client,
// the default platform paths, and a lazy OAPI-client provider used to fetch
// the install manifest. APIClientFunc is a method value, not an eager call —
// commands that short-circuit (or that never install, like sec status / sec
// stop) avoid decrypting credentials from the keychain. Every cmd/sec
// subcommand starts here.
func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) {
paths, err := intsec.DefaultPaths()
if err != nil {
return nil, nil, fmt.Errorf("resolve sec paths: %w", err)
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, nil, fmt.Errorf("resolve http client: %w", err)
}
return &intsec.Installer{
Paths: paths,
HTTPClient: httpClient,
APIClientFunc: f.NewAPIClient,
}, paths, nil
}

View File

@@ -1,127 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// RunOptions holds inputs for `lark-cli sec run`.
type RunOptions struct {
Factory *cmdutil.Factory
ProxyPort int
// AutoInstall runs `sec install` first when no binary is recorded.
AutoInstall bool
}
// NewCmdSecRun starts lark-sec-cli as a user-level system service so it
// persists across logins and gets restarted by the OS supervisor if it
// crashes. Under the hood it shells out to `lark-sec-cli service enable`,
// which is the recommended startup path per the lark-sec-cli manual:
//
// - macOS → user-level launchd plist with KeepAlive=true
// - Linux → user systemd unit with Restart=always
// - Windows → registry autostart + a VBS watchdog loop
//
// Switching to this from a detached `exec.Command(... Setsid:true)` spawn
// fixes two latent issues at once: (1) daemon logs survive past lark-cli
// exit because the service supervisor — not our terminated pipes — owns
// the daemon's stdout, and (2) the daemon's own self-upgrade module can
// now fire (it gates on running-under-supervisor).
func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
opts := &RunOptions{Factory: f, AutoInstall: true}
cmd := &cobra.Command{
Use: "run",
Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)",
Long: `Install lark-sec-cli as a user-level system service so the proxy
daemon runs automatically, persists across logins, and is restarted by the
OS if it exits. The daemon writes its own log file (default: under
~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this
command.
After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with
the proxy port and CA path, so subsequent lark-cli runs route through the
sidecar without any further action.
To stop and remove the service: lark-cli sec stop.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runRun(cmd, opts)
},
}
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)")
cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded")
return cmd
}
func runRun(cmd *cobra.Command, opts *RunOptions) error {
ctx := cmd.Context()
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec run", "constructing installer (lazy credentials)")
inst, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
// Make sure we have a binary on disk before asking it to install itself
// as a service.
tracef(trace, "sec run", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall)
if !opts.AutoInstall {
return output.ErrWithHint(output.ExitValidation, "sec_not_installed",
"lark-sec-cli is not installed",
"Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.")
}
state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace})
if err != nil {
return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err)
}
} else {
tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath)
}
args := []string{"service", "enable"}
if opts.ProxyPort > 0 {
args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort))
}
fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args)
tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args)
c := exec.CommandContext(ctx, state.BinaryPath, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
if err := c.Run(); err != nil {
return output.Errorf(output.ExitInternal, "sec_service_enable",
"`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String())
}
tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len())
// Forward the installer's stdout to the user — it contains the launchd /
// systemd unit name, the registered executable path, and a confirmation
// that the supervisor will respawn the daemon on exit. Useful diagnostic
// output that's better seen than swallowed.
fmt.Fprint(errOut, stdout.String())
output.PrintSuccess(errOut,
"lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.")
return nil
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sec exposes the `lark-cli sec` command tree that bootstraps the
// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`.
// The internal/sec package owns the implementation; this package is a thin
// Cobra wrapper that mirrors the conventions in cmd/auth.
//
// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle —
// lark-cli is not in the update path, which is why there's no `sec update`
// subcommand here.
package sec
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdSec builds the parent `sec` command and registers all subcommands.
//
// The persistent --verbose / -v flag is inherited by every subcommand:
// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to
// stderr.
//
// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli
// if no binary is on disk, so a separate install verb was redundant.
func NewCmdSec(f *cmdutil.Factory) *cobra.Command {
var verbose bool
cmd := &cobra.Command{
Use: "sec",
Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)",
Long: `Manage the lark-sec-cli security sidecar.
lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic,
injects BDMS risk-control signatures, and manages credentials via the OS
keychain. These subcommands handle the runtime lifecycle from lark-cli's side:
start the daemon (auto-installing on first run), inspect its state, register
an app with it, and stop it. Updates after the first install are managed by
lark-sec-cli itself.`,
}
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"print step-by-step pipeline output to stderr")
cmd.AddCommand(NewCmdSecRun(f, nil))
cmd.AddCommand(NewCmdSecStop(f, nil))
cmd.AddCommand(NewCmdSecStatus(f, nil))
cmd.AddCommand(NewCmdSecConfig(f))
return cmd
}

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"sort"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a
// future refactor doesn't silently drop run/status/etc. The `update` verb
// was intentionally removed when lark-sec-cli took over its own upgrade
// lifecycle; if it ever needs to come back, add it here too. `install` was
// removed because `sec run --auto-install` (default on) makes a standalone
// install verb redundant.
func TestNewCmdSec_HasAllSubcommands(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := NewCmdSec(f)
var got []string
for _, c := range cmd.Commands() {
got = append(got, c.Name())
}
sort.Strings(got)
want := []string{"config", "run", "status", "stop"}
if len(got) != len(want) {
t.Fatalf("subcommands = %v, want %v", got, want)
}
for i, name := range want {
if got[i] != name {
t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name)
}
}
}

View File

@@ -1,115 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// StatusOptions holds inputs for `lark-cli sec status`.
type StatusOptions struct {
Factory *cmdutil.Factory
}
// NewCmdSecStatus shows install + runtime state. Implementation strategy:
//
// 1. Read lark-cli's local install record (state.json) — works even when the
// daemon's not installed, and gives the user a version/buildId/path
// fingerprint regardless of whether the service is up.
// 2. If the install exists, shell out to `lark-sec-cli status` for the
// live daemon view (service registration, pid liveness, proxy probe,
// sec_config.json contents). The daemon's own status command does a
// thorough check; we just pass it through.
func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
opts := &StatusOptions{Factory: f}
cmd := &cobra.Command{
Use: "status",
Short: "Show lark-sec-cli install and runtime state",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runStatus(cmd, opts)
},
}
return cmd
}
func runStatus(cmd *cobra.Command, opts *StatusOptions) error {
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec status", "constructing installer (lazy credentials)")
_, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
out := opts.Factory.IOStreams.Out
tracef(trace, "sec status", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
fmt.Fprintln(out, "lark-sec-cli: not installed")
fmt.Fprintln(out, " run: lark-cli sec run")
return nil
}
fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version)
fmt.Fprintf(out, " binary: %s\n", state.BinaryPath)
// Daemon-side detail via `lark-sec-cli status`. The daemon's status
// command already covers service registration + pid + proxy reachability
// + bridge file — better than re-implementing those here.
tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath)
c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status")
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
runErr := c.Run()
tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len())
fmt.Fprintln(out, " --- lark-sec-cli status ---")
if stdout.Len() > 0 {
fmt.Fprint(out, indent(stdout.String(), " "))
}
if stderr.Len() > 0 {
fmt.Fprint(out, indent(stderr.String(), " "))
}
// `lark-sec-cli status` exits 1 when not running — that's diagnostic
// data, not a failure of OUR command. Surface it for the user but don't
// propagate the non-zero exit upward.
_ = runErr
return nil
}
// indent prefixes every line of s with prefix. Cheap pass-through formatter
// used to make the embedded `lark-sec-cli status` output read as a sub-block
// under our own header.
func indent(s, prefix string) string {
if s == "" {
return s
}
var buf bytes.Buffer
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
buf.WriteString(prefix)
buf.WriteString(s[start : i+1])
start = i + 1
}
}
if start < len(s) {
buf.WriteString(prefix)
buf.WriteString(s[start:])
}
return buf.String()
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// StopOptions holds inputs for `lark-cli sec stop`.
type StopOptions struct {
Factory *cmdutil.Factory
}
// NewCmdSecStop disables and removes the lark-sec-cli user system service.
// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`,
// which uninstalls the launchd / systemd / VBS-watchdog registration.
//
// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its
// --disable-on-exit flag, default true), so subsequent lark-cli runs route
// directly to the upstream API instead of dangling through a dead local proxy.
func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command {
opts := &StopOptions{Factory: f}
cmd := &cobra.Command{
Use: "stop",
Short: "Disable and remove the lark-sec-cli user system service",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runStop(cmd, opts)
},
}
return cmd
}
func runStop(cmd *cobra.Command, opts *StopOptions) error {
out := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, out)
tracef(trace, "sec stop", "constructing installer (lazy credentials)")
_, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
tracef(trace, "sec stop", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
// Nothing on disk to stop — no-op.
tracef(trace, "sec stop", "no install on disk; nothing to stop")
output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop")
return nil
}
args := []string{"service", "disable"}
fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args)
tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args)
c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
if err := c.Run(); err != nil {
return output.Errorf(output.ExitInternal, "sec_service_disable",
"`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String())
}
tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len())
fmt.Fprint(out, stdout.String())
output.PrintSuccess(out, "lark-sec-cli service disabled")
return nil
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"io"
"github.com/spf13/cobra"
)
// verboseOut returns the trace destination for a sec subcommand: the given
// stderr writer when the inherited --verbose / -v flag is set, otherwise nil.
// Pair with tracef — a nil destination silently drops traces, so callers can
// emit unconditionally.
func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer {
if v, _ := cmd.Flags().GetBool("verbose"); v {
return errOut
}
return nil
}
// tracef writes one trace line to w when w is non-nil. The prefix names the
// emitting subcommand (e.g. "sec run") so layered output from the install
// pipeline + the command itself stays distinguishable.
func tracef(w io.Writer, prefix, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...)
}

View File

@@ -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")
}
}
}

View File

@@ -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())
}

View File

@@ -1,227 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package secplugin provides a placeholder credential provider for SEC_AUTH mode.
//
// When ~/.lark-cli/sec_config.json has:
//
// LARKSUITE_CLI_SEC_ENABLE=true
// LARKSUITE_CLI_SEC_AUTH=true
//
// this provider returns a minimal Account and placeholder tokens. The proxy
// is expected to replace the placeholder tokens with real ones.
package secplugin
import (
"context"
"fmt"
"os"
"strings"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
internalsec "github.com/larksuite/cli/internal/secplugin"
)
// Provider supplies placeholder credentials when SEC_AUTH mode is enabled.
type Provider struct{}
// Name returns the registered credential provider name.
func (p *Provider) Name() string { return "secplugin" }
// Priority is higher than env (default 10) but lower than sidecar (0),
// so authsidecar builds keep sidecar semantics when both are present.
func (p *Provider) Priority() int { return 1 }
// loadSecConfig is replaceable in tests so provider behavior can be isolated
// from on-disk SEC configuration state.
var loadSecConfig = internalsec.Load
func validateDefaultAs(value string) error {
switch id := credential.Identity(strings.TrimSpace(value)); id {
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
return nil
default:
return fmt.Errorf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id)
}
}
// ResolveAccount builds an account that advertises SEC_AUTH placeholder support.
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
cfg, err := loadSecConfig()
if err != nil {
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
}
if cfg == nil || !cfg.AuthEnabled() {
return nil, nil
}
appID := strings.TrimSpace(os.Getenv(envvars.CliAppID))
brand := credential.Brand(strings.TrimSpace(os.Getenv(envvars.CliBrand)))
var defaultAs credential.Identity
// Prefer explicit env; if missing, allow sec_config.json to provide defaults.
if appID == "" && strings.TrimSpace(cfg.AppID) != "" {
appID = strings.TrimSpace(cfg.AppID)
}
if brand == "" && strings.TrimSpace(cfg.Brand) != "" {
brand = credential.Brand(strings.TrimSpace(cfg.Brand))
}
if defaultAs == "" && strings.TrimSpace(cfg.DefaultAs) != "" {
defaultAs = credential.Identity(strings.TrimSpace(cfg.DefaultAs))
if err := validateDefaultAs(string(defaultAs)); err != nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: err.Error(),
}
}
}
// Prefer explicit env for sandbox use; otherwise fall back to on-disk config
// without resolving any secrets.
if appID == "" || brand == "" {
multi, err := core.LoadMultiAppConfig()
if err != nil || multi == nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but no app config is available; run `lark-cli config init --new` (trusted env), or set " + envvars.CliAppID + " and " + envvars.CliBrand,
}
}
app := multi.CurrentAppConfig("") // profile override not available in provider API
if app == nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but no active profile is available in config.json",
}
}
if appID == "" {
appID = app.AppId
}
if brand == "" {
brand = credential.Brand(app.Brand)
}
if defaultAs == "" {
defaultAs = credential.Identity(app.DefaultAs)
}
// Map strict mode to supported identities (0 = allow all).
mode := multi.StrictMode
if app.StrictMode != nil {
mode = *app.StrictMode
}
switch mode {
case core.StrictModeBot:
// Keep sandbox locked down to bot.
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: credential.SupportsBot,
}, nil
case core.StrictModeUser:
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: credential.SupportsUser,
}, nil
}
}
if appID == "" {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but " + envvars.CliAppID + " is missing",
}
}
if brand == "" {
brand = credential.BrandFeishu
}
if brand != credential.BrandFeishu && brand != credential.BrandLark {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: fmt.Sprintf("invalid %s %q (want feishu or lark)", envvars.CliBrand, brand),
}
}
// DefaultAs comes from env if present (optional).
envDefaultAs := strings.TrimSpace(os.Getenv(envvars.CliDefaultAs))
if err := validateDefaultAs(envDefaultAs); err != nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: err.Error(),
}
}
switch id := credential.Identity(envDefaultAs); id {
case "", credential.IdentityAuto:
// keep defaultAs from config/env; empty is allowed
case credential.IdentityUser, credential.IdentityBot:
defaultAs = id
}
// If STRICT_MODE env is not set, allow sec_config.json to provide a default.
strictModeRaw := strings.TrimSpace(os.Getenv(envvars.CliStrictMode))
if strictModeRaw == "" && strings.TrimSpace(cfg.StrictMode) != "" {
strictModeRaw = strings.TrimSpace(cfg.StrictMode)
}
// SupportedIdentities from STRICT_MODE (optional). Default: allow both.
support := credential.SupportsAll
switch strictMode := strictModeRaw; strictMode {
case "bot":
support = credential.SupportsBot
case "user":
support = credential.SupportsUser
case "off", "":
// Keep the default: allow both identities.
default:
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: support,
}, nil
}
// ResolveToken returns placeholder tokens that a trusted proxy must replace.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
cfg, err := internalsec.Load()
if err != nil {
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
}
if cfg == nil || !cfg.AuthEnabled() {
return nil, nil
}
switch req.Type {
case credential.TokenTypeUAT:
return &credential.Token{
Value: internalsec.SentinelUAT,
Scopes: "", // empty => skip scope pre-check
Source: "secplugin",
}, nil
case credential.TokenTypeTAT:
return &credential.Token{
Value: internalsec.SentinelTAT,
Scopes: "",
Source: "secplugin",
}, nil
default:
return nil, nil
}
}
// init registers the SEC_AUTH placeholder credential provider.
func init() {
credential.Register(&Provider{})
}

View File

@@ -1,486 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
internalsec "github.com/larksuite/cli/internal/secplugin"
)
// TestProvider_Metadata verifies the registered provider metadata.
func TestProvider_Metadata(t *testing.T) {
p := &Provider{}
if p.Name() != "secplugin" {
t.Fatalf("Name() = %q, want secplugin", p.Name())
}
if p.Priority() != 1 {
t.Fatalf("Priority() = %d, want 1", p.Priority())
}
}
// TestProvider_UsesSecConfigDefaults verifies that SEC config defaults populate
// the placeholder account when env vars are absent.
func TestProvider_UsesSecConfigDefaults(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "lark",
DefaultAs: "bot",
StrictMode: "bot",
}, nil
}
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "cli_test_app" {
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_test_app")
}
if string(acct.Brand) != "lark" {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
}
if string(acct.DefaultAs) != "bot" {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "bot")
}
// StrictMode=bot => SupportsBot only.
if acct.SupportedIdentities != 2 {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, 2)
}
}
// TestProvider_EnvOverridesSecConfigDefaults verifies that explicit environment
// variables override SEC config defaults.
func TestProvider_EnvOverridesSecConfigDefaults(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "feishu",
DefaultAs: "bot",
StrictMode: "bot",
}, nil
}
t.Setenv(envvars.CliAppID, "cli_env_app")
t.Setenv(envvars.CliBrand, "lark")
t.Setenv(envvars.CliDefaultAs, "user")
t.Setenv(envvars.CliStrictMode, "user")
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "cli_env_app" {
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_env_app")
}
if string(acct.Brand) != "lark" {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
}
if string(acct.DefaultAs) != "user" {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "user")
}
// StrictMode=user => SupportsUser only (bit 1).
if acct.SupportedIdentities != 1 {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsUser)", acct.SupportedIdentities, 1)
}
}
// TestProvider_ResolveAccount_ReturnsNilWhenDisabled verifies early nil returns
// when SEC_AUTH mode is unavailable.
func TestProvider_ResolveAccount_ReturnsNilWhenDisabled(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
cases := []struct {
name string
cfg *internalsec.Config
}{
{name: "nil config", cfg: nil},
{name: "auth disabled", cfg: &internalsec.Config{Enable: true, Auth: false}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
})
}
}
// TestProvider_ResolveAccount_LoadErrorBlocks verifies that SEC config load failures
// stop provider resolution.
func TestProvider_ResolveAccount_LoadErrorBlocks(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return nil, context.DeadlineExceeded
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr, ok := err.(*credential.BlockError)
if !ok {
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
}
if blockErr.Provider != "secplugin" {
t.Fatalf("blockErr.Provider = %q, want secplugin", blockErr.Provider)
}
if !strings.Contains(blockErr.Reason, context.DeadlineExceeded.Error()) {
t.Fatalf("blockErr.Reason = %q, want load error text", blockErr.Reason)
}
}
// TestProvider_ResolveAccount_DefaultsBrandAndSupport verifies fallback defaults
// for brand and supported identities.
func TestProvider_ResolveAccount_DefaultsBrandAndSupport(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
}, nil
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
DefaultAs: core.AsBot,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.Brand != credential.BrandFeishu {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, credential.BrandFeishu)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, credential.SupportsAll)
}
if acct.DefaultAs != credential.Identity("bot") {
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
}
if acct.AppID != "app_from_disk" {
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
}
}
// TestProvider_ResolveAccount_InvalidValuesBlock verifies validation failures for
// brand and identity-related settings.
func TestProvider_ResolveAccount_InvalidValuesBlock(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
cases := []struct {
name string
cfg *internalsec.Config
envKey string
envValue string
want string
}{
{
name: "invalid brand from config",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "bad-brand"},
want: "invalid " + envvars.CliBrand,
},
{
name: "invalid default as from config",
cfg: &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "lark",
DefaultAs: "bad",
},
want: "invalid " + envvars.CliDefaultAs,
},
{
name: "invalid default as from env",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
envKey: envvars.CliDefaultAs,
envValue: "bad",
want: "invalid " + envvars.CliDefaultAs,
},
{
name: "invalid strict mode from env",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
envKey: envvars.CliStrictMode,
envValue: "bad",
want: "invalid " + envvars.CliStrictMode,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if tt.envKey != "" {
t.Setenv(tt.envKey, tt.envValue)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr, ok := err.(*credential.BlockError)
if !ok {
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
}
if !strings.Contains(blockErr.Reason, tt.want) {
t.Fatalf("blockErr.Reason = %q, want substring %q", blockErr.Reason, tt.want)
}
})
}
}
// TestProvider_ResolveAccount_FallbackToDiskConfig verifies fallback behavior
// when SEC config omits app identity fields.
func TestProvider_ResolveAccount_FallbackToDiskConfig(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{Enable: true, Auth: true}, nil
}
t.Run("missing config blocks", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr := err.(*credential.BlockError)
if !strings.Contains(blockErr.Reason, "no app config is available") {
t.Fatalf("blockErr.Reason = %q, want missing app config message", blockErr.Reason)
}
})
t.Run("missing active profile blocks", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr := err.(*credential.BlockError)
if !strings.Contains(blockErr.Reason, "no active profile") {
t.Fatalf("blockErr.Reason = %q, want no active profile message", blockErr.Reason)
}
})
t.Run("strict mode from disk", func(t *testing.T) {
cases := []struct {
name string
mode core.StrictMode
wantIDs credential.IdentitySupport
}{
{name: "bot", mode: core.StrictModeBot, wantIDs: credential.SupportsBot},
{name: "user", mode: core.StrictModeUser, wantIDs: credential.SupportsUser},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mode := tt.mode
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
DefaultAs: core.AsBot,
StrictMode: &mode,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "app_from_disk" {
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
}
if acct.Brand != credential.Brand("lark") {
t.Fatalf("acct.Brand = %q, want lark", acct.Brand)
}
if acct.DefaultAs != credential.Identity("bot") {
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
}
if acct.SupportedIdentities != tt.wantIDs {
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.wantIDs)
}
})
}
})
}
// TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs verifies
// cfg.DefaultAs is not overwritten by disk profile default in strict-mode path.
func TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
Brand: "lark",
DefaultAs: "user",
StrictMode: "bot",
}, nil
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
DefaultAs: core.AsBot,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.DefaultAs != credential.IdentityUser {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, credential.IdentityUser)
}
if acct.SupportedIdentities != credential.SupportsBot {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, credential.SupportsBot)
}
}
// TestProvider_ResolveToken_ReturnsSentinels verifies placeholder token behavior
// for SEC_AUTH mode.
func TestProvider_ResolveToken_ReturnsSentinels(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliSecEnable, "true")
t.Setenv(envvars.CliSecAuth, "true")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:3128")
t.Setenv(envvars.CliSecCA, "")
p := &Provider{}
uat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("ResolveToken(UAT) error = %v", err)
}
if uat == nil || uat.Value != internalsec.SentinelUAT || uat.Source != "secplugin" {
t.Fatalf("ResolveToken(UAT) = %#v, want sentinel UAT token", uat)
}
tat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatalf("ResolveToken(TAT) error = %v", err)
}
if tat == nil || tat.Value != internalsec.SentinelTAT || tat.Source != "secplugin" {
t.Fatalf("ResolveToken(TAT) = %#v, want sentinel TAT token", tat)
}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenType("other")})
if err != nil {
t.Fatalf("ResolveToken(other) error = %v", err)
}
if tok != nil {
t.Fatalf("ResolveToken(other) = %#v, want nil", tok)
}
}

3
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -4,45 +4,18 @@
package envvars
const (
// CliAppID is the app ID environment variable consumed by the CLI.
CliAppID = "LARKSUITE_CLI_APP_ID"
// CliAppSecret is the app secret environment variable consumed by the CLI.
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
// CliBrand selects the tenant brand environment variable consumed by the CLI.
CliBrand = "LARKSUITE_CLI_BRAND"
// CliUserAccessToken is the user access token override environment variable.
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
// CliTenantAccessToken is the tenant access token override environment variable.
CliAppID = "LARKSUITE_CLI_APP_ID"
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
CliBrand = "LARKSUITE_CLI_BRAND"
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// CliDefaultAs selects the default identity environment variable.
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
// CliStrictMode selects the strict identity mode environment variable.
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// CliAuthProxy is the auth sidecar HTTP address environment variable.
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// CliProxyKey is the shared HMAC signing key environment variable for the sidecar.
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// CliSecEnable enables sec plugin mode from the environment.
CliSecEnable = "LARKSUITE_CLI_SEC_ENABLE"
// CliSecProxy sets the fixed sec plugin HTTP proxy address.
CliSecProxy = "LARKSUITE_CLI_SEC_PROXY"
// CliSecCA points to an extra PEM bundle trusted by sec plugin mode.
CliSecCA = "LARKSUITE_CLI_SEC_CA"
// CliSecAuth enables placeholder-token auth mode for sec plugin flows.
CliSecAuth = "LARKSUITE_CLI_SEC_AUTH"
// CliContentSafetyMode selects the content safety scanning mode.
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
)

View File

@@ -1,138 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
// maxArchiveBytes is a sanity ceiling for total uncompressed size to prevent
// a malicious or corrupt zip from filling the disk. The lark-sec-cli zip is a
// single binary plus one shared library; 1 GiB is several orders of magnitude
// over the real size and well under most users' free disk.
const maxArchiveBytes = 1 << 30
// ExtractZip unpacks src into dst, refusing entries whose target paths would
// escape dst (zip slip). Existing files inside dst are overwritten; dst must
// already exist.
//
// Executable permission is preserved when the zip stores POSIX mode bits;
// otherwise we apply 0o755 to suspected binaries (matching BinaryName() /
// legacy names or anything *.dylib/*.so/*.dll) and 0o644 to everything else.
func ExtractZip(src, dst string) error {
r, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer r.Close()
dstAbs, err := filepath.Abs(dst)
if err != nil {
return err
}
var totalSize uint64
for _, f := range r.File {
totalSize += f.UncompressedSize64
if totalSize > maxArchiveBytes {
return fmt.Errorf("zip exceeds %d bytes; refusing", maxArchiveBytes)
}
if err := extractZipEntry(f, dstAbs); err != nil {
return err
}
}
return nil
}
func extractZipEntry(f *zip.File, dstAbs string) error {
// Reject absolute paths and any traversal segments. filepath.Clean
// collapses redundant separators but does NOT resolve symlinks or strip
// leading slashes — we have to do both explicitly.
name := f.Name
if strings.ContainsRune(name, 0) {
return fmt.Errorf("zip entry name contains NUL: %q", name)
}
cleaned := filepath.Clean(name)
if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") ||
strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) {
return fmt.Errorf("zip entry escapes destination: %q", name)
}
target := filepath.Join(dstAbs, cleaned)
// Defense in depth: even if the checks above missed something, this rel
// check guarantees target is under dstAbs.
rel, err := filepath.Rel(dstAbs, target)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("zip entry escapes destination: %q", name)
}
if f.FileInfo().IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
// Symlink support: zip entries can be symlinks (mode bit set). The
// lark-sec-cli artifact doesn't currently use them, but if it grows to
// (e.g. for shared library version aliases) we want graceful handling.
if f.Mode()&os.ModeSymlink != 0 {
rc, err := f.Open()
if err != nil {
return err
}
linkBytes, readErr := io.ReadAll(io.LimitReader(rc, 1024))
rc.Close()
if readErr != nil {
return readErr
}
os.Remove(target) // os.Symlink fails if target exists
return os.Symlink(string(linkBytes), target)
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
mode := f.Mode().Perm()
if mode == 0 {
mode = guessMode(cleaned)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
return err
}
return out.Close()
}
// guessMode supplies executable bits for entries the zip writer didn't tag
// with POSIX mode info — typically the case for archives built on Windows.
func guessMode(name string) os.FileMode {
base := filepath.Base(name)
if base == BinaryName() {
return 0o755
}
ext := strings.ToLower(filepath.Ext(base))
switch ext {
case ".dylib", ".so", ".dll":
return 0o755
}
if runtime.GOOS != "windows" && !strings.ContainsRune(base, '.') {
// Plausibly an extra unix binary shipped alongside the sec-cli binary.
return 0o755
}
return 0o644
}

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"archive/zip"
"bytes"
"io"
"os"
"path/filepath"
"testing"
)
// makeZip builds an in-memory zip with the given entries, writes it to path,
// and returns nothing — convenience for table-driven tests.
type zipEntry struct {
name string
body string
mode os.FileMode
symlink string // when set, entry is a symlink with this target
}
func makeZip(t *testing.T, path string, entries []zipEntry) {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, e := range entries {
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
if e.mode != 0 {
hdr.SetMode(e.mode)
}
if e.symlink != "" {
hdr.SetMode(os.ModeSymlink | 0o777)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("zip header %q: %v", e.name, err)
}
body := e.body
if e.symlink != "" {
body = e.symlink
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("zip write %q: %v", e.name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
t.Fatalf("write zip: %v", err)
}
}
func TestExtractZip_HappyPath(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "src.zip")
makeZip(t, zipPath, []zipEntry{
{name: "lark-sec-cli", body: "binary", mode: 0o755},
{name: "ca.crt", body: "cert"},
{name: "lib/libMetaSecML.dylib", body: "dylib", mode: 0o755},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err != nil {
t.Fatalf("ExtractZip: %v", err)
}
for name, want := range map[string]string{
"lark-sec-cli": "binary",
"ca.crt": "cert",
"lib/libMetaSecML.dylib": "dylib",
} {
got, err := os.ReadFile(filepath.Join(dst, name))
if err != nil {
t.Errorf("read %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("%s body = %q, want %q", name, got, want)
}
}
if info, err := os.Stat(filepath.Join(dst, "lark-sec-cli")); err == nil {
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("lark-sec-cli not executable: mode=%v", info.Mode())
}
}
}
func TestExtractZip_RejectsTraversal(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "evil.zip")
makeZip(t, zipPath, []zipEntry{
{name: "../../../etc/passwd", body: "pwned"},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err == nil {
t.Fatal("ExtractZip accepted zip-slip entry")
}
}
func TestExtractZip_RejectsAbsolutePath(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "abs.zip")
makeZip(t, zipPath, []zipEntry{
{name: "/etc/passwd", body: "pwned"},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err == nil {
t.Fatal("ExtractZip accepted absolute-path entry")
}
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
_ "embed"
"encoding/json"
"fmt"
)
// bootstrapManifestJSON is the lark-sec-cli release manifest shipped with this
// lark-cli build. It points directly at TOS so a fresh install does not depend
// on any external release-tracking service — first install is fully self-contained.
//
// Updating this file pins a new default version of lark-sec-cli for users who
// install via lark-cli. After install, lark-sec-cli is in charge of finding and
// applying its own updates; lark-cli does not consult any release server.
//
//go:embed bootstrap.json
var bootstrapManifestJSON []byte
// LoadBootstrap parses the embedded bootstrap manifest into a Manifest value.
func LoadBootstrap() (*Manifest, error) {
var entries []Entry
if err := json.Unmarshal(bootstrapManifestJSON, &entries); err != nil {
return nil, fmt.Errorf("decode embedded bootstrap manifest: %w", err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("embedded bootstrap manifest is empty")
}
return &Manifest{Entries: entries}, nil
}

View File

@@ -1,59 +0,0 @@
[
{
"key": 0,
"buildPlatform": "linux",
"urls": [
{
"urls": {
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-amd64/linux-amd64-1.0.1-alpha.23.zip",
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-arm64/linux-arm64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487420795
}
},
{
"key": 1,
"buildPlatform": "win32",
"urls": [
{
"urls": {
"x86": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-386/windows-386-1.0.1-alpha.23.zip",
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-amd64/windows-amd64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487437393
}
},
{
"key": 2,
"buildPlatform": "darwin",
"urls": [
{
"urls": {
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-amd64/darwin-amd64-1.0.1-alpha.23.zip",
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-arm64/darwin-arm64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487395152
}
}
]

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"runtime"
"strings"
"testing"
)
// TestLoadBootstrap_DecodesAllPlatforms guards against the embedded
// manifest becoming malformed or losing an OS — both would break first
// install on whatever GOOS lost its entry.
func TestLoadBootstrap_DecodesAllPlatforms(t *testing.T) {
manifest, err := LoadBootstrap()
if err != nil {
t.Fatalf("LoadBootstrap: %v", err)
}
platforms := map[string]bool{}
for _, e := range manifest.Entries {
platforms[e.BuildPlatform] = true
if e.Version == "" {
t.Errorf("entry %s missing version", e.BuildPlatform)
}
if e.Extra.PipelineID == "" {
t.Errorf("entry %s missing extra.pipeline_id", e.BuildPlatform)
}
}
for _, want := range []string{"darwin", "linux", "win32"} {
if !platforms[want] {
t.Errorf("bootstrap missing platform %q", want)
}
}
}
// TestLoadBootstrap_PickArtifactForCurrentHost ensures the embedded manifest
// resolves to a real URL for whatever platform the test runner is on, so a
// developer fixing this code locally can still smoke-test their changes.
func TestLoadBootstrap_PickArtifactForCurrentHost(t *testing.T) {
manifest, err := LoadBootstrap()
if err != nil {
t.Fatalf("LoadBootstrap: %v", err)
}
art, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, "cn")
if err != nil {
t.Fatalf("PickArtifact for %s/%s: %v", runtime.GOOS, runtime.GOARCH, err)
}
if !strings.HasPrefix(art.URL, "https://") {
t.Errorf("URL is not https: %q", art.URL)
}
if !strings.HasSuffix(art.URL, ".zip") {
t.Errorf("URL is not a .zip: %q", art.URL)
}
if art.BuildID == "" {
t.Error("BuildID is empty")
}
}

View File

@@ -1,156 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"hash"
"io"
"net/http"
"os"
)
// downloadMaxBytes caps the artifact size we'll accept. Comfortably over the
// real lark-sec-cli zip (~tens of MB) and well under what a malicious mirror
// could use to exhaust local disk before we noticed.
const downloadMaxBytes = 512 * 1024 * 1024
// DownloadOptions controls Download.
type DownloadOptions struct {
URL string
Destination string // full path to the .zip we'll create
HTTPClient *http.Client
// ExpectedSHA256, if non-empty, is the hex SHA256 the artifact MUST
// match — verified after the full body has been streamed. Use this when
// the manifest publishes a hash for the artifact (e.g. bootstrap.json's
// `extra.sha256`). Any mismatch fails the download with the .part file
// removed.
//
// When empty (the manifest doesn't carry a hash), the only integrity
// check left is the CDN's own `Content-MD5` response header, applied
// opportunistically below.
ExpectedSHA256 string
}
// Download streams URL to Destination. Writes to a sibling .part file and
// renames into place on success so a crashed or aborted run leaves no
// half-written zip the next run might mistake for valid.
//
// Two layers of integrity check, both opt-in:
//
// 1. ExpectedSHA256 (strong, manifest-provided): cryptographic, fails the
// download on mismatch. Use whenever the release manifest carries a hash.
// 2. CDN `Content-MD5` header (opportunistic): non-cryptographic, catches
// edge replacement or transit corruption when the upstream CDN populates
// the header. Runs unconditionally — if the header is present we honour it.
//
// Neither check defends against a malicious upstream that controls both the
// artifact AND the manifest. That class of risk has to be handled by signing
// the release pipeline, which is out of scope for the client.
func Download(ctx context.Context, opts DownloadOptions) error {
if opts.HTTPClient == nil {
return fmt.Errorf("Download: HTTPClient is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil)
if err != nil {
return err
}
resp, err := opts.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("download %s: %w", opts.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: status %d", opts.URL, resp.StatusCode)
}
tmpPath := opts.Destination + ".part"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
cleanup := func() { out.Close(); os.Remove(tmpPath) }
// Hash both ways during the single read pass. Both hashers are cheap and
// we don't know yet which check (or both) we'll actually need.
sha := sha256.New()
md := md5.New()
writer := io.MultiWriter(out, sha, md)
n, err := io.Copy(writer, io.LimitReader(resp.Body, downloadMaxBytes+1))
if err != nil {
cleanup()
return fmt.Errorf("download %s: %w", opts.URL, err)
}
if n > downloadMaxBytes {
cleanup()
return fmt.Errorf("download %s: exceeds %d bytes", opts.URL, downloadMaxBytes)
}
if err := out.Sync(); err != nil {
cleanup()
return err
}
if err := out.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := verifyChecksums(resp, opts.ExpectedSHA256, sha, md); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("download %s: %w", opts.URL, err)
}
if err := os.Rename(tmpPath, opts.Destination); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// verifyChecksums applies the two-layer integrity check after the body has
// been fully streamed. Returns nil when both layers (whichever apply) agree.
func verifyChecksums(resp *http.Response, expectedSHA256 string, sha, md hash.Hash) error {
if expectedSHA256 != "" {
got := hex.EncodeToString(sha.Sum(nil))
if !equalFoldHex(got, expectedSHA256) {
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, got)
}
}
if cdnMD5 := resp.Header.Get("Content-MD5"); cdnMD5 != "" {
got := base64.StdEncoding.EncodeToString(md.Sum(nil))
if got != cdnMD5 {
return fmt.Errorf("content-md5 mismatch: cdn=%s, computed=%s", cdnMD5, got)
}
}
return nil
}
// equalFoldHex is a non-allocating ASCII case-insensitive compare for hex
// strings. SHA256 manifests sometimes ship uppercase, sometimes lowercase.
func equalFoldHex(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if 'A' <= ca && ca <= 'Z' {
ca += 'a' - 'A'
}
if 'A' <= cb && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}

View File

@@ -1,184 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
const bodyContent = "lark-sec-cli pretend zip bytes"
// fixtureSHA256 / fixtureMD5 are the hashes of bodyContent.
var fixtureSHA256 string
var fixtureMD5b64 string
func init() {
sum := sha256.Sum256([]byte(bodyContent))
fixtureSHA256 = hex.EncodeToString(sum[:])
m := md5.Sum([]byte(bodyContent))
fixtureMD5b64 = base64.StdEncoding.EncodeToString(m[:])
}
func newFixtureServer(t *testing.T, setContentMD5 bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if setContentMD5 {
w.Header().Set("Content-MD5", fixtureMD5b64)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(bodyContent))
}))
}
// TestDownload_HappyPath_NoChecksum confirms that a download with no manifest
// SHA and no CDN MD5 succeeds — the integrity hooks are opt-in, not required.
func TestDownload_HappyPath_NoChecksum(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err != nil {
t.Fatalf("Download: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != bodyContent {
t.Errorf("body roundtrip mismatch")
}
}
// TestDownload_SHA256_Match confirms the manifest-provided SHA256 path
// passes for a correct hash. Tests both cases (with and without CDN MD5)
// so the second layer doesn't interfere.
func TestDownload_SHA256_Match(t *testing.T) {
for _, withMD5 := range []bool{false, true} {
name := "noMD5"
if withMD5 {
name = "withCDNMd5"
}
t.Run(name, func(t *testing.T) {
srv := newFixtureServer(t, withMD5)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: fixtureSHA256,
})
if err != nil {
t.Fatalf("Download: %v", err)
}
})
}
}
// TestDownload_SHA256_Mismatch is the safety property: a wrong manifest hash
// rejects the download AND removes the .part file so the next run doesn't
// pick up a poisoned zip.
func TestDownload_SHA256_Mismatch(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000",
})
if err == nil {
t.Fatal("expected sha256 mismatch error")
}
if !strings.Contains(err.Error(), "sha256 mismatch") {
t.Errorf("error should mention sha256 mismatch: %v", err)
}
if _, statErr := os.Stat(dst); statErr == nil {
t.Errorf("dst should not exist after mismatch")
}
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
t.Errorf(".part should not exist after mismatch")
}
}
// TestDownload_ContentMD5_Mismatch confirms the opportunistic check fires
// even when no manifest SHA was provided. Catches a CDN edge that returned
// content but a stale/wrong Content-MD5 header (or a poisoned proxy).
func TestDownload_ContentMD5_Mismatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-MD5", "Z3JhZmFuYTpyZWFsbHk/Pz8/Pz8/PzA9PT0=") // arbitrary
_, _ = w.Write([]byte(bodyContent))
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err == nil {
t.Fatal("expected content-md5 mismatch error")
}
if !strings.Contains(err.Error(), "content-md5 mismatch") {
t.Errorf("error should mention content-md5 mismatch: %v", err)
}
}
// TestDownload_SHA256_CaseInsensitive guards the hex compare against case
// drift in the manifest (some publishers upper-case).
func TestDownload_SHA256_CaseInsensitive(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: strings.ToUpper(fixtureSHA256),
})
if err != nil {
t.Fatalf("Download (uppercase sha): %v", err)
}
}
// TestDownload_404_NoPartFile confirms that a non-200 response leaves no
// .part file behind to confuse the next attempt.
func TestDownload_404_NoPartFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err == nil {
t.Fatal("expected error for 404")
}
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
t.Errorf(".part should not exist after 404")
}
}

View File

@@ -1,297 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/larksuite/cli/internal/client"
)
// Installer orchestrates first-time install of lark-sec-cli:
// fetch remote manifest via OAPI → download zip → extract into
// versions/<version>/ → swap "current" → write state.json.
//
// After this first install, lark-sec-cli takes over its own updates and
// lark-cli is no longer in the update path. The installer therefore only
// knows about the bootstrap path — no Tron, no other release sources.
type Installer struct {
Paths *Paths
HTTPClient *http.Client
// APIClientFunc resolves the OAPI client lazily. It is invoked only when
// the install pipeline actually needs to fetch the remote manifest —
// short-circuits (and other callers of installer() that don't install,
// like sec status / sec stop) avoid keychain decryption entirely.
APIClientFunc func() (*client.APIClient, error)
}
// InstallOptions tunes a single Install call.
type InstallOptions struct {
// Force re-runs the pipeline even when an install already exists. Used by
// `sec install --force` for repair / re-pinning to the bundled bootstrap.
Force bool
// Region selects which region's URLs to pick from the manifest. Defaults to
// DefaultRegion ("cn"). Reserved for future brand split.
Region string
// Verbose, when non-nil, is the destination for step-by-step trace output.
// nil = silent (production default); typically set to stderr by `sec install -v`.
Verbose io.Writer
}
// tracef writes one trace line to w if w is non-nil.
func tracef(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[sec install] "+format+"\n", args...)
}
// Install runs the bootstrap pipeline and returns the new State on success.
// If a usable install already exists on disk and Force is false, returns the
// existing state unchanged (no network call).
func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, error) {
v := opts.Verbose
tracef(v, "ensuring sec paths under %s", i.Paths.InstallDir())
if err := i.Paths.Ensure(); err != nil {
return nil, err
}
tracef(v, "loading existing state from %s", i.Paths.StateFile())
existing, err := LoadState(i.Paths.StateFile())
if err != nil {
return nil, fmt.Errorf("load sec state: %w", err)
}
if existing != nil {
tracef(v, "existing state: version=%s binary=%s", existing.Version, existing.BinaryPath)
} else {
tracef(v, "no existing state on disk")
}
// Idempotent short-circuit: nothing to do if an install is already on disk.
// Self-upgrades after bootstrap are lark-sec-cli's job, not ours — see the
// upgrade subsystem in lark-sec-cli/internal/upgrade/.
if !opts.Force && existing != nil && binaryReady(existing.BinaryPath) {
tracef(v, "binary exists at %s — short-circuiting (no network)", existing.BinaryPath)
return existing, nil
}
if opts.Force {
tracef(v, "--force set; running full install pipeline")
} else {
tracef(v, "no usable install on disk; running full install pipeline")
}
region := opts.Region
if region == "" {
region = DefaultRegion
}
tracef(v, "region=%s", region)
if i.APIClientFunc == nil {
return nil, errors.New("sec installer: APIClientFunc is required to fetch remote manifest")
}
tracef(v, "resolving OAPI client (will decrypt credentials)")
apiClient, err := i.APIClientFunc()
if err != nil {
return nil, fmt.Errorf("resolve api client: %w", err)
}
platform, arch, err := CurrentPlatformArch()
if err != nil {
return nil, err
}
tracef(v, "detected platform=%s arch=%s", platform, arch)
tracef(v, "fetching remote manifest from %s", secCliManifestPath)
rm, err := FetchRemoteManifest(ctx, apiClient, region, platform, arch, v)
if err != nil {
return nil, err
}
tracef(v, "manifest returned %d url(s): %v", len(rm.URLs), rm.URLs)
downloadURL := rm.URLs[0]
tracef(v, "picked downloadURL=%s", downloadURL)
version, err := versionFromURL(downloadURL)
if err != nil {
return nil, err
}
tracef(v, "parsed version=%s", version)
versionDir := i.Paths.VersionDir(version)
tracef(v, "creating versionDir=%s", versionDir)
if err := os.MkdirAll(versionDir, 0o755); err != nil {
return nil, err
}
zipPath := filepath.Join(i.Paths.VersionsDir(), version+".zip")
tracef(v, "downloading %s -> %s", downloadURL, zipPath)
if err := Download(ctx, DownloadOptions{
URL: downloadURL,
Destination: zipPath,
HTTPClient: i.HTTPClient,
}); err != nil {
return nil, err
}
if info, statErr := os.Stat(zipPath); statErr == nil {
tracef(v, "downloaded %d bytes", info.Size())
}
defer os.Remove(zipPath) // free disk; we keep the unpacked version dir
tracef(v, "extracting %s -> %s", zipPath, versionDir)
if err := ExtractZip(zipPath, versionDir); err != nil {
return nil, err
}
binaryPath, err := locateBinary(versionDir)
if err != nil {
return nil, err
}
tracef(v, "located binary at %s", binaryPath)
// Ensure executable bit on POSIX — some zips lose it.
if runtime.GOOS != "windows" {
if info, err := os.Stat(binaryPath); err == nil {
_ = os.Chmod(binaryPath, info.Mode()|0o100|0o010|0o001)
}
}
tracef(v, "swapping %s -> %s", i.Paths.CurrentLink(), versionDir)
if err := swapCurrent(i.Paths.CurrentLink(), versionDir); err != nil {
return nil, fmt.Errorf("swap current: %w", err)
}
tracef(v, "writing state.json to %s", i.Paths.StateFile())
state := &State{
Version: version,
InstalledAt: time.Now().UTC(),
BinaryPath: i.Paths.BinaryPath(),
}
if err := SaveState(i.Paths.StateFile(), state); err != nil {
return nil, err
}
return state, nil
}
// locateBinary handles two artifact layouts: flat (zip root has the binary)
// and nested (zip root is a single dir containing the binary). The bootstrap
// manifest's example payload uses nested ("linux-amd64-1.0.1-alpha.23/...");
// we accommodate either since the wrapping dir name could change per build.
func locateBinary(versionDir string) (string, error) {
name := BinaryName()
flat := filepath.Join(versionDir, name)
if _, err := os.Stat(flat); err == nil {
return flat, nil
}
var found string
walkErr := filepath.WalkDir(versionDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && d.Name() == name {
found = path
return fs.SkipAll
}
return nil
})
if walkErr != nil {
return "", walkErr
}
if found == "" {
return "", fmt.Errorf("binary %q not found under %s", name, versionDir)
}
// Promote the binary's parent to be versionDir so "current" → versionDir
// produces a predictable layout. Move the *contents* up rather than the
// binary alone, because shared libs may sit beside it.
parent := filepath.Dir(found)
if parent != versionDir {
entries, err := os.ReadDir(parent)
if err != nil {
return "", err
}
for _, e := range entries {
if err := os.Rename(filepath.Join(parent, e.Name()), filepath.Join(versionDir, e.Name())); err != nil {
return "", err
}
}
_ = os.Remove(parent)
}
return filepath.Join(versionDir, name), nil
}
// swapCurrent atomically points <install>/current at versionDir. On POSIX
// we use a symlink with the standard rename-into-place trick; on Windows we
// fall back to removing the directory and copying, since junctions need
// admin / developer-mode privileges we may not have.
func swapCurrent(link, versionDir string) error {
if runtime.GOOS == "windows" {
// Remove any existing target then copy. This is non-atomic, but
// concurrent installs on the same Windows host are not a use case
// we support — `sec install` runs interactively.
if err := os.RemoveAll(link); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return copyDir(versionDir, link)
}
tmp := link + ".new"
_ = os.Remove(tmp)
if err := os.Symlink(versionDir, tmp); err != nil {
return err
}
return os.Rename(tmp, link)
}
func copyDir(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
in, err := os.Open(p)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
}
func binaryReady(path string) bool {
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View File

@@ -1,139 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"runtime"
)
// Manifest describes a lark-sec-cli release set: one Entry per build platform,
// each carrying one or more region-scoped URL maps keyed by arch. It's what we
// embed at build time as the bootstrap manifest. After bootstrap, lark-sec-cli
// queries its own release source for updates — lark-cli is uninvolved.
type Manifest struct {
Entries []Entry
}
// Entry is one row of the bootstrap manifest, one per published platform.
type Entry struct {
Key int `json:"key"`
BuildPlatform string `json:"buildPlatform"` // "darwin" | "linux" | "win32"
URLs []RegionURLs `json:"urls"`
Branch string `json:"branch"`
Version string `json:"version"`
Extra EntryExtra `json:"extra"`
}
// RegionURLs maps an arch ("amd64", "arm64", "x86") to its download URL,
// scoped to a region ("cn" today; reserved for future brand split).
type RegionURLs struct {
URLs map[string]string `json:"urls"`
Region string `json:"region"`
}
// EntryExtra is metadata the release pipeline emits alongside each artifact.
// PipelineID is the build identifier lark-sec-cli will later forward to its
// own update server when checking for new versions. SHA256 (when present) is
// the hex-encoded hash of the zip artifact; the installer fails the download
// on mismatch. Manifests built before the release pipeline added the field
// leave it empty, in which case integrity falls back to the CDN's own
// Content-MD5 header.
type EntryExtra struct {
PipelineID string `json:"pipeline_id"`
UploadDate int64 `json:"upload_date"`
SHA256 string `json:"sha256,omitempty"`
}
// Artifact is the resolved download target after platform/arch/region selection.
type Artifact struct {
URL string
Version string
BuildID string // pipeline_id — recorded in state.json so lark-sec-cli knows what it was installed at
SHA256 string // hex-encoded; empty when the manifest doesn't carry one
}
// PickArtifact selects the right Entry for the current GOOS/GOARCH and the
// requested region. Returns a clear error explaining which combination was
// missing so users can tell whether the build was never published or just not
// for their platform.
func (m *Manifest) PickArtifact(goos, goarch, region string) (*Artifact, error) {
platform, err := platformKey(goos)
if err != nil {
return nil, err
}
arch, err := archKey(goos, goarch)
if err != nil {
return nil, err
}
for _, e := range m.Entries {
if e.BuildPlatform != platform {
continue
}
for _, ru := range e.URLs {
if ru.Region != region {
continue
}
url, ok := ru.URLs[arch]
if !ok || url == "" {
continue
}
return &Artifact{
URL: url,
Version: e.Version,
BuildID: e.Extra.PipelineID,
SHA256: e.Extra.SHA256,
}, nil
}
}
return nil, fmt.Errorf("no artifact for platform=%s arch=%s region=%s", platform, arch, region)
}
// platformKey maps Go's GOOS to the manifest's buildPlatform enum.
func platformKey(goos string) (string, error) {
switch goos {
case "darwin":
return "darwin", nil
case "linux":
return "linux", nil
case "windows":
return "win32", nil
default:
return "", fmt.Errorf("unsupported GOOS: %s", goos)
}
}
// archKey maps Go's GOARCH to the arch key the manifest uses inside RegionURLs.URLs.
// Windows 32-bit ships under "x86" while POSIX 32-bit (e.g. 386 on linux) is not
// currently published — surface that as an error rather than silently falling back.
func archKey(goos, goarch string) (string, error) {
switch goarch {
case "amd64":
return "amd64", nil
case "arm64":
return "arm64", nil
case "386":
if goos == "windows" {
return "x86", nil
}
return "", fmt.Errorf("32-bit %s is not published", goos)
default:
return "", fmt.Errorf("unsupported GOARCH: %s", goarch)
}
}
// CurrentPlatformArch is a convenience for the install flow.
func CurrentPlatformArch() (platform, arch string, err error) {
platform, err = platformKey(runtime.GOOS)
if err != nil {
return "", "", err
}
arch, err = archKey(runtime.GOOS, runtime.GOARCH)
return platform, arch, err
}
// DefaultRegion is the only region published today for bootstrap installs.
// Kept here for callers that still want a single source of truth.
const DefaultRegion = "cn"

View File

@@ -1,108 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"testing"
)
// sampleManifest is the manifest example baked into bootstrap.json, trimmed to
// the three published platforms. PickArtifact must select the right URL for
// each GOOS/GOARCH combination.
func sampleManifest() *Manifest {
return &Manifest{Entries: []Entry{
{
Key: 0,
BuildPlatform: "linux",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"amd64": "https://cdn/linux-amd64.zip",
"arm64": "https://cdn/linux-arm64.zip",
},
}},
},
{
Key: 1,
BuildPlatform: "win32",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"x86": "https://cdn/win-386.zip",
"amd64": "https://cdn/win-amd64.zip",
},
}},
},
{
Key: 2,
BuildPlatform: "darwin",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"amd64": "https://cdn/darwin-amd64.zip",
"arm64": "https://cdn/darwin-arm64.zip",
},
}},
},
}}
}
func TestPickArtifact_HappyPath(t *testing.T) {
m := sampleManifest()
cases := []struct {
goos, goarch string
wantURL string
}{
{"darwin", "arm64", "https://cdn/darwin-arm64.zip"},
{"darwin", "amd64", "https://cdn/darwin-amd64.zip"},
{"linux", "amd64", "https://cdn/linux-amd64.zip"},
{"linux", "arm64", "https://cdn/linux-arm64.zip"},
{"windows", "amd64", "https://cdn/win-amd64.zip"},
{"windows", "386", "https://cdn/win-386.zip"},
}
for _, c := range cases {
t.Run(c.goos+"/"+c.goarch, func(t *testing.T) {
art, err := m.PickArtifact(c.goos, c.goarch, "cn")
if err != nil {
t.Fatalf("PickArtifact: %v", err)
}
if art.URL != c.wantURL {
t.Errorf("URL = %q, want %q", art.URL, c.wantURL)
}
if art.Version != "1.0.1-alpha.23" {
t.Errorf("Version = %q", art.Version)
}
if art.BuildID != "367354993" {
t.Errorf("BuildID = %q", art.BuildID)
}
})
}
}
func TestPickArtifact_Linux386Rejected(t *testing.T) {
if _, err := sampleManifest().PickArtifact("linux", "386", "cn"); err == nil {
t.Fatal("expected error for linux/386 (not published)")
}
}
func TestPickArtifact_UnknownRegion(t *testing.T) {
if _, err := sampleManifest().PickArtifact("darwin", "arm64", "sg"); err == nil {
t.Fatal("expected error for region=sg (not present in fixture)")
}
}
func TestPickArtifact_UnsupportedOS(t *testing.T) {
if _, err := sampleManifest().PickArtifact("plan9", "amd64", "cn"); err == nil {
t.Fatal("expected error for plan9")
}
}

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sec manages the first-time bootstrap install of the lark-sec-cli
// sidecar from lark-cli's side: download the artifact, lay it out on disk,
// record what version landed. Runtime lifecycle (start / stop / status) is
// handled by shelling out to lark-sec-cli's own `service enable / disable /
// status` commands, so we don't need pid files / env capture / log tees here.
// Updates after install are lark-sec-cli's responsibility, not lark-cli's.
package sec
import (
"errors"
"os"
"path/filepath"
"runtime"
)
const (
// envInstallDirOverride lets tests and power users redirect the entire sec
// tree (install + data) to a single root. When set, install_dir is <root>
// and data_dir is <root>/data — no platform-conventional lookup happens.
envInstallDirOverride = "LARKSUITE_CLI_SEC_DIR"
)
// BinaryName returns the executable basename inside the sec-cli artifact for
// the current platform:
//
// darwin → libLarkEntCli.dylib
// linux → liblarkentcli.so
// windows → lark_enterprise_cli.exe
//
// The .dylib/.so extensions on POSIX are convention only — those files are
// normal Mach-O / ELF executables, not loadable libraries.
func BinaryName() string {
switch runtime.GOOS {
case "darwin":
return "libLarkEntCli.dylib"
case "windows":
return "lark_enterprise_cli.exe"
default:
return "liblarkentcli.so"
}
}
// Paths exposes the filesystem layout for the sec sidecar. All methods return
// absolute paths; nothing on disk is created — callers must call Ensure().
type Paths struct {
install string
data string
}
// DefaultPaths returns Paths rooted at the platform-conventional user data dir,
// or at $LARKSUITE_CLI_SEC_DIR when set.
func DefaultPaths() (*Paths, error) {
if root := os.Getenv(envInstallDirOverride); root != "" {
return &Paths{install: root, data: filepath.Join(root, "data")}, nil
}
install, data, err := platformDirs()
if err != nil {
return nil, err
}
return &Paths{install: install, data: data}, nil
}
// platformDirs returns (install_dir, data_dir) for the current OS, applying
// per-platform conventions:
//
// macOS install = data = ~/Library/Application Support/lark-cli/sec
// Linux install = $XDG_DATA_HOME/lark-cli/sec (fallback ~/.local/share/...)
// data = $XDG_STATE_HOME/lark-cli/sec (fallback ~/.local/state/...)
// Windows install = data = %LOCALAPPDATA%\lark-cli\sec
//
// Linux splits install/data along XDG lines; macOS and Windows colocate them
// because their conventions don't distinguish "share" from "state" at the
// per-user level.
func platformDirs() (install, data string, err error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", err
}
switch runtime.GOOS {
case "darwin":
base := filepath.Join(home, "Library", "Application Support", "lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
case "windows":
appData := os.Getenv("LOCALAPPDATA")
if appData == "" {
return "", "", errors.New("LOCALAPPDATA is not set")
}
base := filepath.Join(appData, "lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
case "linux":
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
dataHome = filepath.Join(home, ".local", "share")
}
stateHome := os.Getenv("XDG_STATE_HOME")
if stateHome == "" {
stateHome = filepath.Join(home, ".local", "state")
}
return filepath.Join(dataHome, "lark-cli", "sec"),
filepath.Join(stateHome, "lark-cli", "sec"),
nil
default:
base := filepath.Join(home, ".lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
}
}
// Ensure creates the directories the installer writes into.
func (p *Paths) Ensure() error {
for _, d := range []string{p.install, p.data, p.VersionsDir()} {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
return nil
}
// InstallDir is the root for binaries and version trees.
func (p *Paths) InstallDir() string { return p.install }
// DataDir is the root for state.json (and anything else lark-cli persists
// about the install — currently just state.json).
func (p *Paths) DataDir() string { return p.data }
// VersionsDir stores each unpacked release: versions/<version>/<files>.
func (p *Paths) VersionsDir() string { return filepath.Join(p.install, "versions") }
// VersionDir is the unpack target for a specific version string.
func (p *Paths) VersionDir(version string) string {
return filepath.Join(p.VersionsDir(), version)
}
// CurrentLink points to the active version (symlink on POSIX, plain copy on Windows).
func (p *Paths) CurrentLink() string { return filepath.Join(p.install, "current") }
// BinaryPath is the active sec-cli executable, addressed through the
// `current` symlink so it stays valid across version swaps.
func (p *Paths) BinaryPath() string {
return filepath.Join(p.CurrentLink(), BinaryName())
}
// StateFile records what version is installed and where its binary lives.
func (p *Paths) StateFile() string { return filepath.Join(p.data, "state.json") }

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDefaultPaths_OverrideViaEnv(t *testing.T) {
dir := t.TempDir()
t.Setenv(envInstallDirOverride, dir)
p, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if p.InstallDir() != dir {
t.Errorf("InstallDir = %q, want %q", p.InstallDir(), dir)
}
if p.DataDir() != filepath.Join(dir, "data") {
t.Errorf("DataDir = %q, want %s/data", p.DataDir(), dir)
}
if !strings.HasPrefix(p.StateFile(), dir) {
t.Errorf("StateFile not under override root: %q", p.StateFile())
}
}
func TestPaths_Ensure(t *testing.T) {
dir := t.TempDir()
t.Setenv(envInstallDirOverride, dir)
p, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if err := p.Ensure(); err != nil {
t.Fatalf("Ensure: %v", err)
}
for _, d := range []string{p.InstallDir(), p.DataDir(), p.VersionsDir()} {
info, err := os.Stat(d)
if err != nil {
t.Errorf("missing %s: %v", d, err)
continue
}
if !info.IsDir() {
t.Errorf("%s is not a directory", d)
}
}
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
// secCliManifestPath is the OAPI endpoint that returns the per-platform
// download URLs for lark-sec-cli, gated by tenant_access_token.
const secCliManifestPath = "/open-apis/security_plugin/v1/sec_cli/manifest"
// xTTEnvEnv, when set, injects an x-tt-env header on the manifest request.
// Used for BOE / sub-environment routing (e.g. value "boe_tns_api"). Unset
// in prod — the gateway treats absence as "no override". This is the only
// debug-routing knob in this file; brand/domain switching itself is handled
// at the network layer via the lark-env.sh Whistle pattern in the
// lark-cli maintainer doc.
const xTTEnvEnv = "LARKSUITE_CLI_X_TT_ENV"
// RemoteManifest is the payload returned by GET /open-apis/security_plugin/v1/sec_cli/manifest
// for a single (region, platform, arch) combination. The server returns only
// the download URLs; version metadata is parsed from the URL itself (see
// versionFromURL).
type RemoteManifest struct {
URLs []string `json:"urls"`
}
// FetchRemoteManifest calls the OAPI manifest endpoint with TAT (bot) auth
// and returns the typed payload for the given region/platform/arch. When the
// LARKSUITE_CLI_X_TT_ENV env var is set, its value is sent as an x-tt-env
// request header for sub-environment routing.
//
// Errors are returned as-is — there is no fallback to the embedded
// bootstrap manifest. Callers that need offline behavior must handle that
// explicitly.
func FetchRemoteManifest(
ctx context.Context,
ac *client.APIClient,
region, platform, arch string,
verbose io.Writer,
) (*RemoteManifest, error) {
req := &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: secCliManifestPath,
QueryParams: larkcore.QueryParams{
"region": []string{region},
"platform": []string{platform},
"arch": []string{arch},
},
}
tracef(verbose, "GET %s?region=%s&platform=%s&arch=%s as=bot", secCliManifestPath, region, platform, arch)
var extraOpts []larkcore.RequestOptionFunc
if v := os.Getenv(xTTEnvEnv); v != "" {
h := http.Header{}
h.Set("x-tt-env", v)
extraOpts = append(extraOpts, larkcore.WithHeaders(h))
tracef(verbose, "injecting header x-tt-env=%s (from %s)", v, xTTEnvEnv)
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsBot, extraOpts...)
if err != nil {
return nil, fmt.Errorf("sec_cli manifest request: %w", err)
}
tracef(verbose, "response status=%d body-len=%d body=%q", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
var env struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *RemoteManifest `json:"data"`
}
if err := json.Unmarshal(resp.RawBody, &env); err != nil {
// Print body unconditionally on decode failure — a malformed response is
// the most common case where the caller needs to see exactly what arrived.
fmt.Fprintf(os.Stderr, "[sec_cli manifest] decode failed; status=%d len=%d body=%q\n", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
return nil, fmt.Errorf("sec_cli manifest decode: %w", err)
}
if env.Code != 0 {
return nil, fmt.Errorf("sec_cli manifest error %d: %s", env.Code, env.Msg)
}
if env.Data == nil || len(env.Data.URLs) == 0 {
return nil, fmt.Errorf("sec_cli manifest: no urls for region=%s platform=%s arch=%s", region, platform, arch)
}
return env.Data, nil
}
// versionFromURL extracts the release version from a download URL of the form
// .../releases/<version>/<pipeline-id>/<platform-arch>/<archive>.zip
// The server-side manifest does not return version as a discrete field;
// state.json's Version needs *something* to disambiguate concurrent installs
// in versions/<version>/, so we parse it out here.
var releaseVersionRE = regexp.MustCompile(`/releases/([^/]+)/`)
func versionFromURL(u string) (string, error) {
m := releaseVersionRE.FindStringSubmatch(u)
if len(m) < 2 || m[1] == "" {
return "", fmt.Errorf("could not parse release version from URL %q", u)
}
return m[1], nil
}

View File

@@ -1,79 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
)
// State is the JSON document at <data>/state.json describing the currently
// installed lark-sec-cli artifact. It is the source of truth for what binary
// to launch. After bootstrap install lark-sec-cli may upgrade itself in
// place — when that happens this state file is informational only; the
// daemon owns its own canonical version state.
type State struct {
Version string `json:"version"`
BuildID string `json:"build_id"`
InstalledAt time.Time `json:"installed_at"`
BinaryPath string `json:"binary_path"`
}
// LoadState reads state.json. Returns (nil, nil) when the file is absent —
// callers treat that as "not yet installed". Decode errors are surfaced
// so a corrupt file is never silently overwritten.
func LoadState(path string) (*State, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &s, nil
}
// SaveState writes state.json atomically: a tmpfile next to the target is
// fsynced then renamed in, so concurrent readers either see the previous
// state or the new one — never a torn write.
func SaveState(path string, s *State) error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(dirOf(path), ".state-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op after a successful Rename
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
return path[:i]
}
}
return "."
}

View File

@@ -1,48 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"path/filepath"
"testing"
"time"
)
func TestSaveLoadState_Roundtrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
in := &State{
Version: "1.2.3",
BuildID: "build-42",
InstalledAt: time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC),
BinaryPath: "/tmp/lark-sec-cli",
}
if err := SaveState(path, in); err != nil {
t.Fatalf("SaveState: %v", err)
}
got, err := LoadState(path)
if err != nil {
t.Fatalf("LoadState: %v", err)
}
if got == nil {
t.Fatal("LoadState returned nil")
}
if got.Version != in.Version || got.BuildID != in.BuildID || got.BinaryPath != in.BinaryPath {
t.Errorf("roundtrip mismatch: got=%+v want=%+v", got, in)
}
if !got.InstalledAt.Equal(in.InstalledAt) {
t.Errorf("InstalledAt mismatch: got=%v want=%v", got.InstalledAt, in.InstalledAt)
}
}
func TestLoadState_AbsentFile(t *testing.T) {
got, err := LoadState(filepath.Join(t.TempDir(), "missing.json"))
if err != nil {
t.Fatalf("expected nil error for missing file, got %v", err)
}
if got != nil {
t.Errorf("expected nil state for missing file, got %+v", got)
}
}

View File

@@ -1,135 +0,0 @@
# secplugin Usage Guide
Chinese version: see `README.zh-CN.md`.
`secplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S)
requests to go through a local security proxy and can optionally trust an
additional CA certificate bundle.
It supports two configuration methods:
1. `sec_config.json`
2. `LARKSUITE_CLI_SEC_*` environment variables
## Config File Location
Default config file path:
```text
~/.lark-cli/sec_config.json
```
If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes:
```text
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
```
## Option 1: Config File
Put the following content into `sec_config.json`:
```json
{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
"LARKSUITE_CLI_SEC_AUTH": true,
"LARKSUITE_CLI_APP_ID": "cli_xxx",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_DEFAULT_AS": "bot",
"LARKSUITE_CLI_STRICT_MODE": "bot"
}
```
Field descriptions:
- `LARKSUITE_CLI_SEC_ENABLE`: Enables secplugin. Boolean values are supported.
- `LARKSUITE_CLI_SEC_PROXY`: Local HTTP proxy address. It must be `http://127.0.0.1:<port>`.
- `LARKSUITE_CLI_SEC_CA`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed.
- `LARKSUITE_CLI_SEC_AUTH`: Enables proxy-injected token mode.
- `LARKSUITE_CLI_APP_ID`: Optional app ID used in `SEC_AUTH` mode.
- `LARKSUITE_CLI_BRAND`: Optional, must be `feishu` or `lark`.
- `LARKSUITE_CLI_DEFAULT_AS`: Optional, must be `user`, `bot`, or `auto`.
- `LARKSUITE_CLI_STRICT_MODE`: Optional, must be `user`, `bot`, or `off`.
## Option 2: Environment Variables
You can also enable secplugin directly with environment variables without
creating `sec_config.json`:
```bash
export LARKSUITE_CLI_SEC_ENABLE=true
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
export LARKSUITE_CLI_SEC_AUTH=true
```
If you want to provide app metadata in `SEC_AUTH` mode, set these as well:
```bash
export LARKSUITE_CLI_APP_ID=cli_xxx
export LARKSUITE_CLI_BRAND=feishu
export LARKSUITE_CLI_DEFAULT_AS=bot
export LARKSUITE_CLI_STRICT_MODE=bot
```
## Precedence
The following environment variables override the corresponding fields in
`sec_config.json` when they are present:
- `LARKSUITE_CLI_SEC_ENABLE`
- `LARKSUITE_CLI_SEC_PROXY`
- `LARKSUITE_CLI_SEC_CA`
- `LARKSUITE_CLI_SEC_AUTH`
- `LARKSUITE_CLI_APP_ID`
- `LARKSUITE_CLI_BRAND`
- `LARKSUITE_CLI_DEFAULT_AS`
- `LARKSUITE_CLI_STRICT_MODE`
This means:
- Put stable defaults in `sec_config.json`.
- Use environment variables for temporary overrides.
- SEC-related environment variables can work even without a config file.
## SEC_AUTH Mode
The CLI enters `SEC_AUTH` mode when both of the following are true:
```text
LARKSUITE_CLI_SEC_ENABLE=true
LARKSUITE_CLI_SEC_AUTH=true
```
In this mode, the CLI does not read real tokens directly. Instead, it returns
placeholder tokens and expects the proxy to replace them with real credentials.
App information is resolved in this order:
1. `LARKSUITE_CLI_APP_ID` and `LARKSUITE_CLI_BRAND` from environment variables
2. The same fields in `sec_config.json`
3. The active profile in the regular CLI `config.json`
If no valid app information can be resolved from any source, the command fails.
## Constraints
- `LARKSUITE_CLI_SEC_PROXY` must use the `http` scheme only.
- The host of `LARKSUITE_CLI_SEC_PROXY` must be `127.0.0.1`.
- `LARKSUITE_CLI_SEC_PROXY` must not contain a path.
- `LARKSUITE_CLI_SEC_CA` must be an absolute path to a PEM file.
- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`.
## Recommendations
For long-term stable setup, prefer `sec_config.json`:
- Good for developer machines or controlled environments.
- Avoids repeatedly injecting environment variables into the shell.
For temporary debugging, prefer environment variables:
- Good for switching proxy or CA for just one session.
- No need to modify files on disk.

View File

@@ -1,130 +0,0 @@
# secplugin 使用说明
English version: see `README.md`.
`secplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。
支持两种配置方式:
1. `sec_config.json`
2. `LARKSUITE_CLI_SEC_*` 环境变量
## 配置文件位置
默认配置文件路径:
```text
~/.lark-cli/sec_config.json
```
如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为:
```text
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
```
## 方式一:使用配置文件
`sec_config.json` 中写入:
```json
{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
"LARKSUITE_CLI_SEC_AUTH": true,
"LARKSUITE_CLI_APP_ID": "cli_xxx",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_DEFAULT_AS": "bot",
"LARKSUITE_CLI_STRICT_MODE": "bot"
}
```
字段说明:
- `LARKSUITE_CLI_SEC_ENABLE`: 是否启用 secplugin支持布尔值。
- `LARKSUITE_CLI_SEC_PROXY`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:<port>`
- `LARKSUITE_CLI_SEC_CA`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。
- `LARKSUITE_CLI_SEC_AUTH`: 是否启用代理注入 token 模式。
- `LARKSUITE_CLI_APP_ID`: 可选,`SEC_AUTH` 模式下使用的应用 ID。
- `LARKSUITE_CLI_BRAND`: 可选,取值为 `feishu``lark`
- `LARKSUITE_CLI_DEFAULT_AS`: 可选,取值为 `user``bot``auto`
- `LARKSUITE_CLI_STRICT_MODE`: 可选,取值为 `user``bot``off`
## 方式二:使用环境变量
也可以不写 `sec_config.json`,直接通过环境变量启用:
```bash
export LARKSUITE_CLI_SEC_ENABLE=true
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
export LARKSUITE_CLI_SEC_AUTH=true
```
如果你在 `SEC_AUTH` 模式下希望同时提供应用信息,也可以继续设置:
```bash
export LARKSUITE_CLI_APP_ID=cli_xxx
export LARKSUITE_CLI_BRAND=feishu
export LARKSUITE_CLI_DEFAULT_AS=bot
export LARKSUITE_CLI_STRICT_MODE=bot
```
## 配置优先级
以下环境变量存在时,会覆盖 `sec_config.json` 中对应字段:
- `LARKSUITE_CLI_SEC_ENABLE`
- `LARKSUITE_CLI_SEC_PROXY`
- `LARKSUITE_CLI_SEC_CA`
- `LARKSUITE_CLI_SEC_AUTH`
- `LARKSUITE_CLI_APP_ID`
- `LARKSUITE_CLI_BRAND`
- `LARKSUITE_CLI_DEFAULT_AS`
- `LARKSUITE_CLI_STRICT_MODE`
也就是说:
- 你可以把默认值写进 `sec_config.json`
- 再用环境变量做临时覆盖。
- 如果没有配置文件,但设置了 SEC 相关环境变量,也可以正常工作。
## SEC_AUTH 模式说明
当同时满足以下条件时CLI 会进入 `SEC_AUTH` 模式:
```text
LARKSUITE_CLI_SEC_ENABLE=true
LARKSUITE_CLI_SEC_AUTH=true
```
此时 CLI 不直接读取真实 token而是返回占位 token由代理替换成真实凭证。
应用信息来源优先级如下:
1. 环境变量中的 `LARKSUITE_CLI_APP_ID``LARKSUITE_CLI_BRAND`
2. `sec_config.json` 中的同名字段
3. 常规 CLI 配置文件 `config.json` 的当前 profile
如果以上来源都拿不到可用应用信息,命令会报错。
## 参数约束
- `LARKSUITE_CLI_SEC_PROXY` 只允许 `http` 协议。
- `LARKSUITE_CLI_SEC_PROXY` 的 host 必须是 `127.0.0.1`
- `LARKSUITE_CLI_SEC_PROXY` 不能带路径。
- `LARKSUITE_CLI_SEC_CA` 必须是 PEM 文件的绝对路径。
- 布尔值支持 `true/false``1/0``on/off``yes/no``y/n`
## 推荐用法
长期固定配置建议使用 `sec_config.json`
- 适合开发机或受控环境的稳定配置。
- 避免在 shell 中反复注入环境变量。
临时调试建议使用环境变量:
- 适合本次会话临时切换代理或证书。
- 不需要修改磁盘上的配置文件。

View File

@@ -1,277 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package secplugin implements the ~/.lark-cli/sec_config.json based security proxy plugin mode.
//
// It supports:
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
// - optional "proxy injects token" mode via placeholder tokens (SEC_AUTH)
//
// In sec plugin mode, certain common CLI env vars (APP_ID / BRAND / DEFAULT_AS /
// STRICT_MODE) can also be set in sec_config.json so sandboxes can avoid
// environment injection. When both are present, environment variables win.
package secplugin
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// SEC plugin constants cover the config file name and placeholder token values.
const (
// ConfigFileName is the fixed config file name under core.GetConfigDir().
ConfigFileName = "sec_config.json"
// SentinelUAT is the placeholder user access token used in SEC_AUTH mode.
SentinelUAT = "secplugin-managed-uat"
// SentinelTAT is the placeholder tenant access token used in SEC_AUTH mode.
SentinelTAT = "secplugin-managed-tat"
)
// Config is the on-disk config format. Keys intentionally mirror env var names.
type Config struct {
// Enable turns on sec plugin transport handling.
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
// Proxy is the fixed HTTP proxy address used for all outbound requests.
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
CAPath string `json:"LARKSUITE_CLI_SEC_CA"`
// Auth enables placeholder-token mode for proxy-side credential injection.
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
// Optional defaults for sec plugin mode; env vars override these.
// AppID supplies the app ID when the environment does not set one.
AppID string `json:"LARKSUITE_CLI_APP_ID,omitempty"`
// Brand supplies the tenant brand when the environment does not set one.
Brand string `json:"LARKSUITE_CLI_BRAND,omitempty"` // feishu | lark
// DefaultAs supplies the default identity when the environment does not set one.
DefaultAs string `json:"LARKSUITE_CLI_DEFAULT_AS,omitempty"` // user | bot | auto
// StrictMode supplies the strict mode when the environment does not set one.
StrictMode string `json:"LARKSUITE_CLI_STRICT_MODE,omitempty"` // user | bot | off
}
// Path returns the absolute path to the sec plugin config file.
func Path() string {
return filepath.Join(core.GetConfigDir(), ConfigFileName)
}
// loadOnce guards one-time SEC config loading for process-wide transport reuse.
var loadOnce sync.Once
// loadCfg stores the cached SEC config after the first successful Load call.
var loadCfg *Config
// loadErr stores the cached Load error observed during the first load attempt.
var loadErr error
// Load reads ~/.lark-cli/sec_config.json once and caches the parsed result.
// Environment variables (CliSec*) take precedence over config file values.
//
// Returns (nil, nil) only when:
// - the config file does not exist AND
// - none of the SEC-related env vars are present.
func Load() (*Config, error) {
loadOnce.Do(func() {
// Start from env-only config if any SEC env var is present.
cfg, hasEnv, err := loadFromEnv()
if err != nil {
loadErr = err
return
}
p := Path()
if _, err := vfs.Stat(p); err != nil {
if errors.Is(err, os.ErrNotExist) {
// No file: return env-only config (if any), else nil.
if hasEnv {
loadCfg = cfg
} else {
loadCfg = nil
}
loadErr = nil
return
}
loadErr = fmt.Errorf("failed to stat sec plugin config %q: %w", p, err)
return
}
b, err := vfs.ReadFile(p)
if err != nil {
loadErr = fmt.Errorf("failed to read sec plugin config %q: %w", p, err)
return
}
var fileCfg Config
if err := json.Unmarshal(b, &fileCfg); err != nil {
loadErr = fmt.Errorf("invalid sec plugin config %q: %w", p, err)
return
}
// Merge: file base + env overrides.
if cfg == nil {
cfg = &fileCfg
} else {
*cfg = fileCfg
applyEnvOverrides(cfg)
}
loadCfg = cfg
})
return loadCfg, loadErr
}
// Enabled reports whether SEC plugin mode is enabled.
func (c *Config) Enabled() bool { return c != nil && c.Enable }
// AuthEnabled reports whether SEC_AUTH token placeholder mode is enabled.
func (c *Config) AuthEnabled() bool { return c != nil && c.Enable && c.Auth }
// loadFromEnv builds a config from SEC-related environment variables only.
// It reports whether any SEC-related environment variable was present.
func loadFromEnv() (*Config, bool, error) {
_, hasEnable := os.LookupEnv(envvars.CliSecEnable)
_, hasProxy := os.LookupEnv(envvars.CliSecProxy)
_, hasCA := os.LookupEnv(envvars.CliSecCA)
_, hasAuth := os.LookupEnv(envvars.CliSecAuth)
hasAny := hasEnable || hasProxy || hasCA || hasAuth
if !hasAny {
return nil, false, nil
}
cfg := &Config{}
if err := applyEnvOverrides(cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
// applyEnvOverrides copies SEC-related environment variable values into cfg.
func applyEnvOverrides(cfg *Config) error {
if v, ok := os.LookupEnv(envvars.CliSecEnable); ok {
b, err := parseBoolEnv(envvars.CliSecEnable, v)
if err != nil {
return err
}
cfg.Enable = b
}
if v, ok := os.LookupEnv(envvars.CliSecAuth); ok {
b, err := parseBoolEnv(envvars.CliSecAuth, v)
if err != nil {
return err
}
cfg.Auth = b
}
if v, ok := os.LookupEnv(envvars.CliSecProxy); ok {
cfg.Proxy = v
}
if v, ok := os.LookupEnv(envvars.CliSecCA); ok {
cfg.CAPath = v
}
return nil
}
// parseBoolEnv accepts common boolean spellings used in environment variables.
func parseBoolEnv(name, raw string) (bool, error) {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
// Treat empty as false when explicitly present.
return false, nil
}
switch s {
case "1", "true", "on", "yes", "y":
return true, nil
case "0", "false", "off", "no", "n":
return false, nil
}
if b, err := strconv.ParseBool(s); err == nil {
return b, nil
}
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
}
// proxyURL validates the fixed SEC proxy configuration and returns its URL.
func (c *Config) proxyURL() (*url.URL, error) {
raw := strings.TrimSpace(c.Proxy)
if raw == "" {
return nil, fmt.Errorf("%s is empty", envvars.CliSecProxy)
}
redacted := redactProxyURL(raw)
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliSecProxy, redacted, err)
}
if u.Scheme != "http" {
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliSecProxy, redacted)
}
if u.Host == "" {
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliSecProxy, redacted)
}
// Security hardening: only allow a loopback proxy. This prevents accidental
// cross-machine proxying of credentials/traffic.
if u.Hostname() != "127.0.0.1" {
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliSecProxy, redacted)
}
if u.Port() == "" {
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliSecProxy, redacted)
}
if u.Path != "" && u.Path != "/" {
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliSecProxy, redacted)
}
if u.RawQuery != "" {
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliSecProxy, redacted)
}
if u.Fragment != "" {
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliSecProxy, redacted)
}
return u, nil
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
func redactProxyURL(raw string) string {
u, err := url.Parse(raw)
if err == nil && u.User != nil {
u.User = url.User("***")
return u.String()
}
// Fallback: handle "user:pass@proxy:8080"
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// ApplyToTransport clones base and applies SEC plugin settings to the clone.
// Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
if base == nil {
base = http.DefaultTransport.(*http.Transport)
}
u, err := c.proxyURL()
if err != nil {
return nil, err
}
t := base.Clone()
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
if err := applyExtraRootCA(t, c.CAPath); err != nil {
return nil, err
}
return t, nil
}

View File

@@ -1,245 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
func unsetSecPluginEnv(t *testing.T) {
t.Helper()
unsetEnv(t, envvars.CliSecEnable)
unsetEnv(t, envvars.CliSecProxy)
unsetEnv(t, envvars.CliSecCA)
unsetEnv(t, envvars.CliSecAuth)
}
// writeFile creates parent directories and writes test data for fixtures.
func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, data, perm); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file
// or SEC environment overrides exist.
func TestLoad_MissingFileReturnsNil(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
// TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr
// because multiple tests in this package share the package-level Load()
// cache via sync.Once.
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg != nil {
t.Fatalf("Load() = %#v, want nil (missing file)", cfg)
}
}
// TestApplyToTransport_SetsProxy verifies that a valid SEC config installs a fixed proxy.
func TestApplyToTransport_SetsProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
base := http.DefaultTransport.(*http.Transport)
tr, err := cfg.ApplyToTransport(base)
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
if tr.Proxy == nil {
t.Fatal("Proxy func is nil, want fixed proxy")
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:3128" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
}
}
// TestLoad_RejectsNonLoopbackProxy verifies that SEC mode rejects non-loopback proxies.
func TestLoad_RejectsNonLoopbackProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://10.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
_, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err == nil {
t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error")
}
}
// TestConfig_ProxyURLRejectsUnsupportedParts verifies the SEC proxy validator
// rejects URLs with missing ports, queries, and fragments.
func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{
name: "missing explicit port",
raw: "http://127.0.0.1",
want: "explicit port is required",
},
{
name: "query string",
raw: "http://127.0.0.1:3128?foo=bar",
want: "query is not allowed",
},
{
name: "fragment",
raw: "http://127.0.0.1:3128#frag",
want: "fragment is not allowed",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
_, err := (&Config{Proxy: tt.raw}).proxyURL()
if err == nil {
t.Fatalf("proxyURL() error = nil, want substring %q", tt.want)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want)
}
})
}
}
// TestLoad_EnvOnlyConfig verifies that SEC settings can come entirely from environment variables.
func TestLoad_EnvOnlyConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
t.Setenv(envvars.CliSecEnable, "true")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:7777")
t.Setenv(envvars.CliSecCA, "")
t.Setenv(envvars.CliSecAuth, "true")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
if !cfg.AuthEnabled() {
t.Fatalf("cfg.AuthEnabled() = false, want true")
}
tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:7777" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u)
}
}
// TestLoad_EnvOverridesFile verifies that SEC environment variables override file values.
func TestLoad_EnvOverridesFile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
// File enables with one proxy.
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
// Env overrides: disable + different proxy (should be irrelevant once disabled).
t.Setenv(envvars.CliSecEnable, "false")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:9999")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil {
t.Fatalf("Load() = nil, want non-nil (file exists)")
}
if cfg.Enabled() {
t.Fatalf("cfg.Enabled() = true, want false (env override)")
}
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// applyExtraRootCA augments t with an additional PEM bundle used for SEC proxy
// TLS interception.
func applyExtraRootCA(t *http.Transport, caPath string) error {
caPath = strings.TrimSpace(caPath)
if caPath == "" {
return nil
}
if !filepath.IsAbs(caPath) {
return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliSecCA, caPath)
}
pemBytes, err := vfs.ReadFile(caPath)
if err != nil {
return fmt.Errorf("failed to read %s %q: %w", envvars.CliSecCA, caPath, err)
}
// Start from system pool when possible; if unavailable, create a new pool.
pool, _ := x509.SystemCertPool()
if pool == nil {
pool = x509.NewCertPool()
}
if ok := pool.AppendCertsFromPEM(pemBytes); !ok {
return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliSecCA, caPath)
}
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{}
} else {
// Clone to avoid mutating shared config from the base transport.
t.TLSClientConfig = t.TLSClientConfig.Clone()
}
t.TLSClientConfig.MinVersion = tls.VersionTLS12
t.TLSClientConfig.RootCAs = pool
return nil
}

View File

@@ -1,138 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
)
// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests.
func mustCreateTestCertPEM(t *testing.T) []byte {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "secplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "secplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate() error = %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged.
func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) {
tr := &http.Transport{}
if err := applyExtraRootCA(tr, " "); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig != nil {
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
}
}
// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute.
func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, "ca.pem")
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err)
}
}
// TestApplyExtraRootCA_RejectsMissingFile verifies read errors for missing PEM bundles.
func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem"))
if err == nil || !strings.Contains(err.Error(), "failed to read") {
t.Fatalf("applyExtraRootCA() error = %v, want read error", err)
}
}
// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles.
func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "invalid.pem")
writeFile(t, caPath, []byte("not a pem"), 0600)
tr := &http.Transport{}
err := applyExtraRootCA(tr, caPath)
if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") {
t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err)
}
}
// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent.
func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
tr := &http.Transport{}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == nil {
t.Fatal("TLSClientConfig = nil, want initialized config")
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}
// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings.
func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
original := &tls.Config{ServerName: "open.feishu.cn"}
tr := &http.Transport{TLSClientConfig: original}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == original {
t.Fatal("TLSClientConfig pointer reused, want clone")
}
if tr.TLSClientConfig.ServerName != original.ServerName {
t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName)
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}

View File

@@ -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()

View File

@@ -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])
}
}

View File

@@ -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,
})
}

View File

@@ -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")

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -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")
}
}

View 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{}
}
}

View 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)
}
}

View 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
}

View 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)
}
}

View File

@@ -11,11 +11,8 @@ import (
"os"
"strings"
"sync"
"github.com/larksuite/cli/internal/secplugin"
)
// Proxy environment constants control shared transport proxy behavior.
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
@@ -39,7 +36,6 @@ func DetectProxyEnv() (key, value string) {
return "", ""
}
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
var proxyWarningOnce sync.Once
// redactProxyURL masks userinfo (username:password) in a proxy URL.
@@ -88,31 +84,6 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
return t
})
// secProxyTransport is a fixed-proxy clone of http.DefaultTransport (with optional
// custom root CA), lazily built on first use when sec plugin mode is enabled.
var secProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
cfg, err := secplugin.Load()
if err != nil || cfg == nil || !cfg.Enabled() {
return def
}
t, err := cfg.ApplyToTransport(def)
if err != nil {
// Fail closed: do not silently fall back to direct egress when the
// operator explicitly enabled SEC plugin mode.
blocked := def.Clone()
blocked.Proxy = func(*http.Request) (*url.URL, error) {
return nil, fmt.Errorf("sec plugin enabled but config is invalid: %v", err)
}
return blocked
}
return t
})
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
//
// By default it returns http.DefaultTransport — the stdlib-provided
@@ -128,23 +99,6 @@ var secProxyTransport = sync.OnceValue(func() *http.Transport {
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
// (~90s) fires.
func SharedTransport() http.RoundTripper {
// SEC plugin mode overrides all other proxy behavior (env proxies and
// LARK_CLI_NO_PROXY), per operator intent.
if cfg, err := secplugin.Load(); err != nil {
// Fail closed: if the config file exists but is malformed/unreadable,
// do not silently fall back to direct egress.
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return http.DefaultTransport
}
blocked := def.Clone()
blocked.Proxy = func(*http.Request) (*url.URL, error) {
return nil, fmt.Errorf("sec plugin config is invalid: %v", err)
}
return blocked
} else if cfg != nil && cfg.Enabled() {
return secProxyTransport()
}
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}

View File

@@ -6,43 +6,11 @@ package util
import (
"bytes"
"net/http"
"os"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
func unsetSecPluginEnv(t *testing.T) {
t.Helper()
// Ensure developer machine env doesn't accidentally enable SEC plugin mode
// and change expectations for SharedTransport().
unsetEnv(t, envvars.CliSecEnable)
unsetEnv(t, envvars.CliSecProxy)
unsetEnv(t, envvars.CliSecCA)
unsetEnv(t, envvars.CliSecAuth)
}
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
func TestDetectProxyEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Clear all proxy env vars first
for _, k := range proxyEnvKeys {
t.Setenv(k, "")
@@ -60,10 +28,7 @@ func TestDetectProxyEnv(t *testing.T) {
}
}
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "")
tr := SharedTransport()
if tr != http.DefaultTransport {
@@ -71,10 +36,7 @@ func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
}
}
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
tr := SharedTransport()
if tr == http.DefaultTransport {
@@ -89,10 +51,7 @@ func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
}
}
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
a := SharedTransport()
b := SharedTransport()
@@ -101,10 +60,7 @@ func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
}
}
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
@@ -121,10 +77,7 @@ func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
}
}
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
@@ -137,10 +90,7 @@ func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
}
}
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
func TestWarnIfProxied_WithProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Reset the once guard for this test
proxyWarningOnce = sync.Once{}
@@ -161,10 +111,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
}
}
// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings.
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
for _, k := range proxyEnvKeys {
@@ -179,10 +126,7 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
}
}
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
@@ -196,10 +140,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
}
}
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTP_PROXY", "http://proxy:1234")
@@ -219,10 +160,7 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
}
}
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
func TestRedactProxyURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
tests := []struct {
input string
want string
@@ -245,10 +183,7 @@ func TestRedactProxyURL(t *testing.T) {
}
}
// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials.
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")

View File

@@ -9,8 +9,7 @@ import (
"github.com/larksuite/cli/cmd"
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
_ "github.com/larksuite/cli/extension/credential/secplugin" // activate sec plugin credential provider (SEC_AUTH placeholder tokens)
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
)
func main() {

View File

@@ -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);
}
}

View 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
},
}

View 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

View File

@@ -31,10 +31,17 @@ import (
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",
@@ -278,7 +285,10 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
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())
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{
ParentType: baseAttachmentParentType,
ParentNode: runtime.Str("base-token"),
})
if err != nil {
return err
}
@@ -459,31 +469,33 @@ func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValu
return attachments, nil
}
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 {

View File

@@ -70,10 +70,12 @@ func Shortcuts() []common.Shortcut {
BaseFormsList,
BaseFormUpdate,
BaseFormGet,
BaseFormDetail,
BaseFormQuestionsCreate,
BaseFormQuestionsDelete,
BaseFormQuestionsUpdate,
BaseFormQuestionsList,
BaseFormSubmit,
BaseDashboardList,
BaseDashboardGet,
BaseDashboardCreate,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 != "" {

View File

@@ -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

View 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
},
}

View 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())
}
}

View File

@@ -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{

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownDiff,
MarkdownFetch,
MarkdownPatch,
MarkdownOverwrite,

View File

@@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
for _, path := range [][]string{
{"markdown", "+create"},
{"markdown", "+diff"},
{"markdown", "+fetch"},
{"markdown", "+overwrite"},
} {

View File

@@ -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 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。

View File

@@ -1,7 +1,5 @@
## 快速决策
- 按标题或关键词找云空间里的表格文件,先用 `lark-cli docs +search`
- `docs +search` 会直接返回 `SHEET` 结果,不要把它误解成只能搜文档 / Wiki。
- 已知 spreadsheet URL / token 后,再进入 `sheets +info``sheets +read``sheets +find` 等对象内部操作。
## 核心概念

View File

@@ -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 标识信息 |
@@ -188,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`;更新或删除前先确认题目目标 |
@@ -254,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 命令 |
@@ -341,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 / 系统字段自动产出 |

View 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 填写提交

View File

@@ -0,0 +1,171 @@
# base +form-submit
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
通过表单分享链接填写并提交多维表格表单。仅支持分享模式share_token支持填写普通字段值和上传本地文件作为附件。
## 填写前必读:先获取表单详情
**在调用 `+form-submit` 之前,必须先使用 [`+form-detail`](lark-base-form-detail.md) 获取表单详情。** 原因如下:
1. **字段类型匹配**:每个题目的 `type` 决定了值的格式(文本、数字、选项、人员、日期等),需根据类型正确构造 `fields` 中的值
2. **必填校验**:通过 `questions[].required` 判断哪些题目为必填项,避免遗漏
3. **显示条件过滤**:部分题目带有 `filter`(显示/隐藏逻辑),需根据用户已填的其他题目值判断该题目是否应该出现——**不应填写被 filter 隐藏的题目**
4. **获取 base_token附件场景必用**`+form-detail` 返回的 `data.base_token` 是该表单所属的多维表格标识。当表单包含附件字段时,提交时必须通过 `--base-token` 传入此值,因为附件需要上传到该 Base 的 Drive Media 中
典型流程:
```bash
# 1⃣ 先获取表单详情,了解所有题目
lark-cli base +form-detail --share-token <share_token>
# 2⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
# 3⃣ 再提交
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}'
```
详见 [`lark-base-form-detail.md`](lark-base-form-detail.md) 中的「questions 结构说明」和「filter 结构说明」。
## 命令
```bash
# 基本提交(填写普通字段)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
# 带附件提交(需要额外提供 --base-token
lark-cli base +form-submit \
--share-token <share_token> \
--base-token <base_token> \
--json '{
"fields": {"服务评分": 5, "评价内容": "好"},
"attachments": {
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}'
# 使用应用身份bot
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--as bot
# 预览 API 调用(不实际执行)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--share-token <token>` | 是 | 表单分享 Token必填从表单分享链接中提取 |
| `--base-token <token>` | 条件必填 | Base token**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
### --json 结构说明
`--json` 是一个 JSON 对象,包含两个部分:
#### fields普通字段
`fields` 中的单元格值写法与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 完全对齐,填写前应先阅读该文档了解各类型的构造规则:
```json
{
"文本字段": "Hello World",
"电话字段": "13800000000",
"超链接字段": "https://example.com",
"数字字段": 12.5,
"单选字段": "选项A",
"多选字段": ["选项A", "选项B"],
"时间字段": "2026-04-27 14:30:00",
"复选框字段": true,
"人员字段": [{ "id": "ou_7094d131420c8749632145f08fbf114a" }],
"关联字段": [{ "id": "recXXXXXXXXXXXX" }],
"地理位置字段": { "lng": 116.397428, "lat": 39.90923 }
}
```
> **注意:附件类型字段不要写在 `fields` 里。** `fields` 中不包含附件附件有独立的填写方式见下方「attachments附件上传」章节。
> 自动编号、公式、创建/修改人、创建/修改时间等系统字段会自动填入,无需手动传入。
#### attachments附件上传
**附件字段的填写方式与 `fields` 中的普通单元格完全不同**,不能在 `fields` 里传 `file_token` 或其他附件格式。必须将附件字段单独放在 `--json` 的顶层 `attachments` 对象中,值为**本地文件路径数组**(不是 token
```json
{
"attachments": {
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}
```
CLI 收到路径后会自动完成以下流程:
1. 校验所有文件(存在性、大小 ≤2GB、常规文件
2. 并行上传到 Base Drive Media并发上限 5跨字段重复路径自动去重
3. 获取 `file_token` 后合并到最终表单提交内容中
> 与 [`lark-base-cell-value.md`](lark-base-cell-value.md) 中 Record 场景的附件写法不同Record 写入时附件走独立的 `+record-upload-attachment` 命令;而 `+form-submit` 只需在 `attachments` 中传本地路径,上传由 CLI 内部自动完成。
### 从分享链接提取 share-token
用户提供形如以下格式的表单分享链接时:
```
https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```
**提取方式:** 取 URL 路径最后一段作为 `--share-token`
以上述链接为例:
- `share-token` = `shrbcvST8eZy0vk8zjVZ1CAXNye`
```bash
lark-cli base +form-submit \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
--json '{"fields":{...}}'
```
## 输出格式
| 字段 | 类型 | 说明 |
|------|------|------|
| `can_submit_again` | bool | 是否可以再次填写 |
```json
{
"ok": true,
"data": {
"can_submit_again": true
}
}
```
## 提示
- 本命令仅支持通过表单分享链接share_token提交不支持通过 base_token + table_id + view_id 方式提交
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
- 附件字段只需在 `--json.attachments` 中提供本地路径即可CLI 自动完成校验、并行上传、Token 获取和合并写入
- 限流:单应用 20 QPS单用户 5 QPS
- 权限要求:`base:form:update`;使用 attachments 时还需 `docs:document.media:upload`
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-base-form](references/lark-base-form.md) — 表单管理总览

View File

@@ -9,7 +9,8 @@ form 相关命令索引。
| 文档 | 命令 | 说明 |
|------|------|------|
| [lark-base-form-list.md](lark-base-form-list.md) | `+form-list` | 分页列出表单 |
| [lark-base-form-get.md](lark-base-form-get.md) | `+form-get` | 获取表单详情 |
| [lark-base-form-get.md](lark-base-form-get.md) | `+form-get` | 获取表单详情Base 内部路径) |
| [lark-base-form-detail.md](lark-base-form-detail.md) | `+form-detail` | 通过分享链接获取表单详情(含题目列表) |
| [lark-base-form-create.md](lark-base-form-create.md) | `+form-create` | 创建表单 |
| [lark-base-form-update.md](lark-base-form-update.md) | `+form-update` | 更新表单 |
| [lark-base-form-delete.md](lark-base-form-delete.md) | `+form-delete` | 删除表单 |

View File

@@ -38,8 +38,6 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户说"找一个表格""按名称搜电子表格""找报表""最近打开的表格""最近我编辑过的 xxx" → 直接用 `lark-cli drive +search`(参考 [`lark-drive`](../lark-drive/references/lark-drive-search.md))。**老的 `docs +search` 已进入维护期、后续会下线,不要再新增依赖。**
- `drive +search` 结果里会直接返回 `SHEET` / `Base` / `FOLDER` 等云空间对象,是资源发现的统一入口
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 `<sheet>``<bitable>``<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
@@ -52,18 +50,16 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
**补充:** 云空间资源发现统一走 [`drive +search`](../lark-drive/references/lark-drive-search.md);当用户口头说"表格/报表/最近我编辑过的 xxx"时,也优先从 `drive +search` 开始。老的 `docs +search` 只在沿用 `--filter` JSON 的存量脚本里保留,后续会下线。
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-doc-search.md) | ⚠️ **Deprecated — use [`drive +search`](../lark-drive/references/lark-drive-search.md)**. Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search). Kept for back-compat; new flows should use the drive-scoped command with flat flags. |
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |

View File

@@ -1,217 +0,0 @@
# docs +search云空间搜索文档 / Wiki / 电子表格)
> ⚠️ **此命令进入维护期,后续会下线。新用法请使用 [`drive +search`](../../lark-drive/references/lark-drive-search.md)。**
>
> `drive +search` 把所有过滤条件扁平化为独立 flag`--edited-since` / `--mine` / `--doc-types` 等),面向自然语言场景设计,同时新增了 `my_edit_time`(我编辑过)、`my_comment_time`(我评论过)等维度。除非要沿用老脚本里的 `--filter` JSON否则**都应该切到 `drive +search`**。
>
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间对象。
虽然接口名是 `doc_wiki/search`,但命中结果不只限于文档 / Wiki也会返回 `SHEET``BITABLE``FOLDER` 等云空间对象。因此它适合作为云空间对象的资源发现入口:先定位文档、知识库节点、电子表格、多维表格、文件夹,以及用户以“表格 / 报表”方式描述的相关对象,再切回对应业务 skill 做对象内部操作。
该 shortcut 会:
- 未指定范围字段时,自动补齐 `doc_filter` / `wiki_filter`
- 自动将 `--filter` 中的公共字段同步到搜索范围对应的 filter`folder_tokens` 仅发到 `doc_filter``space_ids` 仅发到 `wiki_filter`
- 支持在 `filter.open_time` / `filter.create_time` 中使用 ISO 8601 时间,并自动转换为 Unix 秒
- 在返回结果中为 `*_time` 字段补充 `*_time_iso`(便于阅读)
- `title_highlighted` / `summary_highlighted` 可能包含高亮标签(如 `<h>` / `<hb>`
## 命令
> **关键约束:搜索关键词必须通过 `--query` 传递。**
> 正确:`lark-cli docs +search --query "方案"`
> 错误:`lark-cli docs +search 方案`
> `+search` 不接受“搜索词位置参数”这种写法;如果把关键词直接跟在命令后面,不会进入 `query`,会变成空搜或返回不符合预期的结果。
```bash
# 关键词搜索
lark-cli docs +search --query "季度总结"
# 搜标题里带“评测结果”的电子表格 / 文档
lark-cli docs +search --query "评测结果"
# 标题包含关键词(默认按关键词检索,不做精确标题匹配)
lark-cli docs +search --query "方案"
# 使用服务端标题限定语法
lark-cli docs +search --query 'intitle:方案'
# 精确短语匹配
lark-cli docs +search --query '"季度 总结"'
# 逻辑或 / 排除
lark-cli docs +search --query '方案 OR 草稿'
lark-cli docs +search --query '方案 -草稿'
# 标题精确短语匹配
lark-cli docs +search --query 'intitle:"季度总结"'
# 按最近打开时间过滤
lark-cli docs +search \
--query "方案" \
--filter '{"open_time":{"start":"2025-09-24T00:00:00+08:00","end":"2025-12-24T23:59:59+08:00"}}'
# 按文档所有者过滤creator_ids 传文档所有者 open_id不是邮箱 / user_id
lark-cli docs +search \
--query "季度总结" \
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"]}'
# 只搜索指定类型
lark-cli docs +search \
--query "评测结果" \
--filter '{"doc_types":["SHEET","DOCX"]}'
# 只在指定文件夹下搜索文档folder_token 通常来自 /drive/folder/<token>
lark-cli docs +search \
--query "方案" \
--filter '{"folder_tokens":["fld_123456"]}'
# 只搜标题,不搜正文 / 摘要
lark-cli docs +search \
--query "周报" \
--filter '{"only_title":true}'
# 只搜评论,不搜标题 / 正文
lark-cli docs +search \
--query "延期原因" \
--filter '{"only_comment":true}'
# 只搜索指定群会话里分享过的文档chat_id 最多 20 个)
lark-cli docs +search \
--query "方案" \
--filter '{"chat_ids":["oc_1234567890abcdef"]}'
# 只搜索指定分享者分享过的文档sharer_ids 传分享者 open_id最多 20 个)
lark-cli docs +search \
--query "复盘" \
--filter '{"sharer_ids":["ou_EXAMPLE_USER_ID"]}'
# 按创建时间过滤并指定排序方式
lark-cli docs +search \
--query "方案" \
--filter '{"create_time":{"start":"2026-01-01","end":"2026-03-31"},"sort_type":"CREATE_TIME"}'
# 组合多个筛选条件
lark-cli docs +search \
--query "项目复盘" \
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
# 只在指定知识空间下搜 Wiki
lark-cli docs +search \
--query "研发规范" \
--filter '{"space_ids":["space_1234567890fedcba"]}'
# 空搜(不传 query 或传空字符串):按最近浏览等默认规则返回
lark-cli docs +search
# 人类可读格式输出
lark-cli docs +search --query "OKR" --format pretty
# 返回原始 JSON并用 page_token 翻页
lark-cli docs +search --query "方案" --format json
lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--query <text>` | 否 | 搜索关键词。**支持高级 Boolean 语法**以提升搜索精度:<br>1. 使用空格表示 AND`方案 设计`)。<br>2. 使用 `OR` 表示逻辑或(如 `方案 OR 草稿`)。<br>3. 使用 `-` 表示排除(如 `方案 -草稿`)。<br>4. 使用双引号 `""` 表示精确匹配短语。<br>5. 使用 `intitle:` 限定关键词出现在标题中(如 `intitle:总结``intitle:"季度 总结"`)。不传/空字符串表示空搜。**凡是有关键词,都要显式通过 `--query` 传递,不要写成位置参数。** |
| `--filter <json>` | 否 | JSON 对象。公共字段默认同时应用到 `doc_filter` / `wiki_filter`;若传 `folder_tokens`,则只发 `doc_filter`;若传 `space_ids`,则只发 `wiki_filter`;两者不能同时传 |
| `--page-size <n>` | 否 | 每页数量(默认 15最大 20 |
| `--page-token <token>` | 否 | 翻页标记(配合 `has_more` 使用) |
| `--format` | 否 | 输出格式json默认 \| pretty |
## `--query` 高级语法
以下语法由服务端搜索能力处理,适合把过滤逻辑尽量下推到搜索侧:
- 空格表示 AND`方案 设计`
- `OR` 表示逻辑或:`方案 OR 草稿`
- `-` 表示排除:`方案 -草稿`
- 双引号表示精确短语:`"季度 总结"`
- `intitle:` 表示标题限定:`intitle:总结`
- 标题精确短语:`intitle:"季度总结"`
## `--filter` 字段速查
`--filter` 是一个 JSON 对象。大多数字段默认会同时作用于 `doc_filter``wiki_filter`;其中 `folder_tokens` 只用于文档侧,`space_ids` 只用于 Wiki 侧。
### 字段归属
- `doc_filter` / `wiki_filter` 公共字段:`creator_ids``doc_types``chat_ids``sharer_ids``only_title``only_comment``open_time``sort_type``create_time`
- `doc_filter` 独有字段:`folder_tokens`
- `wiki_filter` 独有字段:`space_ids`
- 如果传 `folder_tokens`shortcut 只发送 `doc_filter`
- 如果传 `space_ids`shortcut 只发送 `wiki_filter`
- 如果同时传 `folder_tokens``space_ids`shortcut 直接报错,不支持同时查询文档文件夹范围和知识空间范围
| 字段 | 作用范围 | 类型 | 说明 |
|------|----------|------|------|
| `creator_ids` | 文档 + Wiki | `string[]` | 所有者列表,**必须传 open_id**,不是 `user_id` / `union_id` / 邮箱。比如 `["ou_xxx"]`。如果只有姓名,先用 `lark-contact` 查 open_id |
| `doc_types` | 文档 + Wiki | `string[]` | 资源类型过滤。常用值:`DOC``DOCX``SHEET``BITABLE``FILE``WIKI``SLIDES``FOLDER``CATALOG``SHORTCUT` |
| `chat_ids` | 文档 + Wiki | `string[]` | 群会话 ID 列表,只搜索这些会话里分享过的文档,最多 20 个。通常传群 `chat_id`(如 `oc_xxx`);如果用户只给群名,先用 `lark-im` 定位群 |
| `sharer_ids` | 文档 + Wiki | `string[]` | 分享者列表,**必须传分享者 open_id**,最多 20 个。适合“某人分享过的文档”;如果只有姓名,先用 `lark-contact` 查 open_id |
| `folder_tokens` | 仅文档 | `string[]` | 只搜索指定云空间文件夹下的文档;值通常来自文件夹 URL `/drive/folder/<folder_token>` |
| `space_ids` | 仅 Wiki | `string[]` | 只搜索指定知识空间下的 Wiki 节点 |
| `only_title` | 文档 + Wiki | `boolean` | 只搜标题。注意这不是“标题精确等于”,只是把搜索范围限制在标题 |
| `only_comment` | 文档 + Wiki | `boolean` | 只搜评论。用法类似 `only_title`,只是把搜索范围限制在评论区;默认 `false` |
| `open_time` | 文档 + Wiki | `object` | 最近打开时间范围,支持 `{ "start": "...", "end": "..." }`。shortcut 支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒,并自动转成秒级时间戳 |
| `sort_type` | 文档 + Wiki | `string` | 排序方式。常用值:`DEFAULT_TYPE``OPEN_TIME``EDIT_TIME``EDIT_TIME_ASC``CREATE_TIME` |
| `create_time` | 文档 + Wiki | `object` | 文档 / Wiki 创建时间范围,结构与 `open_time` 相同 |
### 字段使用建议
- `creator_ids`:适合“找某个人创建的文档 / 表格 / Wiki”。如果用户只给姓名不要猜 ID先查这个人的 `open_id`
- `doc_types`:只在用户**明确指定资源类型**时使用,适合先把资源类型缩小。显式类型词可按以下方式映射:`表格 / 电子表格 / spreadsheet -> ["SHEET"]``多维表格 / base / bitable -> ["BITABLE"]``知识库 / wiki -> ["WIKI"]``文件夹 -> ["FOLDER"]``普通文档` 或明确要求“只看文档类型、不要表格 / Wiki” -> `["DOC","DOCX"]`。不要因为用户口头说“文档”就默认补 `DOC` / `DOCX`,因为“文档”在很多场景里只是对云空间对象的泛称。
- `chat_ids`:适合“搜某个群里分享过的文档”“看某个群会话里的方案”。如果用户只给群名,先切到 `lark-im` 用群搜索能力拿到 `chat_id`,再回到 `docs +search`
- `sharer_ids`:适合“找某人分享过的文档”“看某个同事转给我的资料”。如果用户只给姓名,不要猜 ID先用 `lark-contact` 查分享者 `open_id`
- `folder_tokens`:适合“在某个云空间文件夹里搜文档”。它不是知识空间 `space_id`,两者不要混用。
- `only_title`:适合“标题里包含某个词”的场景;如果用户明确表达标题限定,也可以直接在 `--query` 里使用 `intitle:`。如果用户要“标题精确等于”,优先使用 `intitle:"完整标题"`,必要时再做客户端精确确认。
- `only_comment`:适合“评论里提到某个词”“只找评论区讨论过某件事”。它和 `only_title` 一样,都是把搜索范围缩小到特定区域,但这里限制到评论区。
- `open_time`:适合“最近打开过 / 最近看过”的描述;如果用户说相对时间,先换算成明确绝对时间再传。
- `sort_type``CREATE_TIME_ASC` 在协议里标注“暂不支持”,`ENTITY_CREATE_TIME_ASC` / `ENTITY_CREATE_TIME_DESC` 已废弃,默认不要主动使用。
- `create_time`:适合“今年新建的”“上个月创建的”这类条件;不写 `start` / `end` 时,协议默认窗口是“请求时间往前 1 年”到“请求时间”。
### 常见 `--filter` JSON 片段
```json
{"creator_ids":["ou_EXAMPLE_USER_ID"]}
{"doc_types":["SHEET","DOCX"]}
{"chat_ids":["oc_1234567890abcdef"]}
{"sharer_ids":["ou_EXAMPLE_USER_ID"]}
{"folder_tokens":["fld_123456"]}
{"only_title":true}
{"only_comment":true}
{"open_time":{"start":"2026-01-01T00:00:00+08:00","end":"2026-03-31T23:59:59+08:00"},"sort_type":"OPEN_TIME"}
{"create_time":{"start":"2026-01-01","end":"2026-03-31"},"sort_type":"CREATE_TIME"}
{"space_ids":["space_1234567890fedcba"]}
```
## 结果判别
- `result_meta.doc_types == SHEET`:电子表格,后续切到 `lark-sheets`
- 其他类型:继续按对应 skill 或 API 处理
## 决策规则
- 参数传递:只要用户给了搜索关键词,就必须显式使用 `--query "<关键词>"`。不要生成 `lark-cli docs +search 方案``lark-cli docs +search xxx搜索关键词` 这种位置参数写法。
- 查询语义:必须优先利用 --query 的高级语法(如 intitle:、""、-)将过滤逻辑下推给服务端。当用户要求“标题精确等于 X”时直接使用 --query "intitle:\"X\"",严禁先进行模糊搜索再做客户端二次筛选。只有在遇到服务端语法无法覆盖的复杂本地比对场景时,才允许在客户端过滤,且比对前必须先去掉 title_highlighted 里的高亮标签。
- 实体补全:如果用户要按“某个群里分享的文档”搜索,先用 `lark-im``chat_id` 再填 `chat_ids`;如果用户要按“某人分享的文档”搜索,先用 `lark-contact``open_id` 再填 `sharer_ids`
- 零结果回退:如果因为用户的显式类型约束加了 `doc_types` 且结果为 0可以提示“按指定类型没搜到”只有在不违背用户明确约束的前提下才建议放宽类型重试。
- 入口选择:用户说“找表格标题”“找名为 `X` 的电子表格”“搜某个报表”时,也默认走 `docs +search`。不要误用 `sheets +find` 做跨文件搜索。
- 分页策略:默认只返回**第一页**,并说明 `has_more` / `page_token`。只有当用户明确要求“全部结果”“继续翻页”“全量扫描”“所有结果”“完整列表”时,才继续翻页。
- 翻页上限:即使用户要求全量,单轮也最多先拉 **5 页**(按默认 `page-size=20` 约等于最多 100 条结果)。达到上限后,先回报当前进度和是否还有更多页,再让用户决定是否继续下一批。
- 总数口径:`total` 是 OpenAPI 的搜索结果总数,不一定等于客户端二次筛选后的精确数量。凡是依赖本地过滤、去重、精确标题匹配的场景,都不要默认承诺“精确总数”。
- 原始返回:如果用户要求“直接返回接口数据 / 原始返回”,优先使用 `--format json`,不要额外做精确标题过滤或摘要重写。
- 时间表达用户如果说“3 到 6 个月前”“最近半年内”等相对时间,先转换成明确的绝对时间,再写入 `filter.open_time` / `filter.create_time`
- 跨 skill handoff如果搜索的目标是某个 spreadsheet返回命中的标题、URL、token 等定位信息后,应切换到 `lark-sheets` 继续后续操作,不要把 `docs +search` 当成对象内部查询。
## 权限
| 操作 | 所需 scope |
|------|-----------|
| 搜索云空间对象(含文档 / Wiki / 表格资源发现) | `search:docs:read` |

View File

@@ -16,10 +16,11 @@ metadata:
## 快速决策
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
@@ -129,7 +130,7 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`slides` 仅支持 block_id,且都支持最终解析到对应类型的 wiki URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`sheet` 使用 `<sheetId>!<cell>``slides` 使用 `<slide-block-type>!<xml-id>`,且都支持最终解析到对应类型的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
@@ -139,7 +140,8 @@ Drive Folder (云空间文件夹)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取
- 局部评论:传 `--block-id` 时启用;不同文档类型的支持范围与参数格式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
@@ -182,6 +184,12 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
### 评论业务特性与引导(关键!)
#### Review 场景评论落点
- 默认策略是“能局部就局部”:用户说 review、审阅、检查文档、标注问题、给修改建议、逐条评论时优先创建局部评论。
- 多个独立问题应分别创建多条局部评论;不要为了省调用次数把 review 发现的问题合并到全文评论。
- 只有在目标类型支持全文评论,且出现以下任一情况时,才退回全文评论:用户明确要求全文/总体评论、评论内容确实是文档级总结、目标类型不支持局部评论,或无法稳定定位到具体位置;否则应说明限制并请求用户提供可定位位置。
- 具体参数、定位方式和不同文档类型的约束见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
#### 评论排序引导
- 一个文档通常有多个评论,评论按 `create_time`(创建时间)排序。
- **重要**:只有当用户明确提到"最新评论"、"最后评论"、"最早评论"时,才需要根据 `create_time` 进行排序:
@@ -249,7 +257,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags (preferred over `docs +search`). Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |

View File

@@ -137,8 +137,9 @@ lark-cli drive +add-comment \
## 行为说明
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL以及最终可解析为 `doc`/`docx` 的 wiki URL。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式支持 `docx``slides`,以及最终可解析为这些类型的 wiki URL。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式支持 `docx``sheet``slides`,以及最终可解析为这些类型的 wiki URL。
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`sheet 没有全文评论,`--full-comment` 不可用。
- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "<SLIDE_BLOCK_TYPE>!<XML_ELEMENT_ID>"`。CLI 会将其拆分映射到 `anchor.block_id` / `anchor.slide_block_type`。此时 `--full-comment``--selection-with-ellipsis` 不可用。
- **Slide 参数映射示例**`--block-id` 由 PPT XML 元素类型和元素 `id` 组成。例如:

View File

@@ -43,6 +43,9 @@ lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还
# 导入到指定文件夹,并指定导入后的文件名
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
# 导入数据到已有的多维表格(不新建,数据挂载到目标多维表格中)
lark-cli drive +import --file ./data.xlsx --type bitable --target-token <BASE_TOKEN>
# 预览底层调用链(上传 -> 创建任务 -> 轮询)
lark-cli drive +import --file ./README.md --type docx --dry-run
```
@@ -55,6 +58,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `--type` | 是 | 导入目标云文档格式。可选值:`docx` (新版文档)、`sheet` (电子表格)、`bitable` (多维表格) |
| `--folder-token` | 否 | 目标文件夹 token不传则请求中的 `point.mount_key` 为空字符串Import API 会将其解释为导入到云空间根目录 |
| `--name` | 否 | 导入后的在线云文档名称,不传默认使用本地文件名去掉扩展名后的结果 |
| `--target-token` | 否 | 已有的多维表格 token将数据导入到该多维表格中**仅支持 `--type bitable`**);传入后数据会挂载到目标多维表格而非新建一个 |
## 行为说明
@@ -64,7 +68,8 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
- 超过 20MB自动切换为分片上传 `upload_prepare -> upload_part -> upload_finish`
2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数
3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令
- **默认根目录行为**:不传 `--folder-token`shortcut 会保留空的 `point.mount_key`Lark Import API 会将其视为导入到调用者根目录
- **默认根目录行为**:不传 `--folder-token`shortcut 会保留空的 `point.mount_key`Lark Import API 会将其视为"导入到调用者根目录"
- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格的 tokenpoint 挂载点逻辑不变。数据会挂载到该已有多维表格中,而非创建新文档。
### 支持的文件类型转换

View File

@@ -5,12 +5,12 @@
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间对象。
和老的 `docs +search` 相比
核心特性
- 把常用过滤条件全部**扁平化为独立 flag**`--edited-since``--mine``--doc-types``--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
- 额外暴露了 4 个"我"维度:`my_edit_time`(我编辑过)、`my_comment_time`(我评论过)、`open_time`(我打开过)、`create_time`(文档创建时间)——直接对应用户自然语言里的"最近我编辑过的"、"我评论过的"等表达
- 自动处理 `my_edit_time` / `my_comment_time` 的小时级聚合(服务端存储粒度):亚小时输入会向整点 snap并在 stderr 打出提示
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`,不必再先去查 contact
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`,不必再先去查 contact(注意 `creator_ids` 服务端按 **owner / 文档归属人** 语义匹配,不是“最初创建人”,详见下文「身份维度」)
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
@@ -28,12 +28,12 @@
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
| 最近一个月我编辑过 且 我评论过的 | `lark-cli drive +search --query "" --edited-since 1m --commented-since 1m` |
| 最近一周我打开过的表格 | `lark-cli drive +search --query "" --opened-since 7d --doc-types sheet` |
| 我创建的所有文档 | `lark-cli drive +search --query "" --mine` |
| 我 30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算) | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
| 我 2026 年 3 月创建的文档(精确日历月) | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
| 我 owner 的所有文档owner 语义,非"我最初创建" | `lark-cli drive +search --query "" --mine` |
| 我 owner、30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算`--mine` 是 owner`--created-*` 才是文档创建时间 | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
| 我 owner、2026 年 3 月创建的文档(精确日历月同上owner + 创建时间窗两个维度 | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
| 关键词"预算",最近一周我打开过,按编辑时间降序 | `lark-cli drive +search --query 预算 --opened-since 7d --sort edit_time` |
| 某个 wiki space 下、我 30-60 天前创建的 | `lark-cli drive +search --query "" --mine --space-ids space_xxx --created-since 2m --created-until 1m` |
| 张三创建的文档 | `lark-cli drive +search --query "" --creator-ids ou_zhangsan` |
| 某个 wiki space 下、我 owner 且 30-60 天前创建的 | `lark-cli drive +search --query "" --mine --space-ids space_xxx --created-since 2m --created-until 1m` |
| 张三 owner / 负责的文档(注意是 owner 语义,不是张三最初创建的)| `lark-cli drive +search --query "" --creator-ids ou_zhangsan` |
| 我最近 3 个月评论过的 docx | `lark-cli drive +search --query "" --commented-since 3m --doc-types docx` |
### 更多示例
@@ -42,7 +42,7 @@
# 纯关键词搜索
lark-cli drive +search --query "季度总结"
# 使用服务端 query 高级语法(和 docs +search 一致)
# 使用服务端 query 高级语法
lark-cli drive +search --query 'intitle:方案'
lark-cli drive +search --query '"季度 总结"'
lark-cli drive +search --query '方案 OR 草稿'
@@ -80,12 +80,14 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
| `--format` | 否 | `json`(默认)/ `pretty` |
### 身份creator 维度
### 身份(owner 维度API 字段名 `creator_ids`
> **语义说明(重要)**`creator_ids`(含 `--mine` / `--creator-ids`)虽然 OpenAPI 字段名是 “creator”但服务端实际按 **owner文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**:我创建后转交他人的文档不会命中,他人创建后转给我(我成为 owner的会命中。用户说“我的 / 我创建的 / 我负责的”文档都路由到 `--mine`,但要清楚它返回的是“我 owner 的”。
| 参数 | 映射 | 说明 |
|---|---|---|
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键"我创建的";从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id取不到直接报错提示运行 `lark-cli auth login` |
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔;**与 `--mine` 互斥** |
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键“我 owner 的”(**不是**“我最初创建的”);从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id取不到直接报错提示运行 `lark-cli auth login` |
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按 **owner** 匹配**与 `--mine` 互斥** |
### 时间维度(每个维度一对 since/until
@@ -162,8 +164,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
## 决策规则
- **`docs +search` 的选择**:优先使用 `drive +search`(本指令),不要再用 `docs +search``docs +search` 进入维护期、后续会下线
- **身份快捷方式**:只要用户说"我创建的",直接 `--mine` 即可,不需要先查 contact 拿 open_id。
- **身份快捷方式**:用户说“我的 / 我创建的 / 我负责的”文档,直接 `--mine` 即可,不需要先查 contact 拿 open_id。注意 `--mine`**owner** 语义(我归属/负责的),不是“我最初创建的”——转交出去的不算、转交给我的算
- **时间维度选择**
- "我编辑的"、"我修改的" → `--edited-since` / `--edited-until`
- "我评论的"、"我回复过的" → `--commented-since` / `--commented-until`
@@ -173,10 +174,10 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
- "某个文件夹下" → `--folder-tokens`doc-only
- "某个 wiki 空间下" → `--space-ids`wiki-only
- 两者不能同时使用,混用会报错
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传,会直接报错。"我和张三创建的" `--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id但这种场景少见
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传,会直接报错。我和张三的”owner`--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id但这种场景少见
- **实体补全**
- 用户说"某个群里",先用 `lark-im``chat_id`
- 用户说"某人创建/分享的"(非自己),先用 `lark-contact` 查 open_id再填 `--creator-ids` / `--sharer-ids`
- 用户说某人的 / 某人分享的(非自己`--creator-ids` 按 owner 匹配),先用 `lark-contact` 查 open_id再填 `--creator-ids` / `--sharer-ids`
- **查询语义下推**`--query` 支持的服务端高级语法(`intitle:``""``OR``-`)优先使用,不要先模糊搜再在客户端二次过滤。
- **时间表达**
- 模糊相对时间("最近半年"、"过去 30 天"、"最近一周")→ `--*-since 6m` / `--*-since 30d` / `--*-since 7d`,不展开成 ISO 时间

View File

@@ -1,7 +1,7 @@
---
name: lark-markdown
version: 1.1.0
description: "飞书 Markdown查看、创建、上传编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取修改时使用。"
version: 1.2.0
description: "飞书 Markdown查看、创建、上传编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取修改、局部 patch 或比较差异时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -15,9 +15,11 @@ metadata:
## 快速决策
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
- 用户要**比较原生 `.md` 文件的历史版本差异**,或比较远端 Markdown 与本地草稿,使用 `lark-cli markdown +diff`
- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch`
- 用户要对 Markdown 文件做**局部文本替换 / 正则替换**,优先使用 `lark-cli markdown +patch`
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
- 用户要把本地 Markdown **导入成在线新版文档docx**,不要用本 skill改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
@@ -42,6 +44,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |
| [`+diff`](references/lark-markdown-diff.md) | Compare two remote Markdown versions, or compare remote Markdown against a local file |
| [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive |
| [`+patch`](references/lark-markdown-patch.md) | Patch a Markdown file in Drive via fetch-local-replace-overwrite |
| [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive |

View File

@@ -2,7 +2,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在 Drive 中创建一个原生 Markdown 文件(`.md`)。
在 Drive 中创建一个原生 Markdown 文件(`.md`,支持创建到普通 Drive 文件夹或 Wiki 节点下
## 命令
@@ -32,6 +32,11 @@ lark-cli markdown +create \
--folder-token fldcn_xxx \
--file ./README.md
# 创建到指定 wiki 节点
lark-cli markdown +create \
--wiki-token wikcn_xxx \
--file ./README.md
# 预览底层请求
lark-cli markdown +create \
--name README.md \
@@ -43,7 +48,8 @@ lark-cli markdown +create \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--folder-token` | 否 | 目标 Drive 文件夹 token省略时创建到根目录 |
| `--folder-token` | 否 | 目标 Drive 文件夹 token`--wiki-token` 互斥;省略时创建到根目录 |
| `--wiki-token` | 否 | 目标 wiki 节点 token`--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file``-`stdin |
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
@@ -51,8 +57,10 @@ lark-cli markdown +create \
## 关键约束
- `--content``--file` 必须二选一
- `--folder-token``--wiki-token` 互斥
- `--name` 必须带 `.md` 后缀
- `--file` 指向的本地文件名也必须带 `.md` 后缀
-`--wiki-token` 时,返回值中不会附带 `/file/<token>` URL因为 wiki 承载文件没有稳定的独立 file URL
## 返回值

View File

@@ -0,0 +1,156 @@
# markdown +diff
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
比较 Drive 中原生 Markdown 的两个历史版本,或比较远端 Markdown 与本地 `.md` 草稿。需要历史版本号时,先用 [`drive +version-history`](../../lark-drive/references/lark-drive-version-history.md) 获取 `version`,不要使用 `tag`
## 命令
```bash
# 比较两个远端版本
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621 \
--to-version 7633658129540910628
# 比较历史版本与远端最新版本
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621
# 比较远端最新版本与本地草稿
lark-cli markdown +diff \
--file-token boxcnxxxx \
--file ./draft.md \
--format pretty
# 比较指定远端版本与本地草稿
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621 \
--file ./draft.md
# 预览底层请求
lark-cli markdown +diff \
--file-token boxcnxxxx \
--from-version 7633658129540910621 \
--to-version 7633658129540910628 \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | 目标 Markdown 文件 token |
| `--from-version` | 否 | 基准远端版本;不传 `--file` 时必填,传 `--file` 时省略表示“远端最新 vs 本地文件” |
| `--to-version` | 否 | 目标远端版本;要求同时传 `--from-version`,且不能与 `--file` 一起使用。省略时表示远端最新版本 |
| `--file` | 否 | 本地 `.md` 文件路径;传入后进入“远端 vs 本地”比较模式 |
| `--context-lines` | 否 | unified diff 每个 hunk 前后保留的上下文行数,默认 `3` |
| `--format` | 否 | 仅支持 `json`(默认)和 `pretty` |
## 关键行为
- `--file` 存在时:
- 省略 `--from-version` = 比较“远端最新版本 vs 本地文件”
- 传入 `--from-version` = 比较“指定远端版本 vs 本地文件”
- `--to-version` 只能用于“远端版本 vs 远端版本”,不能与 `--file` 同时出现
- `--format pretty` 输出带颜色的 unified diff`--format json` 返回结构化摘要和完整 diff 文本
- 无差异时:
- `json` 输出里 `changed=false`
- `pretty` 输出固定为 `No differences.`
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"changed": true,
"mode": "remote_vs_remote",
"file_token": "boxcnxxxx",
"from_version": "7633658129540910621",
"to_version": "7633658129540910628",
"from_label": "a/boxcnxxxx@version:7633658129540910621",
"to_label": "b/boxcnxxxx@version:7633658129540910628",
"added_lines": 3,
"deleted_lines": 2,
"context_lines": 3,
"hunks": [
{
"header": "@@ -1,6 +1,7 @@",
"old_start": 1,
"old_lines": 6,
"new_start": 1,
"new_lines": 7
}
],
"diff": "--- a/boxcnxxxx@version:7633658129540910621\n+++ b/boxcnxxxx@version:7633658129540910628\n@@ -1,2 +1,2 @@\n..."
}
}
```
完整字段说明:
| 字段 | 层级 | 含义 |
|------|------|------|
| `ok` | 顶层 | CLI 通用成功标记;`true` 表示命令执行成功 |
| `identity` | 顶层 | 本次执行使用的身份,通常是 `user``bot` |
| `data` | 顶层 | 本次 diff 的业务结果对象 |
| `changed` | `data` | 是否存在差异;`true` 表示两侧内容不同,`false` 表示完全一致 |
| `mode` | `data` | 比较模式;`remote_vs_remote` = 远端对远端,`remote_vs_local` = 远端对本地 |
| `file_token` | `data` | 被比较的远端 Markdown 文件 token |
| `from_version` | `data` | 基准远端版本号;远端最新 vs 本地时可能为空字符串 |
| `to_version` | `data` | 目标远端版本号;当目标侧是远端最新版本或本地文件时通常为空字符串 |
| `from_label` | `data` | unified diff 基准侧标签名,会直接出现在 `diff` 文本的 `---` 头部 |
| `to_label` | `data` | unified diff 目标侧标签名,会直接出现在 `diff` 文本的 `+++` 头部 |
| `added_lines` | `data` | 新增行数统计 |
| `deleted_lines` | `data` | 删除行数统计 |
| `context_lines` | `data` | 每个 hunk 前后保留的上下文行数,对应传入的 `--context-lines` |
| `hunks` | `data` | 结构化的变更块摘要数组;每个元素对应 patch 里的一个 `@@ ... @@` 段 |
| `diff` | `data` | 完整 unified diff 文本;最适合直接阅读或保存 |
| `local_file` | `data` | 仅在 `remote_vs_local` 模式下出现;值就是传给 `--file` 的本地 Markdown 路径 |
标签字段补充:
- `from_label` / `to_label` 只用于标识 diff 两侧,不代表额外 API 字段
- `from_label` 表示基准侧,`to_label` 表示目标侧
- 远端版本通常形如 `a/<file_token>@version:<version>``b/<file_token>@version:<version>`
- 当目标侧是远端最新版本时,`to_label` 形如 `b/<file_token>@latest`
- 当目标侧是本地文件时,`to_label` 形如 `b/./draft.md`
`hunks` 子字段说明:
| 字段 | 含义 |
|------|------|
| `header` | 原始 hunk 头,例如 `@@ -3,1 +3,1 @@` |
| `old_start` | 旧内容从第几行开始 |
| `old_lines` | 旧内容这段覆盖多少行 |
| `new_start` | 新内容从第几行开始 |
| `new_lines` | 新内容这段覆盖多少行 |
补充说明:
- `hunks` 适合 agent 或脚本快速定位变更范围;完整逐行内容仍以 `diff` 字段为准
- `changed=false` 时,`hunks` 通常为空数组,`diff` 通常为空字符串;如果使用 `--format pretty`,终端输出会是 `No differences.`
远端 vs 本地时会额外返回:
```json
{
"local_file": "./draft.md"
}
```
- `local_file`
- 只有传了 `--file`、进入“远端 vs 本地”模式时才会返回
- 值就是本次命令实际比较的本地 Markdown 路径,也就是你传给 `--file` 的那个路径
- 它表示“目标侧本地文件”,不是临时下载文件,也不是远端文件名
- 如果没有这个字段,说明本次是“远端版本 vs 远端版本”
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览
- [lark-drive-version-history](../../lark-drive/references/lark-drive-version-history.md) — 获取可用于 diff 的历史版本号
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,7 +1,7 @@
---
name: lark-sheets
version: 1.2.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-doc 的 docs +search 先定位资源。"
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。"
metadata:
requires:
bins: ["lark-cli"]
@@ -13,8 +13,6 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 快速决策
- 按标题或关键词找云空间里的表格文件,先用 `lark-cli docs +search`
- `docs +search` 会直接返回 `SHEET` 结果,不要把它误解成只能搜文档 / Wiki。
- 已知 spreadsheet URL / token 后,再进入 `sheets +info``sheets +read``sheets +find` 等对象内部操作。
## 核心概念

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBaseFormDetailDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-detail",
"--share-token", "shrXXXX",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/detail")
assert.Contains(t, output, `"share_token"`)
assert.Contains(t, output, "shrXXXX")
assert.Contains(t, output, `"method": "POST"`)
}
func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-detail",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "share-token")
}

View File

@@ -43,6 +43,33 @@ func TestMarkdownCreateDryRun_Content(t *testing.T) {
assert.Contains(t, output, `"size": 7`)
}
func TestMarkdownCreateDryRun_WikiTarget(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "wikcnMarkdownDryRun",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, `"file_name": "README.md"`)
assert.Contains(t, output, `"parent_node": "wikcnMarkdownDryRun"`)
assert.Contains(t, output, `"parent_type": "wiki"`)
assert.Contains(t, output, `"size": 7`)
}
func TestMarkdownCreateDryRun_FileShowsConcreteSize(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
@@ -96,6 +123,83 @@ func TestMarkdownCreateDryRun_RejectsEmptyContent(t *testing.T) {
assert.Contains(t, errMsg, "empty markdown content is not supported")
}
func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", "boxcnMarkdownDryRun",
"--from-version", "7633658129540910621",
"--to-version", "7633658129540910628",
"--context-lines", "1",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_remote"`)
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"version": "7633658129540910628"`)
assert.Contains(t, output, `"context_lines": 1`)
}
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(dir+"/draft.md", []byte("# draft\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", "boxcnMarkdownDryRun",
"--file", "./draft.md",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_local"`)
assert.Contains(t, output, `"local_file": "./draft.md"`)
}
func TestMarkdownCreateDryRun_RejectsEmptyWikiToken(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, result.Stdout+result.Stderr, "--wiki-token cannot be empty")
}
func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
setMarkdownDryRunConfigEnv(t)

View File

@@ -6,10 +6,12 @@ package markdown
import (
"context"
"os"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
@@ -126,4 +128,137 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) {
fetchUpdatedResult.AssertExitCode(t, 0)
fetchUpdatedResult.AssertStdoutStatus(t, true)
require.Equal(t, updatedContent, gjson.Get(fetchUpdatedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchUpdatedResult.Stdout)
historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
historyResult.AssertExitCode(t, 0)
historyResult.AssertStdoutStatus(t, true)
latestVersion := gjson.Get(overwriteResult.Stdout, "data.version").String()
require.NotEmpty(t, latestVersion, "stdout:\n%s", overwriteResult.Stdout)
versions := gjson.Get(historyResult.Stdout, "data.versions").Array()
require.GreaterOrEqual(t, len(versions), 2, "stdout:\n%s", historyResult.Stdout)
var previousVersion string
// version-history returns versions in descending chronological order;
// pick the first non-latest as the previous version.
for _, version := range versions {
candidate := version.Get("version").String()
if candidate != "" && candidate != latestVersion {
previousVersion = candidate
break
}
}
require.NotEmpty(t, previousVersion, "stdout:\n%s", historyResult.Stdout)
diffResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", fileToken,
"--from-version", previousVersion,
"--to-version", latestVersion,
},
DefaultAs: "user",
})
require.NoError(t, err)
diffResult.AssertExitCode(t, 0)
diffResult.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(diffResult.Stdout, "data.changed").Bool(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, "remote_vs_remote", gjson.Get(diffResult.Stdout, "data.mode").String(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, previousVersion, gjson.Get(diffResult.Stdout, "data.from_version").String(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, latestVersion, gjson.Get(diffResult.Stdout, "data.to_version").String(), "stdout:\n%s", diffResult.Stdout)
assert.GreaterOrEqual(t, len(gjson.Get(diffResult.Stdout, "data.hunks").Array()), 1, "stdout:\n%s", diffResult.Stdout)
diffText := gjson.Get(diffResult.Stdout, "data.diff").String()
assert.True(t, strings.Contains(diffText, "-hello markdown workflow") || strings.Contains(diffText, "-# Initial"), "stdout:\n%s", diffResult.Stdout)
assert.True(t, strings.Contains(diffText, "+new body") || strings.Contains(diffText, "+# Updated"), "stdout:\n%s", diffResult.Stdout)
}
func TestMarkdownCreateWorkflow_WikiParent(t *testing.T) {
if os.Getenv("LARK_MARKDOWN_E2E") == "" {
t.Skip("set LARK_MARKDOWN_E2E=1 to run markdown live workflow after backend version support is deployed")
}
wikiToken := strings.TrimSpace(os.Getenv("LARK_MARKDOWN_E2E_WIKI_TOKEN"))
if wikiToken == "" {
t.Skip("set LARK_MARKDOWN_E2E_WIKI_TOKEN to run markdown live workflow against a wiki parent node")
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-markdown-wiki-" + suffix + ".md"
initialContent := "# Wiki Parent\n\nhello wiki markdown workflow\n"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--wiki-token", wikiToken,
"--name", fileName,
"--content", initialContent,
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
require.False(t, gjson.Get(createResult.Stdout, "data.url").Exists(), "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
requireDeleteWikiHostedMarkdownFile(parentT, fileToken)
})
fetchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "bot",
})
require.NoError(t, err)
fetchResult.AssertExitCode(t, 0)
fetchResult.AssertStdoutStatus(t, true)
require.Equal(t, initialContent, gjson.Get(fetchResult.Stdout, "data.content").String(), "stdout:\n%s", fetchResult.Stdout)
}
func requireDeleteWikiHostedMarkdownFile(parentT *testing.T, fileToken string) {
parentT.Helper()
request := clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
}
for _, identity := range []string{"bot", "user"} {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: request.Args,
DefaultAs: identity,
})
cleanupCancel()
if err == nil && result != nil && result.ExitCode == 0 {
return
}
}
parentT.Fatalf("cleanup failed: could not delete wiki-hosted markdown file %s with either bot or user identity", fileToken)
}