mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
5 Commits
feat/app_r
...
feat/confi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771a30a3b1 | ||
|
|
d7a83dfc79 | ||
|
|
5b050095ac | ||
|
|
960f6daabc | ||
|
|
2fcb703943 |
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -9,11 +9,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# All platforms (incl. darwin keychain_signer) are CGO-free and cross-compiled
|
||||
# on a single ubuntu runner in one goreleaser run (one checksums.txt). The
|
||||
# darwin signer's runtime FFI is validated separately by the signer-test job.
|
||||
goreleaser:
|
||||
needs: signer-test-macos
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -38,21 +34,6 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Validate the macOS keychain signer on real hardware. The release binaries are
|
||||
# cross-compiled on ubuntu (CGO-free purego FFI), so this is the only step that
|
||||
# needs a Mac — and it gates the release rather than producing it.
|
||||
signer-test-macos:
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
- name: Keychain signer round-trip (CGO-free purego FFI)
|
||||
run: LARK_KEYCHAIN_IT=1 CGO_ENABLED=0 go test -tags keychain_signer -run Keychain -v ./internal/keysigner/
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -5,53 +5,15 @@ before:
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
# Linux & Windows: pure-Go TPM 2.0 signer is compiled in by default (no build
|
||||
# tag), cross-compiled with CGO disabled — the binaries ship the platform key
|
||||
# signer for private_key_jwt. windows/arm64 is the one exception: the sks
|
||||
# Windows dependency stack (go-ole) has no arm64 support, so the signer file is
|
||||
# arch-excluded there and that binary falls back to client_secret only.
|
||||
- id: linux
|
||||
binary: lark-cli
|
||||
main: .
|
||||
- binary: lark-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- id: windows
|
||||
binary: lark-cli
|
||||
main: .
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# macOS: the keychain signer calls Security.framework via runtime FFI (purego),
|
||||
# so it is CGO-free, compiled into every darwin build (no build tag), and
|
||||
# cross-compiles from the same ubuntu runner as linux/windows.
|
||||
- id: darwin
|
||||
binary: lark-cli
|
||||
main: .
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
@@ -61,7 +23,7 @@ archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
format: zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,29 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
@@ -1212,7 +1189,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
|
||||
6
Makefile
6
Makefile
@@ -33,11 +33,7 @@ build: fetch_meta
|
||||
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
vet: fetch_meta
|
||||
# -unsafeptr=false: the macOS keychain signer dereferences dylib data-symbol
|
||||
# addresses from purego.Dlsym (uintptr->unsafe.Pointer over stable C memory) —
|
||||
# safe FFI, but go vet's unsafeptr can't prove it and has no inline suppress.
|
||||
# golangci-lint still runs full govet (honoring the //nolint:govet) in CI.
|
||||
go vet -unsafeptr=false ./...
|
||||
go vet ./...
|
||||
|
||||
# fmt-check fails when any file would be reformatted by gofmt. Keep this
|
||||
# in sync with the fast-gate "Check formatting" step in CI.
|
||||
|
||||
@@ -265,7 +265,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -325,7 +325,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Step 3: Poll for token
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
@@ -415,7 +415,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
|
||||
@@ -847,7 +847,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
|
||||
}
|
||||
|
||||
@@ -886,7 +886,7 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, "", "", nil); err != nil {
|
||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
||||
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
||||
}
|
||||
|
||||
@@ -206,88 +206,6 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyRefFromResult_PrivateKeyJWT(t *testing.T) {
|
||||
ref := keyRefFromResult(&configInitResult{
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
KeyLabel: "lark-cli-default",
|
||||
})
|
||||
if ref == nil {
|
||||
t.Fatal("keyRefFromResult returned nil")
|
||||
}
|
||||
if ref.Source != "tee" || ref.ID != "lark-cli-default" {
|
||||
t.Fatalf("key ref = %#v, want tee/lark-cli-default", ref)
|
||||
}
|
||||
|
||||
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodPrivateKeyJWT}); ref != nil {
|
||||
t.Fatalf("missing key label should not persist key ref, got %#v", ref)
|
||||
}
|
||||
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodClientSecret, KeyLabel: "ignored"}); ref != nil {
|
||||
t.Fatalf("client_secret should not persist key ref, got %#v", ref)
|
||||
}
|
||||
if ref := keyRefFromResult(nil); ref != nil {
|
||||
t.Fatalf("nil result should not persist key ref, got %#v", ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInitConfig_PrivateKeyJWTSingleAppPersistsSecretlessAuth(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
|
||||
if err := saveInitConfig("", nil, f, "cli_pkjwt", core.SecretInput{}, core.BrandFeishu, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
|
||||
t.Fatalf("saveInitConfig private_key_jwt single app: %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
if len(got.Apps) != 1 {
|
||||
t.Fatalf("apps len = %d, want 1", len(got.Apps))
|
||||
}
|
||||
app := got.Apps[0]
|
||||
if app.AppId != "cli_pkjwt" {
|
||||
t.Fatalf("AppId = %q, want cli_pkjwt", app.AppId)
|
||||
}
|
||||
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
|
||||
}
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
|
||||
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
|
||||
}
|
||||
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
|
||||
t.Fatalf("private_key_jwt config must stay secretless, AppSecret=%#v", app.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInitConfig_PrivateKeyJWTProfilePersistsSecretlessAuth(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
|
||||
if err := saveInitConfig("prod", &core.MultiAppConfig{}, f, "cli_pkjwt", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
|
||||
t.Fatalf("saveInitConfig private_key_jwt profile: %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
app := got.FindApp("prod")
|
||||
if app == nil {
|
||||
t.Fatalf("profile prod not saved: %#v", got.Apps)
|
||||
}
|
||||
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
|
||||
}
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
|
||||
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
|
||||
}
|
||||
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
|
||||
t.Fatalf("private_key_jwt profile must stay secretless, AppSecret=%#v", app.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
|
||||
// strictly validated the same way bind validates: wrong-case / typo / removed
|
||||
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
|
||||
@@ -367,12 +285,18 @@ func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-terminal without flags")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "--new") {
|
||||
t.Errorf("expected error to mention --new, got: %s", msg)
|
||||
if !strings.Contains(err.Error(), "terminal") {
|
||||
t.Errorf("expected error to mention terminal, got: %s", err.Error())
|
||||
}
|
||||
if !strings.Contains(msg, "terminal") {
|
||||
t.Errorf("expected error to mention terminal, got: %s", msg)
|
||||
// Missing-terminal is a failed precondition (valid request, wrong runtime
|
||||
// state), and the actionable guidance lives in the hint.
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeFailedPrecondition, p)
|
||||
}
|
||||
// Lock the two-step guidance contract: the hint must point at both flags.
|
||||
if !strings.Contains(p.Hint, "--no-wait") || !strings.Contains(p.Hint, "--device-code") {
|
||||
t.Errorf("hint should describe the two-step flow (--no-wait / --device-code), got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +394,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "", nil)
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
@@ -509,46 +433,6 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_UpdatePersistsPrivateKeyJWT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: core.PlainSecret("old-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
}},
|
||||
}
|
||||
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
|
||||
|
||||
if err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "prod", "cli_prod", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
|
||||
t.Fatalf("saveAsProfile update private_key_jwt: %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
app := got.FindApp("prod")
|
||||
if app == nil {
|
||||
t.Fatalf("profile prod not saved: %#v", got.Apps)
|
||||
}
|
||||
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
|
||||
}
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
|
||||
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
|
||||
}
|
||||
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
|
||||
t.Fatalf("private_key_jwt update must stay secretless, AppSecret=%#v", app.AppSecret)
|
||||
}
|
||||
if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_1" {
|
||||
t.Fatalf("same-app update should preserve users, Users=%#v", app.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -32,7 +31,13 @@ type ConfigInitOptions struct {
|
||||
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
||||
Brand string
|
||||
New bool
|
||||
AuthMethod string // --auth-method for --new: "" (default client_secret) | private_key_jwt
|
||||
|
||||
// NoWait initiates a new-app creation and returns immediately with a
|
||||
// device code (non-blocking step 1); DeviceCode completes a creation
|
||||
// previously started with --no-wait (non-blocking step 2). They mirror
|
||||
// `auth login`'s --no-wait / --device-code split.
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
|
||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
@@ -41,8 +46,6 @@ type ConfigInitOptions struct {
|
||||
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
Restore bool // Restore re-registers the app already in config to recover a lost credential
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
@@ -60,9 +63,11 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
|
||||
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
For AI agents: prefer the non-blocking two-step flow. Run '--new --no-wait' to
|
||||
get a device code and verification URL immediately (printed as JSON), send the
|
||||
URL/QR to the user, then run '--device-code <code>' after they confirm to finish.
|
||||
The plain '--new' still blocks until the user completes setup in the browser if
|
||||
you need the old behavior.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
@@ -85,13 +90,13 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
|
||||
cmd.Flags().StringVar(&opts.AuthMethod, "auth-method", "", "auth method for --new: client_secret (default) or private_key_jwt (signed by a platform key, no app secret)")
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "create a new app but return immediately with a device code; complete later with --device-code (non-blocking, for AI agents)")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "complete a new-app creation started with --no-wait, using its device code")
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
@@ -138,7 +143,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin || o.NoWait || o.DeviceCode != ""
|
||||
}
|
||||
|
||||
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
|
||||
@@ -157,44 +162,11 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
||||
}
|
||||
}
|
||||
|
||||
// removeStaleSecretForPKJWT clears a secret left in the keychain when the SAME
|
||||
// appId is migrated from client_secret to private_key_jwt. cleanupOldConfig
|
||||
// explicitly skips a matching appId, and saveAsProfile only cleans up on an
|
||||
// appId change, so a same-appId migration would orphan the old secret. This
|
||||
// fills that gap. RemoveSecretStore only deletes Source=="keychain" entries, so
|
||||
// the new pkjwt tee key handle is never touched.
|
||||
func removeStaleSecretForPKJWT(existing *core.MultiAppConfig, profileName, appID string, kc keychain.KeychainAccess) {
|
||||
if existing == nil {
|
||||
return
|
||||
}
|
||||
var prior *core.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
prior = &existing.Apps[idx]
|
||||
}
|
||||
} else {
|
||||
prior = existing.CurrentAppConfig("")
|
||||
}
|
||||
if prior != nil && prior.AppId == appID && !prior.AppSecret.IsZero() {
|
||||
core.RemoveSecretStore(prior.AppSecret, kc)
|
||||
}
|
||||
}
|
||||
|
||||
// keyRefFromResult builds the TEE key reference to persist for a private_key_jwt
|
||||
// registration result, or nil for client_secret.
|
||||
func keyRefFromResult(r *configInitResult) *core.SecretRef {
|
||||
if r != nil && r.AuthMethod == core.AuthMethodPrivateKeyJWT && r.KeyLabel != "" {
|
||||
return &core.SecretRef{Source: "tee", ID: r.KeyLabel}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveAsOnlyApp overwrites config.json with a single-app config.
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
config := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
||||
AuthMethod: authMethod, KeyRef: keyRef,
|
||||
}},
|
||||
}
|
||||
return core.SaveMultiAppConfig(config)
|
||||
@@ -203,11 +175,9 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
|
||||
// saveInitConfig saves a new/updated app config, respecting --profile mode.
|
||||
// With profileName: appends or updates the named profile (preserves other profiles).
|
||||
// Without profileName: cleans up old config and saves as the only app.
|
||||
// authMethod/keyRef carry the credential type: ("", nil) for client_secret,
|
||||
// (private_key_jwt, &{tee,label}) for the secretless TEE flow.
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
if profileName != "" {
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, authMethod, keyRef)
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
var prior i18n.Lang
|
||||
@@ -216,7 +186,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
||||
prior = app.Lang
|
||||
}
|
||||
}
|
||||
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)), authMethod, keyRef)
|
||||
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
|
||||
}
|
||||
|
||||
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
|
||||
@@ -236,7 +206,7 @@ func wrapSaveConfigError(err error) error {
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
// If a profile with the same name exists, it updates it; otherwise appends.
|
||||
// When updating, cleans up old keychain secrets if AppId changed.
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
multi := existing
|
||||
if multi == nil {
|
||||
multi = &core.MultiAppConfig{}
|
||||
@@ -255,8 +225,6 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
||||
multi.Apps[idx].AuthMethod = authMethod
|
||||
multi.Apps[idx].KeyRef = keyRef
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
@@ -265,14 +233,12 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []core.AppUser{},
|
||||
AuthMethod: authMethod,
|
||||
KeyRef: keyRef,
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
@@ -350,97 +316,25 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
// persistAndProbeResult saves a registration/restore result into profileName and
|
||||
// runs the post-registration probe. profileName == "" replaces the single app
|
||||
// (legacy); a named profile is updated in place. Shared by --new and --restore.
|
||||
func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
|
||||
// private_key_jwt apps have no secret: persist auth method + TEE key ref.
|
||||
// Registration success already validated the key (server bound the public
|
||||
// key), so the app_secret probe is skipped.
|
||||
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
||||
if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain)
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
|
||||
return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel)
|
||||
}
|
||||
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand)
|
||||
}
|
||||
|
||||
// runRestoreFlow re-registers the app already in config to recover a lost
|
||||
// credential (deleted keychain key / lost app secret). It reads the existing
|
||||
// app id + auth method + brand from config (no secret needed — that's the lost
|
||||
// part) and re-runs the device-flow registration with the app id sent on begin,
|
||||
// so the server re-registers that app instead of creating a new one. The
|
||||
// re-issued credential is written back to the same profile.
|
||||
func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error {
|
||||
if existing == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found").
|
||||
WithHint("run: lark-cli config init")
|
||||
}
|
||||
app := existing.CurrentAppConfig(opts.ProfileName)
|
||||
if app == nil || app.AppId == "" {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)).
|
||||
WithHint("run: lark-cli config init")
|
||||
}
|
||||
|
||||
restoreAppID := app.AppId
|
||||
// Reuse the stored auth method authoritatively — never prompt. Empty on disk
|
||||
// means client_secret (omitempty back-compat); pass it explicitly so
|
||||
// resolveRegisterAuthMethod doesn't fall through to the interactive picker.
|
||||
authMethod := app.AuthMethod
|
||||
if authMethod == "" {
|
||||
authMethod = core.AuthMethodClientSecret
|
||||
}
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result")
|
||||
}
|
||||
|
||||
// Safety: if the server did not honor app_id (e.g. not yet supported), it may
|
||||
// have created a NEW app instead of restoring. Warn so the user is not silently
|
||||
// switched to a different app id.
|
||||
if result.AppID != restoreAppID {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID)
|
||||
}
|
||||
|
||||
// Write back to the profile we restored: an explicit --name, else the resolved
|
||||
// app's own name. Empty name => legacy single-app replace.
|
||||
saveProfile := opts.ProfileName
|
||||
if saveProfile == "" {
|
||||
saveProfile = app.Name
|
||||
}
|
||||
return persistAndProbeResult(opts, f, saveProfile, result)
|
||||
}
|
||||
|
||||
// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed.
|
||||
func profileSuffix(profileName string) string {
|
||||
if profileName == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (profile %q)", profileName)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
// Validate the non-blocking flags before touching stdin so a contradictory
|
||||
// combination (e.g. --no-wait --app-secret-stdin) fails fast instead of
|
||||
// blocking on a stdin read.
|
||||
if opts.NoWait && opts.DeviceCode != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait and --device-code cannot be used together").WithParam("--device-code")
|
||||
}
|
||||
if (opts.NoWait || opts.DeviceCode != "") && (opts.AppID != "" || opts.AppSecretStdin) {
|
||||
// Point remediation at whichever non-blocking flag the caller actually
|
||||
// passed (mutual exclusion above guarantees at most one is set here).
|
||||
conflictParam := "--no-wait"
|
||||
if opts.DeviceCode != "" {
|
||||
conflictParam = "--device-code"
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait/--device-code create a new app and cannot be combined with --app-id/--app-secret-stdin").WithParam(conflictParam)
|
||||
}
|
||||
|
||||
// Read secret from stdin if --app-secret-stdin is set
|
||||
if opts.AppSecretStdin {
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
@@ -468,15 +362,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
// --restore recovers an existing app; it is incompatible with creating a new
|
||||
// app (--new) or importing one non-interactively (--app-id / stdin secret).
|
||||
if opts.Restore {
|
||||
if opts.New {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore")
|
||||
}
|
||||
if opts.AppID != "" || opts.AppSecretStdin {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore")
|
||||
}
|
||||
// Non-blocking step 2: complete a creation started with --no-wait.
|
||||
if opts.DeviceCode != "" {
|
||||
return resumeAppRegistration(opts)
|
||||
}
|
||||
// Non-blocking step 1: initiate a new-app creation and return immediately.
|
||||
if opts.NoWait {
|
||||
return initiateNoWaitAppRegistration(opts, existing)
|
||||
}
|
||||
|
||||
// Mode 1: Non-interactive
|
||||
@@ -486,7 +378,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, "", nil); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
@@ -512,26 +404,34 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
msg := getInitMsg(opts.UILang)
|
||||
|
||||
// Mode: Restore (--restore) — re-register the app already in config.
|
||||
if opts.Restore {
|
||||
return runRestoreFlow(opts, existing, f, msg)
|
||||
}
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), opts.AuthMethod, msg, "")
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||
}
|
||||
return persistAndProbeResult(opts, f, opts.ProfileName, result)
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mode 4: Interactive TUI (terminal)
|
||||
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveConfigInit(opts.Ctx, f, opts.AuthMethod, msg)
|
||||
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -542,22 +442,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
|
||||
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
||||
// Secretless create: persist auth method + TEE key ref, no secret.
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
removeStaleSecretForPKJWT(existing, opts.ProfileName, result.AppID, f.Keychain)
|
||||
if err := runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if result.AppSecret != "" {
|
||||
if result.AppSecret != "" {
|
||||
// New secret provided (either from "create" or "existing" with input)
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
@@ -582,9 +473,12 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||
// Non-terminal: the request is valid but the runtime state is wrong (no
|
||||
// terminal for interactive mode) — a failed precondition, not a bad
|
||||
// argument. Point the caller at the non-blocking two-step flow.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "config init interactive mode requires a terminal").
|
||||
WithHint("Create a new app non-interactively with the two-step flow: `lark-cli config init --new --no-wait` (prints device_code + verification_url, returns immediately), then `lark-cli config init --device-code <code>` after the user finishes in the browser. Or run `lark-cli config init --new` in a terminal.")
|
||||
}
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
@@ -662,7 +556,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, "", nil); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
type authMethodTestSigner struct{}
|
||||
|
||||
func (authMethodTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (authMethodTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (authMethodTestSigner) Sign(context.Context, keysigner.KeyRef, []byte) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// TestResolveRegisterAuthMethod covers the non-interactive gating paths. The
|
||||
// darwin keychain signer is compiled into every build, so the test cannot rely
|
||||
// on the binary lacking a signer — it forces a known no-signer state for the
|
||||
// rejection cases, then registers a stub for the success case.
|
||||
func TestResolveRegisterAuthMethod(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
prevSigner := keysigner.Active()
|
||||
t.Cleanup(func() { keysigner.Register(prevSigner) })
|
||||
keysigner.Register(nil)
|
||||
|
||||
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodClientSecret); err != nil || m != core.AuthMethodClientSecret {
|
||||
t.Errorf("client_secret: got (%q, %v), want (client_secret, nil)", m, err)
|
||||
}
|
||||
|
||||
if m, err := resolveRegisterAuthMethod(f, ""); err != nil || m != core.AuthMethodClientSecret {
|
||||
t.Errorf("default: got (%q, %v), want (client_secret, nil)", m, err)
|
||||
}
|
||||
|
||||
if _, err := resolveRegisterAuthMethod(f, "bogus"); err == nil {
|
||||
t.Error("bogus auth-method: expected error")
|
||||
}
|
||||
|
||||
if _, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err == nil {
|
||||
t.Error("private_key_jwt without a signer: expected error")
|
||||
}
|
||||
|
||||
keysigner.Register(authMethodTestSigner{})
|
||||
|
||||
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err != nil || m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("private_key_jwt with signer: got (%q, %v), want (private_key_jwt, nil)", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePKJWTKeyBinding covers the guard that rejects a registration
|
||||
// resolving to private_key_jwt with no signing key bound (e.g. an existing
|
||||
// secret-based app was selected on the confirm page).
|
||||
func TestValidatePKJWTKeyBinding(t *testing.T) {
|
||||
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, ""); err == nil {
|
||||
t.Error("pkjwt with empty keyLabel: expected error")
|
||||
}
|
||||
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, "agent-key"); err != nil {
|
||||
t.Errorf("pkjwt with keyLabel: expected nil, got %v", err)
|
||||
}
|
||||
if err := validatePKJWTKeyBinding(core.AuthMethodClientSecret, ""); err != nil {
|
||||
t.Errorf("client_secret: expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFinalAuthMethod locks the authoritative-method logic. The 2nd case
|
||||
// is the real bug: we requested private_key_jwt but the server resolved to an
|
||||
// existing client_secret app — we must persist client_secret, not pkjwt.
|
||||
func TestResolveFinalAuthMethod(t *testing.T) {
|
||||
if m := resolveFinalAuthMethod([]string{"client_secret", "private_key_jwt"}, core.AuthMethodClientSecret); m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("prefers private_key_jwt: got %q", m)
|
||||
}
|
||||
if m := resolveFinalAuthMethod([]string{"client_secret"}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodClientSecret {
|
||||
t.Errorf("server client_secret must override requested pkjwt: got %q", m)
|
||||
}
|
||||
if m := resolveFinalAuthMethod(nil, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("fallback to requested when server is silent: got %q", m)
|
||||
}
|
||||
// Explicit empty slice (not just nil) also falls back to requested — the same
|
||||
// len()==0 back-compat allowance the init guard relies on to let private_key_jwt
|
||||
// proceed against an older server (see internal/auth
|
||||
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods).
|
||||
if m := resolveFinalAuthMethod([]string{}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("empty []string should fall back to requested private_key_jwt: got %q", m)
|
||||
}
|
||||
if m := resolveFinalAuthMethod(nil, ""); m != core.AuthMethodClientSecret {
|
||||
t.Errorf("default to client_secret: got %q", m)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -17,26 +13,22 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
type configInitResult struct {
|
||||
Mode string // "create" or "existing"
|
||||
Brand core.LarkBrand
|
||||
AppID string
|
||||
AppSecret string
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // TEE key handle when AuthMethod == private_key_jwt
|
||||
Mode string // "create" or "existing"
|
||||
Brand core.LarkBrand
|
||||
AppID string
|
||||
AppSecret string
|
||||
}
|
||||
|
||||
// runInteractiveConfigInit shows an interactive TUI for config init.
|
||||
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
|
||||
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
|
||||
// Phase 1: Choose mode
|
||||
var mode string
|
||||
form1 := huh.NewForm(
|
||||
@@ -62,7 +54,7 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMetho
|
||||
return runExistingAppForm(f, msg)
|
||||
}
|
||||
|
||||
return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "")
|
||||
return runCreateAppFlow(ctx, f, "", msg)
|
||||
}
|
||||
|
||||
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
|
||||
@@ -154,59 +146,9 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveRegisterAuthMethod decides the auth method for a new-app registration.
|
||||
// An explicit --auth-method flag wins; otherwise, on an interactive terminal with
|
||||
// a TEE signer available, the user is prompted; the default is client_secret.
|
||||
func resolveRegisterAuthMethod(f *cmdutil.Factory, flag string) (string, error) {
|
||||
signerAvailable := keysigner.Active() != nil
|
||||
switch flag {
|
||||
case core.AuthMethodPrivateKeyJWT:
|
||||
if !signerAvailable {
|
||||
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"--auth-method private_key_jwt requires a platform key signer, which is unavailable on this device/build").
|
||||
WithHint("omit --auth-method (or pass --auth-method client_secret) to register with an app secret")
|
||||
}
|
||||
return core.AuthMethodPrivateKeyJWT, nil
|
||||
case core.AuthMethodClientSecret:
|
||||
return core.AuthMethodClientSecret, nil
|
||||
case "":
|
||||
// fall through to interactive / default
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown --auth-method %q (use client_secret or private_key_jwt)", flag)
|
||||
}
|
||||
|
||||
if signerAvailable && f.IOStreams.IsTerminal {
|
||||
var choice string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Authentication method").
|
||||
Options(
|
||||
huh.NewOption("App Secret (client_secret)", core.AuthMethodClientSecret),
|
||||
huh.NewOption("Secure key signer, no secret (private_key_jwt)", core.AuthMethodPrivateKeyJWT),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
if err := form.Run(); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
return core.AuthMethodClientSecret, nil
|
||||
}
|
||||
|
||||
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
|
||||
// If brandOverride is non-empty, skip the interactive brand selection.
|
||||
// authMethodFlag is the raw --auth-method value ("" when unset).
|
||||
// restoreAppID, when non-empty, is sent on the registration begin request so the
|
||||
// server re-registers that existing app (credential recovery) instead of creating
|
||||
// a new one. Empty preserves the normal new-app flow.
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, authMethodFlag string, msg *initMsg, restoreAppID string) (*configInitResult, error) {
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
|
||||
var larkBrand core.LarkBrand
|
||||
if brandOverride != "" {
|
||||
larkBrand = brandOverride
|
||||
@@ -234,52 +176,17 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
larkBrand = parseBrand(brand)
|
||||
}
|
||||
|
||||
authMethod, err := resolveRegisterAuthMethod(f, authMethodFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin).
|
||||
// Step 1: Request app registration (begin)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
|
||||
// For private_key_jwt: init to obtain a nonce, then sign a TEE attestation
|
||||
// (carrying the public key in its jwk header) to send with begin.
|
||||
beginOpts := larkauth.AppRegistrationBeginOptions{}
|
||||
keyLabel := ""
|
||||
if authMethod == core.AuthMethodPrivateKeyJWT {
|
||||
signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod
|
||||
initResp, initErr := larkauth.RequestAppRegistrationInit(httpClient)
|
||||
if initErr != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr)
|
||||
}
|
||||
// An empty SupportedAuthMethods is intentionally treated as "older server /
|
||||
// unknown": len()==0 makes this guard false, so the requested
|
||||
// private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's
|
||||
// back-compat fallback to the requested method. Only an explicit list that
|
||||
// omits private_key_jwt rejects here.
|
||||
if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")).
|
||||
WithHint("register with --auth-method client_secret instead")
|
||||
}
|
||||
keyLabel = keysigner.DefaultKeyLabel
|
||||
attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now())
|
||||
if signErr != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr)
|
||||
}
|
||||
beginOpts = larkauth.AppRegistrationBeginOptions{
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
AuthAttestation: attestation,
|
||||
}
|
||||
}
|
||||
|
||||
// Restore flow: re-register the existing app instead of creating a new one.
|
||||
beginOpts.RestoreAppID = restoreAppID
|
||||
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
// Pass a lower-layer typed error (e.g. a network/transport error) through
|
||||
// unchanged; only wrap genuinely-untyped failures as invalid_client.
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -311,28 +218,18 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// The final auth method is decided by the user/admin at confirmation and
|
||||
// returned by poll — NOT necessarily what we requested. Selecting an existing
|
||||
// client_secret app, for example, yields client_secret even though we sent
|
||||
// private_key_jwt. Trust the result so we persist the truth.
|
||||
finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod)
|
||||
|
||||
// Lark brand special case (client_secret only): a lark-tenant app returns its
|
||||
// secret only from the lark endpoint. private_key_jwt returns no secret, so
|
||||
// this retry does not apply.
|
||||
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// Step 4: Handle Lark brand special case
|
||||
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
finalMethod = resolveFinalAuthMethod(result.AuthMethods, authMethod)
|
||||
}
|
||||
|
||||
if result.ClientID == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id")
|
||||
}
|
||||
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret")
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
@@ -343,67 +240,13 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
finalBrand = core.BrandFeishu
|
||||
}
|
||||
|
||||
// Surface a downgrade: requested private_key_jwt but the app resolved to a
|
||||
// secret-based method (e.g. an existing app was selected). The key was NOT
|
||||
// bound, so we must store the secret method, not private_key_jwt.
|
||||
if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod)
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
|
||||
|
||||
keyToStore := ""
|
||||
if finalMethod == core.AuthMethodPrivateKeyJWT {
|
||||
keyToStore = keyLabel
|
||||
}
|
||||
if err := validatePKJWTKeyBinding(finalMethod, keyToStore); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &configInitResult{
|
||||
Mode: "create",
|
||||
Brand: finalBrand,
|
||||
AppID: result.ClientID,
|
||||
AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise
|
||||
AuthMethod: finalMethod,
|
||||
KeyLabel: keyToStore,
|
||||
Mode: "create",
|
||||
Brand: finalBrand,
|
||||
AppID: result.ClientID,
|
||||
AppSecret: result.ClientSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validatePKJWTKeyBinding rejects a registration that resolved to
|
||||
// private_key_jwt without a signing key bound to it. keyLabel is non-empty only
|
||||
// when the local flow chose private_key_jwt and signed a TEE attestation; a
|
||||
// resolved method of private_key_jwt with no key handle would save an unusable
|
||||
// config (rejected later at config load, surfacing as "saved OK, fails on first
|
||||
// use"), so it is caught here at registration time instead.
|
||||
func validatePKJWTKeyBinding(finalMethod, keyLabel string) error {
|
||||
if finalMethod == core.AuthMethodPrivateKeyJWT && keyLabel == "" {
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"registration resolved to private_key_jwt but no signing key was bound to this app (an existing secret-based app may have been selected)").
|
||||
WithHint("re-register with: lark-cli config init --new --auth-method private_key_jwt")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFinalAuthMethod picks the authoritative method from the poll result,
|
||||
// preferring private_key_jwt, then client_secret. It falls back to the requested
|
||||
// method when the server returns nothing (older servers).
|
||||
func resolveFinalAuthMethod(serverMethods []string, requested string) string {
|
||||
if len(serverMethods) == 0 {
|
||||
if requested == "" {
|
||||
return core.AuthMethodClientSecret
|
||||
}
|
||||
return requested
|
||||
}
|
||||
for _, m := range serverMethods {
|
||||
if m == core.AuthMethodPrivateKeyJWT {
|
||||
return core.AuthMethodPrivateKeyJWT
|
||||
}
|
||||
}
|
||||
for _, m := range serverMethods {
|
||||
if m == core.AuthMethodClientSecret {
|
||||
return core.AuthMethodClientSecret
|
||||
}
|
||||
}
|
||||
return serverMethods[0]
|
||||
}
|
||||
|
||||
265
cmd/config/init_nowait.go
Normal file
265
cmd/config/init_nowait.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// newRegistrationHTTPClient builds the HTTP client used for app-registration
|
||||
// traffic. It is a package var so tests can inject a stub transport.
|
||||
var newRegistrationHTTPClient = func() *http.Client { return transport.NewHTTPClient(0) }
|
||||
|
||||
// initNoWaitHint is the agent-facing guidance embedded in the --no-wait JSON
|
||||
// output, mirroring the two-step contract of `auth login --no-wait`.
|
||||
const initNoWaitHint = "**Generate AND display the QR code:** call `lark-cli auth qrcode <verification_url>` and show it (PNG via --output; ASCII via --ascii only if the user asks). " +
|
||||
"**You MUST include the QR image in your response** — generating the file alone is not enough. Output the URL first, then the QR image below it. " +
|
||||
"**Treat verification_url as an opaque string** — do not URL-encode/decode it or add spaces/punctuation. " +
|
||||
"**Hand control back:** make the QR/URL the final message of this turn; do NOT run --device-code in the same turn. Tell the user to come back and notify you after they finish creating the app in the browser. " +
|
||||
"**After the user confirms:** YOU must finish by running lark-cli with the exact arguments in `resume_args`, passing each element as a separate literal argument (do not re-quote or shell-interpret them). It already carries the right flags. " +
|
||||
"**Do NOT cache verification_url or device_code** — run `lark-cli config init --new --no-wait` fresh whenever a new app is needed."
|
||||
|
||||
// initiateNoWaitAppRegistration runs the non-blocking first step: request a
|
||||
// device code, cache the resume context, print JSON, and return immediately
|
||||
// without polling.
|
||||
func initiateNoWaitAppRegistration(opts *ConfigInitOptions, existing *core.MultiAppConfig) error {
|
||||
f := opts.Factory
|
||||
brand := parseBrand(opts.Brand)
|
||||
|
||||
httpClient := newRegistrationHTTPClient()
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, brand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
// Pass a lower-layer typed error (e.g. a network/transport error) through
|
||||
// unchanged; only wrap genuinely-untyped failures as invalid_client.
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: string(brand),
|
||||
ProfileName: opts.ProfileName,
|
||||
Lang: opts.Lang,
|
||||
LangExplicit: opts.langExplicit,
|
||||
Interval: authResp.Interval,
|
||||
ExpiresAt: time.Now().Unix() + int64(authResp.ExpiresIn),
|
||||
ConfigDigest: computeConfigDigest(existing),
|
||||
}
|
||||
// The resume step (--device-code) fully depends on this cache to finish
|
||||
// persisting the app — unlike auth login, which can re-derive its scope. So
|
||||
// a cache-write failure is fatal: fail now rather than hand back a
|
||||
// device_code the user can never complete.
|
||||
if err := saveInitNoWaitRecord(authResp.DeviceCode, rec); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to persist the context needed by `config init --device-code`: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Emit the resume step as an argv array rather than a shell string: the
|
||||
// device_code is opaque and may contain spaces or metacharacters, and a
|
||||
// single quoted string can't be both POSIX- and cmd.exe-safe. argv sidesteps
|
||||
// quoting entirely — agents pass each element as a literal argument.
|
||||
// --force-init must be carried along: guardAgentWorkspace runs in RunE
|
||||
// before the cache is read, so resuming without it inside an agent workspace
|
||||
// would be rejected. (Profile name is recovered from the cache.)
|
||||
resumeArgs := []string{"lark-cli", "config", "init", "--device-code", authResp.DeviceCode}
|
||||
if opts.ForceInit {
|
||||
resumeArgs = append(resumeArgs, "--force-init")
|
||||
}
|
||||
|
||||
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
||||
data := map[string]interface{}{
|
||||
"verification_url": verificationURL,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"resume_args": resumeArgs,
|
||||
"hint": initNoWaitHint,
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeAppRegistration runs the non-blocking second step: poll with a device
|
||||
// code from a previous --no-wait call, then persist the new app and probe it.
|
||||
func resumeAppRegistration(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
rec, err := loadInitNoWaitRecord(opts.DeviceCode)
|
||||
if err != nil {
|
||||
// The record exists but could not be read/parsed (permissions, disk,
|
||||
// corruption). The resume step fully depends on this cache, so surface a
|
||||
// storage error instead of the misleading "no pending creation"
|
||||
// validation path — the user should fix local storage, not assume the
|
||||
// device code is bad and throw away a still-valid creation attempt.
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to read the cached resume context: %v", err).WithCause(err)
|
||||
}
|
||||
if rec == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no pending app creation found for this device code; re-initiate with `lark-cli config init --new --no-wait`").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
// Expiry check against the cached absolute deadline (device codes are
|
||||
// short-lived — the registration default is 300s).
|
||||
remaining := rec.ExpiresAt - time.Now().Unix()
|
||||
if remaining <= 0 {
|
||||
_ = removeInitNoWaitRecord(opts.DeviceCode)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"device code expired; re-initiate with `lark-cli config init --new --no-wait`").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
// Drift guard (fast path): bail out before the long poll if the config
|
||||
// already changed since initiation, so we don't waste minutes polling.
|
||||
existing, err := loadConfigForDriftCheck()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if computeConfigDigest(existing) != rec.ConfigDigest {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"configuration changed since this app creation was started; re-initiate with `lark-cli config init --new --no-wait` to avoid overwriting it").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
interval := rec.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
httpClient := newRegistrationHTTPClient()
|
||||
result, pollErr := pollAppRegistrationResume(opts.Ctx, httpClient, opts.DeviceCode, interval, int(remaining), f.IOStreams.ErrOut)
|
||||
if pollErr != nil {
|
||||
// Clear the cache only on terminal failures (denied / expired /
|
||||
// timed-out). Keep it on cancellation or transient errors so the user
|
||||
// can retry with the same device code while it is still valid.
|
||||
if appRegShouldClearCache(pollErr) {
|
||||
_ = removeInitNoWaitRecord(opts.DeviceCode)
|
||||
}
|
||||
// Pass an already-typed error through unchanged (e.g. the ConfigError
|
||||
// for a missing client_id/secret) instead of downgrading it to
|
||||
// authentication/unknown — matching runCreateAppFlow.
|
||||
if _, ok := errs.ProblemOf(pollErr); ok {
|
||||
return pollErr
|
||||
}
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", pollErr).WithCause(pollErr)
|
||||
}
|
||||
|
||||
// Re-check drift immediately before persisting. The poll above can block
|
||||
// for minutes while the user finishes in the browser, and a concurrent
|
||||
// process may have changed config.json in that window — saving the stale
|
||||
// pre-poll snapshot would drop those edits. Reload and compare again.
|
||||
existing, err = loadConfigForDriftCheck()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if computeConfigDigest(existing) != rec.ConfigDigest {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"configuration changed while the app was being created, so it was not saved (to avoid overwriting that change); re-run `lark-cli config init --new --no-wait`").
|
||||
WithParam("--device-code")
|
||||
}
|
||||
|
||||
// Determine the final brand from the response, falling back to the cached
|
||||
// brand. The cached brand only seeds link generation + this fallback; the
|
||||
// Lark-tenant re-poll inside pollAppRegistrationResume is what actually
|
||||
// detects a Lark tenant.
|
||||
finalBrand := parseBrand(rec.Brand)
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
finalBrand = core.BrandLark
|
||||
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
||||
finalBrand = core.BrandFeishu
|
||||
}
|
||||
|
||||
secret, err := core.ForStorage(result.ClientID, core.PlainSecret(result.ClientSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(rec.ProfileName, existing, f, result.ClientID, secret, finalBrand, rec.Lang); err != nil {
|
||||
// Preserve a typed error (e.g. the --name conflict ValidationError) via
|
||||
// the shared helper instead of downgrading everything to storage —
|
||||
// matching the blocking init paths.
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
|
||||
// Config persisted — only now is it safe to drop the resume cache. Clearing
|
||||
// it only after a successful save means a failure in the drift re-check,
|
||||
// ForStorage, or saveInitConfig above leaves the cache intact so the user
|
||||
// can retry `--device-code` (the remote app already exists).
|
||||
_ = removeInitNoWaitRecord(opts.DeviceCode)
|
||||
|
||||
if rec.LangExplicit && rec.Lang != "" {
|
||||
msg := getInitMsg(opts.UILang)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, rec.Lang))
|
||||
}
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.ClientID, "appSecret": "****", "brand": finalBrand})
|
||||
if err := runProbe(opts.Ctx, f, result.ClientID, result.ClientSecret, finalBrand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollAppRegistrationResume polls the registration endpoint (feishu first, then
|
||||
// the lark endpoint on the tenant_brand=lark special case) and returns the raw
|
||||
// error so the caller can classify it for cache-cleanup decisions.
|
||||
func pollAppRegistrationResume(ctx context.Context, httpClient *http.Client, deviceCode string, interval, expiresIn int, errOut io.Writer) (*larkauth.AppRegistrationResult, error) {
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, deviceCode, interval, expiresIn, errOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Lark tenant special case: if tenant_brand=lark and no client_secret,
|
||||
// re-poll against the lark endpoint to obtain the secret.
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, deviceCode, interval, expiresIn, errOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// appRegShouldClearCache reports whether the cached resume context should be
|
||||
// discarded after a poll outcome. Success and terminal failures (user denied,
|
||||
// device code expired, deadline elapsed) clear it; cancellation and transient
|
||||
// errors keep it so the user can retry while the device code is still valid.
|
||||
func appRegShouldClearCache(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, larkauth.ErrAppRegDenied) ||
|
||||
errors.Is(err, larkauth.ErrAppRegExpired) ||
|
||||
errors.Is(err, larkauth.ErrAppRegTimeout)
|
||||
}
|
||||
|
||||
// loadConfigForDriftCheck loads the config for the drift comparison. A missing
|
||||
// config (first-time setup) is fine — it yields a nil config and an empty
|
||||
// digest. A genuine storage failure (permission denied, corruption) is surfaced
|
||||
// as a typed storage error rather than being silently read as "config drift".
|
||||
func loadConfigForDriftCheck() (*core.MultiAppConfig, error) {
|
||||
existing, err := core.LoadMultiAppConfig()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "failed to load config for the drift check: %v", err).WithCause(err)
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
116
cmd/config/init_nowait_cache.go
Normal file
116
cmd/config/init_nowait_cache.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// initNoWaitCacheVersion is the schema version of the cached init context.
|
||||
// Bump it when the record shape changes so stale entries are ignored.
|
||||
const initNoWaitCacheVersion = 1
|
||||
|
||||
// initNoWaitRecord is the context persisted by `config init --new --no-wait` so
|
||||
// that the later `--device-code` step can complete the app creation. It must
|
||||
// never hold a secret, verification URL, or full config — only what the resume
|
||||
// step needs to finish persisting the new app.
|
||||
type initNoWaitRecord struct {
|
||||
Version int `json:"version"`
|
||||
Brand string `json:"brand"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
Lang string `json:"lang"`
|
||||
LangExplicit bool `json:"lang_explicit"`
|
||||
Interval int `json:"interval"`
|
||||
ExpiresAt int64 `json:"expires_at"` // unix seconds; absolute device-code deadline
|
||||
ConfigDigest string `json:"config_digest"`
|
||||
}
|
||||
|
||||
// initNoWaitCacheDir returns the directory used to persist config init
|
||||
// --no-wait context keyed by device_code.
|
||||
func initNoWaitCacheDir() string {
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "config_init_nowait")
|
||||
}
|
||||
|
||||
// initNoWaitCachePath returns the cache file path for a given device_code.
|
||||
func initNoWaitCachePath(deviceCode string) string {
|
||||
return filepath.Join(initNoWaitCacheDir(), initNoWaitCacheKey(deviceCode)+".json")
|
||||
}
|
||||
|
||||
// initNoWaitCacheKey derives a collision-free, filesystem-safe filename token
|
||||
// from an opaque device_code. A sha256 hex digest avoids the collisions a
|
||||
// character-replacement sanitizer would cause (e.g. "a/b" and "a:b" both
|
||||
// mapping to "a_b").
|
||||
func initNoWaitCacheKey(deviceCode string) string {
|
||||
sum := sha256.Sum256([]byte(deviceCode))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// saveInitNoWaitRecord persists the resume context for a device_code.
|
||||
func saveInitNoWaitRecord(deviceCode string, rec initNoWaitRecord) error {
|
||||
if err := vfs.MkdirAll(initNoWaitCacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(initNoWaitCachePath(deviceCode), data, 0600)
|
||||
}
|
||||
|
||||
// loadInitNoWaitRecord loads the resume context for a device_code. It returns
|
||||
// (nil, nil) when no cache entry exists.
|
||||
func loadInitNoWaitRecord(deviceCode string) (*initNoWaitRecord, error) {
|
||||
data, err := vfs.ReadFile(initNoWaitCachePath(deviceCode))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var rec initNoWaitRecord
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
|
||||
return nil, err
|
||||
}
|
||||
if rec.Version != initNoWaitCacheVersion {
|
||||
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
|
||||
return nil, nil
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// removeInitNoWaitRecord deletes the cache entry for a device_code.
|
||||
func removeInitNoWaitRecord(deviceCode string) error {
|
||||
err := vfs.Remove(initNoWaitCachePath(deviceCode))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// computeConfigDigest returns a stable digest of the existing config so the
|
||||
// resume step can detect drift between initiation and completion. The digest
|
||||
// is a hash of config.json content (app IDs, brands, users, secret references)
|
||||
// — it contains no plaintext secret and is safe to cache. A nil config and an
|
||||
// (unexpected) marshal error both map to the empty digest.
|
||||
func computeConfigDigest(existing *core.MultiAppConfig) string {
|
||||
if existing == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
521
cmd/config/init_nowait_test.go
Normal file
521
cmd/config/init_nowait_test.go
Normal file
@@ -0,0 +1,521 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to an http.RoundTripper.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// TestNoWait_InitiateThenResume_EndToEnd drives the full two-step flow against a
|
||||
// real local HTTP server: initiate writes the on-disk cache, then a SEPARATE
|
||||
// resume call polls the same server, succeeds, and persists the new app. Only
|
||||
// the device_code + the cache bridge the two invocations — exactly as the two
|
||||
// CLI commands would. (A black-box binary E2E of the success path is impossible
|
||||
// without a human: endpoints are hardcoded HTTPS and the real device flow needs
|
||||
// a browser scan, so this in-process run through httptest is the highest-fidelity
|
||||
// autonomous end-to-end.)
|
||||
func TestNoWait_InitiateThenResume_EndToEnd(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
switch r.FormValue("action") {
|
||||
case "begin":
|
||||
_, _ = w.Write([]byte(`{"device_code":"E2E-DEVICE-CODE","user_code":"E2E-UC","verification_uri":"https://example.test/verify","expires_in":600,"interval":1}`))
|
||||
case "poll":
|
||||
_, _ = w.Write([]byte(`{"client_id":"cli_e2e","client_secret":"sec_e2e","user_info":{"tenant_brand":"feishu","open_id":"ou_e2e"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected action "+r.FormValue("action"), http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
tsURL, _ := url.Parse(ts.URL)
|
||||
|
||||
// Redirect the registration client to the local test server.
|
||||
orig := newRegistrationHTTPClient
|
||||
newRegistrationHTTPClient = func() *http.Client {
|
||||
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
r.URL.Scheme, r.URL.Host = tsURL.Scheme, tsURL.Host
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
})}
|
||||
}
|
||||
t.Cleanup(func() { newRegistrationHTTPClient = orig })
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
// Step 1 — initiate: should print device_code and write the resume cache.
|
||||
initOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true}
|
||||
if err := initiateNoWaitAppRegistration(initOpts, nil); err != nil {
|
||||
t.Fatalf("initiate: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("initiate stdout not JSON: %v; raw=%s", err, stdout.String())
|
||||
}
|
||||
if out["device_code"] != "E2E-DEVICE-CODE" {
|
||||
t.Fatalf("device_code = %v, want E2E-DEVICE-CODE", out["device_code"])
|
||||
}
|
||||
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec == nil {
|
||||
t.Fatal("initiate did not write the resume cache")
|
||||
}
|
||||
|
||||
// Step 2 — resume (separate invocation; bridged only by device_code + cache).
|
||||
stdout.Reset()
|
||||
resumeOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "E2E-DEVICE-CODE"}
|
||||
if err := resumeAppRegistration(resumeOpts); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
|
||||
// The new app must be persisted to config...
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
if err != nil || cfg == nil {
|
||||
t.Fatalf("config not persisted: %v", err)
|
||||
}
|
||||
if app := cfg.CurrentAppConfig(""); app == nil || app.AppId != "cli_e2e" {
|
||||
t.Fatalf("persisted app = %+v, want AppId cli_e2e", app)
|
||||
}
|
||||
// ...the cache cleared after the successful save...
|
||||
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec != nil {
|
||||
t.Error("resume should clear the cache after a successful save")
|
||||
}
|
||||
// ...and the success JSON emitted.
|
||||
if !strings.Contains(stdout.String(), "cli_e2e") {
|
||||
t.Errorf("resume stdout missing appId: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// stubRT returns a single canned HTTP response for every request.
|
||||
type stubRT struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (s stubRT) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: s.status, Body: io.NopCloser(strings.NewReader(s.body)), Header: make(http.Header)}, nil
|
||||
}
|
||||
|
||||
// seqRT returns successive canned responses (last one repeats), for flows that
|
||||
// poll more than once (e.g. the Lark-tenant re-poll).
|
||||
type seqRT struct {
|
||||
bodies []string
|
||||
i int
|
||||
}
|
||||
|
||||
func (s *seqRT) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
idx := s.i
|
||||
if idx >= len(s.bodies) {
|
||||
idx = len(s.bodies) - 1
|
||||
}
|
||||
s.i++
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(s.bodies[idx])), Header: make(http.Header)}, nil
|
||||
}
|
||||
|
||||
// withStubRegistrationClient swaps the registration HTTP client for the test.
|
||||
func withStubRegistrationClient(t *testing.T, rt http.RoundTripper) {
|
||||
t.Helper()
|
||||
orig := newRegistrationHTTPClient
|
||||
newRegistrationHTTPClient = func() *http.Client { return &http.Client{Transport: rt} }
|
||||
t.Cleanup(func() { newRegistrationHTTPClient = orig })
|
||||
}
|
||||
|
||||
// --- cache round-trip ---
|
||||
|
||||
func TestInitNoWaitCache_RoundTrip(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
ProfileName: "work",
|
||||
Lang: "zh_cn",
|
||||
LangExplicit: true,
|
||||
Interval: 5,
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: "abc123",
|
||||
}
|
||||
const dc = "device-code-xyz"
|
||||
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := loadInitNoWaitRecord(dc)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("load returned nil for a saved record")
|
||||
}
|
||||
if *got != rec {
|
||||
t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, rec)
|
||||
}
|
||||
|
||||
if err := removeInitNoWaitRecord(dc); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
got2, err := loadInitNoWaitRecord(dc)
|
||||
if err != nil {
|
||||
t.Fatalf("load after remove: %v", err)
|
||||
}
|
||||
if got2 != nil {
|
||||
t.Errorf("expected nil after remove, got %+v", got2)
|
||||
}
|
||||
// Removing a non-existent record must be a no-op, not an error.
|
||||
if err := removeInitNoWaitRecord(dc); err != nil {
|
||||
t.Errorf("remove of missing record should be nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoWaitCache_LoadMissing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
got, err := loadInitNoWaitRecord("never-saved")
|
||||
if err != nil {
|
||||
t.Fatalf("load missing: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for missing record, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoWaitCache_VersionMismatchIgnored(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
const dc = "stale-version"
|
||||
rec := initNoWaitRecord{Version: initNoWaitCacheVersion + 1, ExpiresAt: time.Now().Unix() + 300}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := loadInitNoWaitRecord(dc)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for version mismatch, got %+v", got)
|
||||
}
|
||||
// The stale entry should have been discarded by the load.
|
||||
got2, _ := loadInitNoWaitRecord(dc)
|
||||
if got2 != nil {
|
||||
t.Errorf("stale-version entry was not removed on load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoWaitCacheKey(t *testing.T) {
|
||||
// Distinct device codes that a char-replacement sanitizer would collide
|
||||
// ("a/b" and "a:b" -> "a_b") must map to distinct keys.
|
||||
if initNoWaitCacheKey("a/b") == initNoWaitCacheKey("a:b") {
|
||||
t.Error("distinct device codes must not collide on the cache key")
|
||||
}
|
||||
// Deterministic.
|
||||
if initNoWaitCacheKey("xyz") != initNoWaitCacheKey("xyz") {
|
||||
t.Error("cache key must be deterministic")
|
||||
}
|
||||
// sha256 hex: 64 chars, filesystem-safe regardless of input.
|
||||
k := initNoWaitCacheKey("has /, :, ;, spaces and 'quotes'")
|
||||
if len(k) != 64 {
|
||||
t.Errorf("expected 64-char sha256 hex key, got %d: %q", len(k), k)
|
||||
}
|
||||
}
|
||||
|
||||
// --- config digest ---
|
||||
|
||||
func TestComputeConfigDigest(t *testing.T) {
|
||||
if d := computeConfigDigest(nil); d != "" {
|
||||
t.Errorf("nil digest = %q, want empty", d)
|
||||
}
|
||||
cfg1 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
|
||||
cfg1Dup := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
|
||||
cfg2 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_b", Brand: core.BrandFeishu}}}
|
||||
|
||||
if computeConfigDigest(cfg1) == "" {
|
||||
t.Error("non-nil config digest should be non-empty")
|
||||
}
|
||||
if computeConfigDigest(cfg1) != computeConfigDigest(cfg1Dup) {
|
||||
t.Error("equal configs should produce equal digests")
|
||||
}
|
||||
if computeConfigDigest(cfg1) == computeConfigDigest(cfg2) {
|
||||
t.Error("different configs should produce different digests")
|
||||
}
|
||||
}
|
||||
|
||||
// --- failure classification for cache cleanup ---
|
||||
|
||||
func TestAppRegShouldClearCache(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"success", nil, true},
|
||||
{"denied", larkauth.ErrAppRegDenied, true},
|
||||
{"expired", larkauth.ErrAppRegExpired, true},
|
||||
{"expired wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegExpired), true},
|
||||
{"timeout", larkauth.ErrAppRegTimeout, true},
|
||||
{"timeout wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegTimeout), true},
|
||||
{"cancelled", larkauth.ErrAppRegCancelled, false},
|
||||
{"transient generic", fmt.Errorf("network boom"), false},
|
||||
{"missing fields", fmt.Errorf("app registration succeeded but missing client_id or client_secret"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := appRegShouldClearCache(c.err); got != c.want {
|
||||
t.Errorf("%s: appRegShouldClearCache = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- initiate (stubbed registration client) ---
|
||||
|
||||
func TestInitiateNoWaitAppRegistration_WritesCacheAndJSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
withStubRegistrationClient(t, stubRT{200, `{"device_code":"dc-abc","user_code":"U-1","verification_uri":"https://open.feishu.cn","expires_in":3600,"interval":5}`})
|
||||
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true, ForceInit: true}
|
||||
if err := initiateNoWaitAppRegistration(opts, nil); err != nil {
|
||||
t.Fatalf("initiate: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("stdout not JSON: %v; raw=%s", err, stdout.String())
|
||||
}
|
||||
if out["device_code"] != "dc-abc" {
|
||||
t.Errorf("device_code = %v, want dc-abc", out["device_code"])
|
||||
}
|
||||
args, ok := out["resume_args"].([]interface{})
|
||||
if !ok || len(args) == 0 || args[len(args)-1] != "--force-init" {
|
||||
t.Errorf("resume_args should end with --force-init, got %v", out["resume_args"])
|
||||
}
|
||||
|
||||
rec, _ := loadInitNoWaitRecord("dc-abc")
|
||||
if rec == nil {
|
||||
t.Fatal("cache record not written")
|
||||
}
|
||||
if rec.Brand != "feishu" || rec.Version != initNoWaitCacheVersion {
|
||||
t.Errorf("cache record = %+v", *rec)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pollAppRegistrationResume (stubbed client) ---
|
||||
|
||||
func TestPollAppRegistrationResume_Success(t *testing.T) {
|
||||
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`}}
|
||||
res, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistrationResume_MissingSecret(t *testing.T) {
|
||||
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x"}`}}
|
||||
if _, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard); err == nil {
|
||||
t.Error("expected error when client_secret is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistrationResume_LarkRetry(t *testing.T) {
|
||||
// First poll (feishu endpoint): lark tenant, no secret -> triggers re-poll
|
||||
// against the lark endpoint, which returns the secret.
|
||||
rt := &seqRT{bodies: []string{
|
||||
`{"client_id":"cli_x","client_secret":"","user_info":{"tenant_brand":"lark"}}`,
|
||||
`{"client_id":"cli_x","client_secret":"larksec","user_info":{"tenant_brand":"lark"}}`,
|
||||
}}
|
||||
res, err := pollAppRegistrationResume(context.Background(), &http.Client{Transport: rt}, "dc", 0, 60, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.ClientSecret != "larksec" {
|
||||
t.Errorf("expected lark re-poll to yield the secret, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// Full resume happy path: stubbed poll succeeds, the app is persisted, and the
|
||||
// cache is cleared. (runProbe hits the factory's mock client, which has no stub
|
||||
// and returns an untyped error that runProbe swallows.)
|
||||
func TestResumeAppRegistration_Success(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
|
||||
|
||||
const dc = "resume-ok"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
Interval: 1, // keep the single poll fast
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: computeConfigDigest(nil),
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
if err := resumeAppRegistration(opts); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
|
||||
cfg, _ := core.LoadMultiAppConfig()
|
||||
if cfg == nil || cfg.CurrentAppConfig("") == nil || cfg.CurrentAppConfig("").AppId != "cli_new" {
|
||||
t.Errorf("config not persisted with new app id: %+v", cfg)
|
||||
}
|
||||
if got, _ := loadInitNoWaitRecord(dc); got != nil {
|
||||
t.Error("cache should be cleared after a successful save")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "cli_new") {
|
||||
t.Errorf("stdout missing new appId: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A profile-name conflict on the resume save path must surface as the typed
|
||||
// ValidationError(--name), not be downgraded to an internal/storage error.
|
||||
func TestResumeAppRegistration_ProfileNameConflict_PreservesValidationError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
|
||||
|
||||
// Seed a config whose app id collides with the profile name we resume into.
|
||||
seeded := &core.MultiAppConfig{Apps: []core.AppConfig{
|
||||
{AppId: "cli_existing", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu},
|
||||
}}
|
||||
if err := core.SaveMultiAppConfig(seeded); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
loaded, _ := core.LoadMultiAppConfig() // digest must match what resume recomputes
|
||||
|
||||
const dc = "conflict-dc"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
ProfileName: "cli_existing", // collides with the existing appId in saveAsProfile
|
||||
Interval: 1,
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: computeConfigDigest(loaded),
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save cache: %v", err)
|
||||
}
|
||||
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--name")
|
||||
}
|
||||
|
||||
// --- flag validation (returns before any network) ---
|
||||
|
||||
func TestConfigInitRun_NoWaitAndDeviceCodeMutuallyExclusive(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, DeviceCode: "x"}
|
||||
assertValidationParam(t, configInitRun(opts), "--device-code")
|
||||
}
|
||||
|
||||
func TestConfigInitRun_NoWaitWithAppIDRejected(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, AppID: "cli_x"}
|
||||
assertValidationParam(t, configInitRun(opts), "--no-wait")
|
||||
}
|
||||
|
||||
// The conflict error must point at the flag the caller actually passed: with
|
||||
// --device-code (not --no-wait) + --app-id, remediation should name --device-code.
|
||||
func TestConfigInitRun_DeviceCodeWithAppIDReportsDeviceCode(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "dc", AppID: "cli_x"}
|
||||
assertValidationParam(t, configInitRun(opts), "--device-code")
|
||||
}
|
||||
|
||||
// --- resume guards (return before any network) ---
|
||||
|
||||
func TestResumeAppRegistration_NoCacheEntry(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "missing-dc"}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
|
||||
}
|
||||
|
||||
func TestResumeAppRegistration_ExpiredClearsCache(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
const dc = "expired-dc"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
Interval: 5,
|
||||
ExpiresAt: time.Now().Unix() - 10, // already past
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
|
||||
|
||||
if got, _ := loadInitNoWaitRecord(dc); got != nil {
|
||||
t.Error("expired cache entry should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
// A cache file that exists but cannot be parsed is a storage failure, not a
|
||||
// "no pending creation" validation error — the user should fix storage rather
|
||||
// than assume the device code is bad.
|
||||
func TestResumeAppRegistration_CorruptCacheIsStorageError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
const dc = "corrupt-dc"
|
||||
if err := os.MkdirAll(initNoWaitCacheDir(), 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(initNoWaitCachePath(dc), []byte("{ not valid json"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
err := resumeAppRegistration(opts)
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError for unreadable cache, got %T: %v", err, err)
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeStorage {
|
||||
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeStorage, p)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("expected the underlying cache-read failure to be preserved as a cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeAppRegistration_ConfigDrift(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
const dc = "drift-dc"
|
||||
rec := initNoWaitRecord{
|
||||
Version: initNoWaitCacheVersion,
|
||||
Brand: "feishu",
|
||||
Interval: 5,
|
||||
ExpiresAt: time.Now().Unix() + 300,
|
||||
ConfigDigest: "stale-digest-that-will-not-match-current-config",
|
||||
}
|
||||
if err := saveInitNoWaitRecord(dc, rec); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
|
||||
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
@@ -91,32 +90,3 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runProbePKJWT does a best-effort key-binding validation after a private_key_jwt
|
||||
// config is saved: it signs a client_assertion with the local platform key and
|
||||
// mints a token. A typed error (a deterministic server rejection — e.g. the key
|
||||
// is not bound to this app) is propagated so `config init` exits non-zero with
|
||||
// the canonical envelope; untyped errors (transport / HTTP / parse / timeout)
|
||||
// are swallowed (return nil). The mint itself is the probe — no second call.
|
||||
func runProbePKJWT(parent context.Context, factory *cmdutil.Factory, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) error {
|
||||
if factory == nil || signer == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := credential.FetchTATWithAssertion(ctx, httpClient, brand, clientID, signer, keyLabel); err != nil {
|
||||
// Typed = deterministic credential rejection → propagate. Untyped
|
||||
// (transport / HTTP / parse / timeout) is ambiguous → stay silent.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ package config
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,17 +17,14 @@ import (
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
oauthHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
oauthCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
@@ -56,50 +48,10 @@ func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/authen/v2/oauth/token"):
|
||||
f.oauthCalls++
|
||||
if f.oauthHandler == nil {
|
||||
return jsonResp(200, `{"access_token":"t-jwt"}`), nil
|
||||
}
|
||||
return f.oauthHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
// probeTestSigner is an in-memory real ECDSA P-256 signer used to sign the
|
||||
// client_assertion in runProbePKJWT tests (authMethodTestSigner returns a nil
|
||||
// key and cannot sign).
|
||||
type probeTestSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newProbeTestSigner(t *testing.T) *probeTestSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &probeTestSigner{key: k}
|
||||
}
|
||||
|
||||
func (p *probeTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return p.key.Public(), nil
|
||||
}
|
||||
|
||||
func (p *probeTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return p.key.Public(), nil
|
||||
}
|
||||
|
||||
func (p *probeTestSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(crand.Reader, p.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
@@ -333,42 +285,3 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
// runProbePKJWT: a deterministic server rejection (invalid_client) is propagated
|
||||
// as a typed ConfigError so config init exits non-zero.
|
||||
func TestRunProbePKJWT_DeterministicReject_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
|
||||
return jsonResp(401, `{"error":"invalid_client","error_description":"unknown key"}`), nil
|
||||
}}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key")
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected propagated typed error, got %T %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbePKJWT must not write stderr, got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// runProbePKJWT: ambiguous upstream noise (HTTP 503) is swallowed — silent, exit 0.
|
||||
func TestRunProbePKJWT_Ambiguous_Silent(t *testing.T) {
|
||||
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
|
||||
return jsonResp(503, `unavailable`), nil
|
||||
}}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
|
||||
}
|
||||
|
||||
// runProbePKJWT: a successful mint returns nil.
|
||||
func TestRunProbePKJWT_Success_Silent(t *testing.T) {
|
||||
rt := &fakeRT{} // default oauth handler returns 200 + access_token
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
|
||||
}
|
||||
|
||||
// runProbePKJWT: a nil signer is a defensive no-op (should not be reached, must
|
||||
// not panic).
|
||||
func TestRunProbePKJWT_NilSigner_Silent(t *testing.T) {
|
||||
f, errBuf := fakeFactory(t, &fakeRT{})
|
||||
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", nil, "k"), errBuf)
|
||||
}
|
||||
|
||||
@@ -10,25 +10,9 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestRunRestoreFlow_NothingToRestore covers the early guards that return before
|
||||
// any network/registration call: no config at all, and a config whose resolved
|
||||
// app has no app id (nothing to send on begin).
|
||||
func TestRunRestoreFlow_NothingToRestore(t *testing.T) {
|
||||
// No config on disk.
|
||||
if err := runRestoreFlow(&ConfigInitOptions{}, nil, nil, nil); err == nil {
|
||||
t.Fatal("expected error when there is no config to restore")
|
||||
}
|
||||
// Config present but the resolved app has no app id.
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: ""}}}
|
||||
if err := runRestoreFlow(&ConfigInitOptions{}, existing, nil, nil); err == nil {
|
||||
t.Fatal("expected error when the resolved app has no app id")
|
||||
}
|
||||
}
|
||||
|
||||
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
||||
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
|
||||
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
|
||||
@@ -135,62 +119,3 @@ func assertValidationParam(t *testing.T, err error, wantParam string) {
|
||||
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
// countingKeychain is an in-memory KeychainAccess that records whether Remove
|
||||
// was invoked, so the stale-secret cleanup can be asserted without a real OS
|
||||
// keychain.
|
||||
type countingKeychain struct {
|
||||
store map[string]string
|
||||
removeCalled bool
|
||||
}
|
||||
|
||||
func newCountingKeychain() *countingKeychain {
|
||||
return &countingKeychain{store: map[string]string{}}
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Get(service, account string) (string, error) {
|
||||
v, ok := k.store[service+"/"+account]
|
||||
if !ok {
|
||||
return "", keychain.ErrNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Set(service, account, value string) error {
|
||||
k.store[service+"/"+account] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Remove(service, account string) error {
|
||||
k.removeCalled = true
|
||||
delete(k.store, service+"/"+account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRemoveStaleSecretForPKJWT_SameAppID(t *testing.T) {
|
||||
kc := newCountingKeychain()
|
||||
ref, err := core.ForStorage("cli_same", core.PlainSecret("old-secret"), kc) // → Source:"keychain"
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_same", AppSecret: ref}}}
|
||||
removeStaleSecretForPKJWT(existing, "", "cli_same", kc)
|
||||
if !kc.removeCalled {
|
||||
t.Error("same appId with keychain secret: expected kc.Remove to be invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStaleSecretForPKJWT_DifferentAppID(t *testing.T) {
|
||||
kc := newCountingKeychain()
|
||||
ref, _ := core.ForStorage("cli_old", core.PlainSecret("old-secret"), kc)
|
||||
kc.removeCalled = false // ForStorage does not call Remove, but reset to be safe
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_old", AppSecret: ref}}}
|
||||
removeStaleSecretForPKJWT(existing, "", "cli_new", kc)
|
||||
if kc.removeCalled {
|
||||
t.Error("different appId: must NOT remove")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStaleSecretForPKJWT_NilExisting(t *testing.T) {
|
||||
removeStaleSecretForPKJWT(nil, "", "cli_x", newCountingKeychain()) // must not panic
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -134,9 +132,6 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 3b. private_key_jwt / TEE signer (local; runs even with --offline) ──
|
||||
checks = append(checks, teeSignerCheck(opts.Ctx, cfg))
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
@@ -150,54 +145,6 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
const teeUnavailableHint = "ensure the device secure hardware is accessible (Linux TPM: add your user to the 'tss' group or run with sufficient privileges)"
|
||||
|
||||
// teeSignerCheck reports the private_key_jwt signing backend (TEE/TPM) status.
|
||||
// The probe is local hardware only (no network), so it runs even with --offline;
|
||||
// in a build without a TEE signer it short-circuits without touching any
|
||||
// hardware. It is a hard requirement for private_key_jwt apps and purely
|
||||
// informational for client_secret apps.
|
||||
func teeSignerCheck(ctx context.Context, cfg *core.CliConfig) checkResult {
|
||||
usesPKJWT := cfg != nil && cfg.AuthMethod == core.AuthMethodPrivateKeyJWT
|
||||
info, ok, err := keysigner.ProbeActiveHardware(ctx)
|
||||
return teeCheckResult(info, ok, err, usesPKJWT)
|
||||
}
|
||||
|
||||
// teeCheckResult maps a hardware probe to a doctor check. Split out from
|
||||
// teeSignerCheck so the full matrix is unit-testable without a TPM.
|
||||
func teeCheckResult(info keysigner.HardwareInfo, ok bool, probeErr error, usesPKJWT bool) checkResult {
|
||||
const name = "tee_signer"
|
||||
|
||||
// No signer registered → private_key_jwt is unsupported on this build.
|
||||
if !ok {
|
||||
if usesPKJWT {
|
||||
return fail(name,
|
||||
"app uses private_key_jwt but this build has no TEE key signer",
|
||||
"the platform key signer ships by default on macOS, Linux, and Windows/amd64; this platform (e.g. Windows/arm64) has none — use a supported platform or re-register with --auth-method client_secret")
|
||||
}
|
||||
return skip(name, "no TEE signer in this build (only private_key_jwt is affected; client_secret is unaffected)")
|
||||
}
|
||||
|
||||
backend := info.Backend
|
||||
if backend == "" {
|
||||
backend = "tee"
|
||||
}
|
||||
|
||||
switch {
|
||||
case probeErr != nil:
|
||||
return warn(name, fmt.Sprintf("%s signer present but probe errored: %s", backend, probeErr), "")
|
||||
case info.Available:
|
||||
if info.VendorName != "" {
|
||||
return pass(name, fmt.Sprintf("%s TEE available (%s)", backend, info.VendorName))
|
||||
}
|
||||
return pass(name, fmt.Sprintf("%s TEE available", backend))
|
||||
case usesPKJWT:
|
||||
return fail(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
|
||||
default:
|
||||
return warn(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
|
||||
}
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -287,90 +234,14 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
}
|
||||
}
|
||||
|
||||
workspace := core.CurrentWorkspace().Display()
|
||||
// A terminal on STDOUT gets a readable report; pipes, redirects, scripts and
|
||||
// tests keep the stable JSON contract (NO_COLOR disables ANSI styling).
|
||||
// StdoutIsTerminal checks stdout specifically — IOStreams.IsTerminal reflects
|
||||
// stdin, which would wrongly send the human report into `doctor | jq`.
|
||||
if f.IOStreams.StdoutIsTerminal() {
|
||||
renderDoctorHuman(f.IOStreams.Out, workspace, checks, allOK, os.Getenv("NO_COLOR") == "")
|
||||
} else {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"workspace": workspace,
|
||||
"checks": checks,
|
||||
})
|
||||
result := map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"checks": checks,
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
if !allOK {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderDoctorHuman writes a readable health report: one aligned line per check
|
||||
// with a colored status tag, an indented hint when present, and a summary line.
|
||||
func renderDoctorHuman(w io.Writer, workspace string, checks []checkResult, allOK, color bool) {
|
||||
const (
|
||||
green = "\033[32m"
|
||||
yellow = "\033[33m"
|
||||
red = "\033[31m"
|
||||
gray = "\033[90m"
|
||||
bold = "\033[1m"
|
||||
reset = "\033[0m"
|
||||
)
|
||||
colorOf := map[string]string{"pass": green, "warn": yellow, "fail": red, "skip": gray}
|
||||
tagOf := map[string]string{"pass": "PASS", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"}
|
||||
paint := func(code, s string) string {
|
||||
if !color || code == "" {
|
||||
return s
|
||||
}
|
||||
return code + s + reset
|
||||
}
|
||||
|
||||
nameW := 0
|
||||
for _, c := range checks {
|
||||
if len(c.Name) > nameW {
|
||||
nameW = len(c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n%s (workspace: %s)\n\n", paint(bold, "lark-cli doctor"), workspace)
|
||||
|
||||
var passN, warnN, failN, skipN int
|
||||
for _, c := range checks {
|
||||
tag := tagOf[c.Status]
|
||||
if tag == "" {
|
||||
tag = "????"
|
||||
}
|
||||
fmt.Fprintf(w, " %s %-*s %s\n", paint(colorOf[c.Status], "["+tag+"]"), nameW, c.Name, c.Message)
|
||||
if c.Hint != "" {
|
||||
fmt.Fprintf(w, " %-*s %s\n", nameW, "", paint(gray, "↳ "+c.Hint))
|
||||
}
|
||||
switch c.Status {
|
||||
case "pass":
|
||||
passN++
|
||||
case "warn":
|
||||
warnN++
|
||||
case "fail":
|
||||
failN++
|
||||
case "skip":
|
||||
skipN++
|
||||
}
|
||||
}
|
||||
|
||||
headline := paint(green, "healthy")
|
||||
if !allOK {
|
||||
headline = paint(red, "problems found")
|
||||
}
|
||||
fmt.Fprintf(w, "\n %s — %d passed", headline, passN)
|
||||
if warnN > 0 {
|
||||
fmt.Fprintf(w, ", %d warning(s)", warnN)
|
||||
}
|
||||
if failN > 0 {
|
||||
fmt.Fprintf(w, ", %d failed", failN)
|
||||
}
|
||||
if skipN > 0 {
|
||||
fmt.Fprintf(w, ", %d skipped", skipN)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,14 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -143,107 +139,6 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func TestTeeCheckResult(t *testing.T) {
|
||||
avail := keysigner.HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
|
||||
unavail := keysigner.HardwareInfo{Backend: "tpm2", Reason: "open /dev/tpmrm0: permission denied"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
info keysigner.HardwareInfo
|
||||
ok bool
|
||||
probeErr error
|
||||
pkjwt bool
|
||||
want string
|
||||
}{
|
||||
{"no signer + private_key_jwt → fail", keysigner.HardwareInfo{}, false, nil, true, "fail"},
|
||||
{"no signer + client_secret → skip", keysigner.HardwareInfo{}, false, nil, false, "skip"},
|
||||
{"available + private_key_jwt → pass", avail, true, nil, true, "pass"},
|
||||
{"available + client_secret → pass", avail, true, nil, false, "pass"},
|
||||
{"unavailable + private_key_jwt → fail", unavail, true, nil, true, "fail"},
|
||||
{"unavailable + client_secret → warn", unavail, true, nil, false, "warn"},
|
||||
{"probe error → warn", keysigner.HardwareInfo{Backend: "tpm2"}, true, errors.New("boom"), true, "warn"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := teeCheckResult(tc.info, tc.ok, tc.probeErr, tc.pkjwt)
|
||||
if got.Name != "tee_signer" {
|
||||
t.Errorf("name = %q, want tee_signer", got.Name)
|
||||
}
|
||||
if got.Status != tc.want {
|
||||
t.Errorf("status = %q, want %q (msg=%q)", got.Status, tc.want, got.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorRun_TeeSignerWired proves the tee_signer check is part of doctorRun.
|
||||
// It asserts the build-independent invariant (a client_secret app must never
|
||||
// FAIL on TEE) so the test passes whether or not a signer is compiled in.
|
||||
func TestDoctorRun_TeeSignerWired(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default", AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
var c *checkResult
|
||||
for i := range got.Checks {
|
||||
if got.Checks[i].Name == "tee_signer" {
|
||||
c = &got.Checks[i]
|
||||
}
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatalf("tee_signer check not present in doctor output: %#v", got.Checks)
|
||||
}
|
||||
if c.Status == "fail" {
|
||||
t.Errorf("tee_signer = fail for a client_secret app; want skip/warn/pass (msg=%q)", c.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDoctorHuman(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
checks := []checkResult{
|
||||
pass("cli_version", "1.0.50"),
|
||||
warn("tee_signer", "tpm2 signer present but TEE unavailable", "add your user to the 'tss' group"),
|
||||
fail("identity_ready", "no usable identity", "run: lark-cli auth status --verify"),
|
||||
skip("endpoint_open", "skipped (--offline)"),
|
||||
}
|
||||
renderDoctorHuman(&buf, "local", checks, false, false)
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"lark-cli doctor", "workspace: local",
|
||||
"[PASS]", "cli_version", "1.0.50",
|
||||
"[WARN]", "tee_signer", "↳ add your user to the 'tss' group",
|
||||
"[FAIL]", "identity_ready", "↳ run: lark-cli auth status --verify",
|
||||
"[SKIP]", "endpoint_open",
|
||||
"problems found", "1 passed", "1 warning(s)", "1 failed", "1 skipped",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "\033[") {
|
||||
t.Errorf("color=false but ANSI escapes present:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
|
||||
@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -55,10 +55,8 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
var content string
|
||||
if msg.MessageType == "interactive" {
|
||||
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
|
||||
} else {
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
|
||||
18
go.mod
18
go.mod
@@ -7,8 +7,6 @@ require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
|
||||
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
@@ -29,10 +27,7 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/ebitengine/purego v0.10.1
|
||||
|
||||
require (
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
@@ -47,23 +42,12 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-ole/go-ole v1.2.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/btree v1.0.1 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.2 // indirect
|
||||
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
|
||||
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
|
||||
github.com/google/go-attestation v0.5.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/go-tspi v0.3.0 // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -73,12 +57,10 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
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/crypto v0.31.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,9 +14,24 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by PollAppRegistration so callers can classify a
|
||||
// failure (e.g. to decide whether a cached device code should be discarded)
|
||||
// via errors.Is without parsing message strings.
|
||||
var (
|
||||
// ErrAppRegDenied means the user rejected the app registration.
|
||||
ErrAppRegDenied = errors.New("app registration denied by user")
|
||||
// ErrAppRegExpired means the device code is no longer valid.
|
||||
ErrAppRegExpired = errors.New("device code expired")
|
||||
// ErrAppRegCancelled means polling was cancelled via the context.
|
||||
ErrAppRegCancelled = errors.New("polling was cancelled")
|
||||
// ErrAppRegTimeout means the local polling deadline elapsed.
|
||||
ErrAppRegTimeout = errors.New("app registration timed out")
|
||||
)
|
||||
|
||||
// AppRegistrationResponse is the response from the app registration begin endpoint.
|
||||
type AppRegistrationResponse struct {
|
||||
DeviceCode string
|
||||
@@ -31,11 +47,6 @@ type AppRegistrationResult struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
UserInfo *AppRegUserInfo
|
||||
// AuthMethods is the authoritative auth method(s) the app must use, as
|
||||
// decided by the user/admin at confirmation (20260409 `auth_method` field).
|
||||
// It may differ from what the client requested — e.g. selecting an existing
|
||||
// client_secret app. Empty on older servers.
|
||||
AuthMethods []string
|
||||
}
|
||||
|
||||
// AppRegUserInfo contains user info returned from app registration.
|
||||
@@ -44,81 +55,8 @@ type AppRegUserInfo struct {
|
||||
TenantBrand string // "feishu" or "lark"
|
||||
}
|
||||
|
||||
// AppRegistrationInit is the response from the app registration init endpoint.
|
||||
type AppRegistrationInit struct {
|
||||
Nonce string
|
||||
SupportedAuthMethods []string // e.g. ["client_secret", "private_key_jwt"]
|
||||
}
|
||||
|
||||
// AppRegistrationBeginOptions parametrizes the registration begin request.
|
||||
// A zero value selects the legacy client_secret flow, preserving prior behavior.
|
||||
type AppRegistrationBeginOptions struct {
|
||||
AuthMethod string // "" => client_secret; core.AuthMethodPrivateKeyJWT
|
||||
AuthAttestation string // private_key_jwt: the TEE-signed attestation JWT
|
||||
RestoreAppID string // when set, asks the server to re-register this existing app
|
||||
}
|
||||
|
||||
// RequestAppRegistrationInit performs the init step of the registration flow,
|
||||
// returning a server nonce (to be embedded in a TEE-signed attestation JWT) and
|
||||
// the auth methods the server supports for this archetype.
|
||||
func RequestAppRegistrationInit(httpClient *http.Client) (*AppRegistrationInit, error) {
|
||||
// Registration always begins against the feishu accounts host (mirrors begin).
|
||||
endpoint := core.ResolveEndpoints(core.BrandFeishu).Accounts + PathAppRegistration
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "init")
|
||||
form.Set("archetype", "PersonalAgent")
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("app registration init failed: read body: %w", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("app registration init failed: HTTP %d – response not JSON", resp.StatusCode)
|
||||
}
|
||||
|
||||
if _, hasError := data["error"]; resp.StatusCode >= 400 || hasError {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "error")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "Unknown error"
|
||||
}
|
||||
return nil, fmt.Errorf("app registration init failed: %s", msg)
|
||||
}
|
||||
|
||||
out := &AppRegistrationInit{Nonce: getStr(data, "nonce")}
|
||||
if methods, ok := data["supported_auth_methods"].([]interface{}); ok {
|
||||
for _, m := range methods {
|
||||
if s, ok := m.(string); ok {
|
||||
out.SupportedAuthMethods = append(out.SupportedAuthMethods, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.Nonce == "" {
|
||||
return nil, fmt.Errorf("app registration init failed: server returned no nonce")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RequestAppRegistration initiates the app registration device flow (begin step).
|
||||
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts AppRegistrationBeginOptions, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
// RequestAppRegistration initiates the app registration device flow.
|
||||
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
@@ -127,24 +65,11 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
|
||||
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
|
||||
endpoint := regEp.Accounts + PathAppRegistration
|
||||
|
||||
authMethod := opts.AuthMethod
|
||||
if authMethod == "" {
|
||||
authMethod = core.AuthMethodClientSecret
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "begin")
|
||||
form.Set("archetype", "PersonalAgent")
|
||||
form.Set("auth_method", authMethod)
|
||||
form.Set("auth_method", "client_secret")
|
||||
form.Set("request_user_info", "open_id tenant_brand")
|
||||
if opts.AuthAttestation != "" {
|
||||
form.Set("auth_attestation", opts.AuthAttestation)
|
||||
}
|
||||
// Restore flow: carry the existing app id so the server re-registers it
|
||||
// rather than creating a new app.
|
||||
if opts.RestoreAppID != "" {
|
||||
form.Set("app_id", opts.RestoreAppID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
@@ -154,7 +79,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "app registration request failed: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
@@ -186,24 +111,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
|
||||
|
||||
userCode := getStr(data, "user_code")
|
||||
verificationUri := getStr(data, "verification_uri")
|
||||
// Prefer the server-provided complete URL (currently /page/launcher); fall
|
||||
// back to building it from verification_uri, then to /page/launcher. The old
|
||||
// hard-coded /page/cli is stale — the server now returns /page/launcher.
|
||||
verificationUriComplete := getStr(data, "verification_uri_complete")
|
||||
if verificationUriComplete == "" {
|
||||
base := verificationUri
|
||||
if base == "" {
|
||||
base = ep.Open + "/page/launcher"
|
||||
}
|
||||
// The server may return verification_uri with its own query (e.g.
|
||||
// client_id when registering against an existing app), so join with
|
||||
// the same ?/& logic as BuildVerificationURL.
|
||||
sep := "?"
|
||||
if strings.Contains(base, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
verificationUriComplete = base + sep + "user_code=" + url.QueryEscape(userCode)
|
||||
}
|
||||
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
|
||||
|
||||
return &AppRegistrationResponse{
|
||||
DeviceCode: getStr(data, "device_code"),
|
||||
@@ -215,26 +123,6 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseAuthMethods normalizes the poll response `auth_method` field, which the
|
||||
// server returns as a JSON array of strings (e.g. ["private_key_jwt"]) — or, on
|
||||
// some variants, a single space-separated string.
|
||||
func parseAuthMethods(v interface{}) []string {
|
||||
switch t := v.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, m := range t {
|
||||
if s, ok := m.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Fields(t)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
|
||||
func BuildVerificationURL(baseURL, cliVersion string) string {
|
||||
sep := "&"
|
||||
@@ -266,13 +154,13 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
for time.Now().Before(deadline) && attempts < maxPollAttempts {
|
||||
attempts++
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("polling was cancelled")
|
||||
return nil, ErrAppRegCancelled
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(currentInterval) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("polling was cancelled")
|
||||
return nil, ErrAppRegCancelled
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
@@ -315,7 +203,6 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
result := &AppRegistrationResult{
|
||||
ClientID: getStr(data, "client_id"),
|
||||
ClientSecret: getStr(data, "client_secret"),
|
||||
AuthMethods: parseAuthMethods(data["auth_method"]),
|
||||
}
|
||||
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
|
||||
result.UserInfo = &AppRegUserInfo{
|
||||
@@ -334,9 +221,9 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
|
||||
continue
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("app registration denied by user")
|
||||
return nil, ErrAppRegDenied
|
||||
case "expired_token", "invalid_grant":
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
return nil, fmt.Errorf("%w, please try again", ErrAppRegExpired)
|
||||
}
|
||||
|
||||
desc := getStr(data, "error_description")
|
||||
@@ -352,5 +239,5 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
if attempts >= maxPollAttempts {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
|
||||
}
|
||||
return nil, fmt.Errorf("app registration timed out, please try again")
|
||||
return nil, fmt.Errorf("%w, please try again", ErrAppRegTimeout)
|
||||
}
|
||||
|
||||
95
internal/auth/app_registration_sentinel_test.go
Normal file
95
internal/auth/app_registration_sentinel_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubRoundTripper returns a canned response for every request.
|
||||
type stubRoundTripper struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: s.status,
|
||||
Body: io.NopCloser(strings.NewReader(s.body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestAppRegSentinelMessages locks the user-facing message text so the
|
||||
// interactive create flow (which renders these via "%v") does not regress when
|
||||
// the errors gained errors.Is support.
|
||||
func TestAppRegSentinelMessages(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
ErrAppRegDenied.Error(): "app registration denied by user",
|
||||
ErrAppRegCancelled.Error(): "polling was cancelled",
|
||||
fmt.Errorf("%w, please try again", ErrAppRegExpired).Error(): "device code expired, please try again",
|
||||
fmt.Errorf("%w, please try again", ErrAppRegTimeout).Error(): "app registration timed out, please try again",
|
||||
}
|
||||
for got, want := range cases {
|
||||
if got != want {
|
||||
t.Errorf("message = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollAppRegistration_Classifies verifies that terminal poll outcomes are
|
||||
// returned as the matching sentinel error (interval 0 keeps the test fast).
|
||||
func TestPollAppRegistration_Classifies(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want error
|
||||
}{
|
||||
{"access_denied", `{"error":"access_denied"}`, ErrAppRegDenied},
|
||||
{"expired_token", `{"error":"expired_token"}`, ErrAppRegExpired},
|
||||
{"invalid_grant", `{"error":"invalid_grant"}`, ErrAppRegExpired},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
client := &http.Client{Transport: stubRoundTripper{status: 200, body: c.body}}
|
||||
_, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
|
||||
if !errors.Is(err, c.want) {
|
||||
t.Fatalf("err = %v, want errors.Is(%v)", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistration_Success(t *testing.T) {
|
||||
body := `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu","open_id":"ou_1"}}`
|
||||
client := &http.Client{Transport: stubRoundTripper{status: 200, body: body}}
|
||||
res, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
|
||||
t.Errorf("got client_id=%q secret=%q, want cli_x/sec", res.ClientID, res.ClientSecret)
|
||||
}
|
||||
if res.UserInfo == nil || res.UserInfo.TenantBrand != "feishu" {
|
||||
t.Errorf("user info not parsed: %+v", res.UserInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollAppRegistration_CancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel up front
|
||||
client := &http.Client{Transport: stubRoundTripper{status: 200, body: `{"error":"authorization_pending"}`}}
|
||||
_, err := PollAppRegistration(ctx, client, core.BrandFeishu, "dc", 0, 60, io.Discard)
|
||||
if !errors.Is(err, ErrAppRegCancelled) {
|
||||
t.Fatalf("err = %v, want errors.Is(ErrAppRegCancelled)", err)
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -37,184 +31,3 @@ func Test_BuildVerificationURL(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// captureClient returns an http.Client that records the last request's form body
|
||||
// and replies with the given JSON payload.
|
||||
func captureClient(gotBody *url.Values, respJSON string) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
v, _ := url.ParseQuery(string(b))
|
||||
*gotBody = v
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(respJSON)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistrationInit_ParsesNonceAndMethods(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, `{"nonce":"n-123","supported_auth_methods":["client_secret","private_key_jwt"]}`)
|
||||
|
||||
out, err := RequestAppRegistrationInit(hc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Nonce != "n-123" {
|
||||
t.Errorf("nonce = %q, want n-123", out.Nonce)
|
||||
}
|
||||
if len(out.SupportedAuthMethods) != 2 || out.SupportedAuthMethods[1] != "private_key_jwt" {
|
||||
t.Errorf("methods = %v", out.SupportedAuthMethods)
|
||||
}
|
||||
if body.Get("action") != "init" {
|
||||
t.Errorf("action = %q, want init", body.Get("action"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistrationInit_ErrorOnMissingNonce(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, `{"supported_auth_methods":["client_secret"]}`)
|
||||
if _, err := RequestAppRegistrationInit(hc); err == nil {
|
||||
t.Fatal("expected error when server returns no nonce")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods covers the older-server
|
||||
// back-compat path: an empty supported_auth_methods array parses to an empty
|
||||
// slice, so the init guard in cmd/config/init_interactive.go
|
||||
// (`len(SupportedAuthMethods) > 0 && !slices.Contains(...)`) stays false and does
|
||||
// NOT reject the requested private_key_jwt. This aligns with
|
||||
// resolveFinalAuthMethod(nil/[], private_key_jwt) == private_key_jwt
|
||||
// (see cmd/config TestResolveFinalAuthMethod).
|
||||
func TestRequestAppRegistrationInit_EmptySupportedAuthMethods(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, `{"nonce":"n-1","supported_auth_methods":[]}`)
|
||||
|
||||
out, err := RequestAppRegistrationInit(hc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Nonce != "n-1" {
|
||||
t.Errorf("nonce = %q, want n-1", out.Nonce)
|
||||
}
|
||||
if len(out.SupportedAuthMethods) != 0 {
|
||||
t.Errorf("SupportedAuthMethods = %v, want empty", out.SupportedAuthMethods)
|
||||
}
|
||||
// Reproduce the init guard expression on the real parsed result: an empty
|
||||
// slice must NOT reject private_key_jwt.
|
||||
rejected := len(out.SupportedAuthMethods) > 0 &&
|
||||
!slices.Contains(out.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT)
|
||||
if rejected {
|
||||
t.Error("empty SupportedAuthMethods must allow private_key_jwt (older-server back-compat)")
|
||||
}
|
||||
}
|
||||
|
||||
const beginRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
|
||||
|
||||
func TestRequestAppRegistration_BeginDefaultsToClientSecret(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, beginRespJSON)
|
||||
|
||||
if _, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Get("action") != "begin" {
|
||||
t.Errorf("action = %q", body.Get("action"))
|
||||
}
|
||||
if body.Get("auth_method") != "client_secret" {
|
||||
t.Errorf("auth_method = %q, want client_secret (default)", body.Get("auth_method"))
|
||||
}
|
||||
if body.Has("auth_attestation") {
|
||||
t.Errorf("auth_attestation should be absent for client_secret, got %q", body.Get("auth_attestation"))
|
||||
}
|
||||
// Normal (non-restore) begin must NOT carry app_id.
|
||||
if body.Has("app_id") {
|
||||
t.Errorf("app_id should be absent when RestoreAppID is empty, got %q", body.Get("app_id"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestAppRegistration_BeginRestoreAppID verifies the restore flow sends the
|
||||
// existing app id on begin so the server re-registers that app.
|
||||
func TestRequestAppRegistration_BeginRestoreAppID(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, beginRespJSON)
|
||||
|
||||
opts := AppRegistrationBeginOptions{RestoreAppID: "cli_restore_me"}
|
||||
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Get("action") != "begin" {
|
||||
t.Errorf("action = %q, want begin", body.Get("action"))
|
||||
}
|
||||
if body.Get("app_id") != "cli_restore_me" {
|
||||
t.Errorf("app_id = %q, want cli_restore_me", body.Get("app_id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "bare verification_uri",
|
||||
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`,
|
||||
want: "https://example/verify?user_code=uc",
|
||||
},
|
||||
{
|
||||
name: "verification_uri with existing query",
|
||||
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?client_id=cli_x","expires_in":300,"interval":5}`,
|
||||
want: "https://example/verify?client_id=cli_x&user_code=uc",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, tc.resp)
|
||||
got, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.VerificationUriComplete != tc.want {
|
||||
t.Errorf("VerificationUriComplete = %q, want %q", got.VerificationUriComplete, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthMethods(t *testing.T) {
|
||||
if got := parseAuthMethods([]interface{}{"private_key_jwt", "client_secret"}); len(got) != 2 || got[0] != "private_key_jwt" {
|
||||
t.Errorf("array form = %v", got)
|
||||
}
|
||||
if got := parseAuthMethods("client_secret private_key_jwt"); len(got) != 2 || got[1] != "private_key_jwt" {
|
||||
t.Errorf("string form = %v", got)
|
||||
}
|
||||
if got := parseAuthMethods(nil); got != nil {
|
||||
t.Errorf("nil form = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, beginRespJSON)
|
||||
|
||||
opts := AppRegistrationBeginOptions{
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
AuthAttestation: "header.claims.sig",
|
||||
}
|
||||
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Get("auth_method") != "private_key_jwt" {
|
||||
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
|
||||
}
|
||||
if body.Get("auth_attestation") != "header.claims.sig" {
|
||||
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// ClientAuth describes how to authenticate the OAuth client at the token
|
||||
// endpoint: with a client_secret (default) or a TEE-signed client_assertion
|
||||
// (private_key_jwt).
|
||||
type ClientAuth struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
Signer keysigner.Signer
|
||||
KeyLabel string
|
||||
}
|
||||
|
||||
// ClientAuthFromConfig builds a ClientAuth from resolved config, picking up the
|
||||
// active key signer for private_key_jwt apps.
|
||||
func ClientAuthFromConfig(cfg *core.CliConfig) ClientAuth {
|
||||
if cfg == nil {
|
||||
return ClientAuth{}
|
||||
}
|
||||
return ClientAuth{
|
||||
AppID: cfg.AppID,
|
||||
AppSecret: cfg.AppSecret,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
KeyLabel: cfg.KeyLabel,
|
||||
Signer: keysigner.Active(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c ClientAuth) isPrivateKeyJWT() bool { return c.AuthMethod == core.AuthMethodPrivateKeyJWT }
|
||||
|
||||
// applyClientAssertion adds client_assertion(+type) to a token-endpoint form for
|
||||
// private_key_jwt and returns true. For client_secret it returns false, leaving
|
||||
// the caller to apply its own secret-based authentication. audience is the token
|
||||
// endpoint URL (the assertion's aud claim).
|
||||
func (c ClientAuth) applyClientAssertion(ctx context.Context, form url.Values, audience string) (bool, error) {
|
||||
if !c.isPrivateKeyJWT() {
|
||||
return false, nil
|
||||
}
|
||||
if c.Signer == nil {
|
||||
return false, fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
|
||||
}
|
||||
assertion, err := jwt.SignClientAssertion(ctx, c.Signer, keysigner.KeyRef{Label: c.KeyLabel}, c.AppID, audience, time.Now())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
form.Set("client_assertion_type", jwt.ClientAssertionType)
|
||||
form.Set("client_assertion", assertion)
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// fakeAuthSigner is a real in-memory ECDSA P-256 signer for client-auth tests.
|
||||
type fakeAuthSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newFakeAuthSigner(t *testing.T) *fakeAuthSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fakeAuthSigner{key: k}
|
||||
}
|
||||
|
||||
func (f *fakeAuthSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeAuthSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeAuthSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func TestClientAuth_applyClientAssertion_ClientSecret(t *testing.T) {
|
||||
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // AuthMethod "" => client_secret
|
||||
form := url.Values{}
|
||||
used, err := ca.applyClientAssertion(context.Background(), form, "https://aud/token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used {
|
||||
t.Error("client_secret must not produce a client_assertion")
|
||||
}
|
||||
if form.Has("client_assertion") || form.Has("client_assertion_type") {
|
||||
t.Errorf("form should be untouched, got %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuth_applyClientAssertion_PrivateKeyJWT(t *testing.T) {
|
||||
ca := ClientAuth{
|
||||
AppID: "cli_a",
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
Signer: newFakeAuthSigner(t),
|
||||
KeyLabel: "k",
|
||||
}
|
||||
form := url.Values{}
|
||||
used, err := ca.applyClientAssertion(context.Background(), form, "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !used {
|
||||
t.Fatal("expected client_assertion to be applied")
|
||||
}
|
||||
if form.Get("client_assertion_type") != jwt.ClientAssertionType {
|
||||
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
|
||||
}
|
||||
if form.Get("client_assertion") == "" {
|
||||
t.Error("client_assertion is empty")
|
||||
}
|
||||
if form.Has("client_secret") {
|
||||
t.Error("client_secret must NOT be present for private_key_jwt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuth_applyClientAssertion_NilSigner(t *testing.T) {
|
||||
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT} // Signer nil
|
||||
if _, err := ca.applyClientAssertion(context.Background(), url.Values{}, "aud"); err == nil {
|
||||
t.Fatal("expected error when private_key_jwt has no signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuthFromConfig(t *testing.T) {
|
||||
ca := ClientAuthFromConfig(&core.CliConfig{
|
||||
AppID: "cli_x",
|
||||
AppSecret: "s",
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
KeyLabel: "label-1",
|
||||
})
|
||||
if ca.AppID != "cli_x" || ca.AppSecret != "s" || ca.AuthMethod != core.AuthMethodPrivateKeyJWT || ca.KeyLabel != "label-1" {
|
||||
t.Errorf("ClientAuth = %+v", ca)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
}
|
||||
|
||||
// RequestDeviceAuthorization requests a device authorization code.
|
||||
func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
|
||||
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
@@ -77,26 +77,18 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
|
||||
}
|
||||
}
|
||||
|
||||
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", ca.AppID)
|
||||
form.Set("client_id", appId)
|
||||
form.Set("scope", scope)
|
||||
|
||||
// private_key_jwt authenticates the client with a signed assertion in the
|
||||
// body; client_secret uses HTTP Basic.
|
||||
usedAssertion, err := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
|
||||
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if !usedAssertion {
|
||||
basicAuth := base64.StdEncoding.EncodeToString([]byte(ca.AppID + ":" + ca.AppSecret))
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth)
|
||||
}
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -147,7 +139,7 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
|
||||
}
|
||||
|
||||
// PollDeviceToken polls the token endpoint until authorization completes or times out.
|
||||
func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
|
||||
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
@@ -179,16 +171,10 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("client_id", ca.AppID)
|
||||
usedAssertion, caErr := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
|
||||
if caErr != nil {
|
||||
return &DeviceFlowResult{OK: false, Error: "invalid_client", Message: caErr.Error()}
|
||||
}
|
||||
if !usedAssertion {
|
||||
form.Set("client_secret", ca.AppSecret)
|
||||
}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||||
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -85,7 +83,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
_, err := RequestDeviceAuthorization(context.Background(), httpmock.NewClient(reg), ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "", nil)
|
||||
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
|
||||
}
|
||||
@@ -108,66 +106,6 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// captureRT records the last request + body and returns a canned device-auth response.
|
||||
func captureDeviceAuthClient(gotReq **http.Request, gotBody *string, respJSON string) *http.Client {
|
||||
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
*gotReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
*gotBody = string(b)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(respJSON)),
|
||||
}, nil
|
||||
})}
|
||||
}
|
||||
|
||||
const deviceAuthRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
|
||||
|
||||
func TestRequestDeviceAuthorization_PrivateKeyJWT_UsesAssertionNotBasic(t *testing.T) {
|
||||
var req *http.Request
|
||||
var body string
|
||||
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
|
||||
|
||||
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k"}
|
||||
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "im:message:send", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Header.Get("Authorization") != "" {
|
||||
t.Errorf("private_key_jwt must NOT send Basic auth, got %q", req.Header.Get("Authorization"))
|
||||
}
|
||||
form, _ := url.ParseQuery(body)
|
||||
if form.Get("client_assertion") == "" {
|
||||
t.Error("missing client_assertion")
|
||||
}
|
||||
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
|
||||
}
|
||||
if form.Has("client_secret") {
|
||||
t.Error("client_secret must not be present for private_key_jwt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceAuthorization_ClientSecret_UsesBasic(t *testing.T) {
|
||||
var req *http.Request
|
||||
var body string
|
||||
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
|
||||
|
||||
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // client_secret
|
||||
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") {
|
||||
t.Errorf("client_secret should use Basic auth, got %q", req.Header.Get("Authorization"))
|
||||
}
|
||||
form, _ := url.ParseQuery(body)
|
||||
if form.Has("client_assertion") {
|
||||
t.Error("client_secret must not send a client_assertion")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
|
||||
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
|
||||
got := keychain.FormatAuthCmdline([]string{
|
||||
@@ -267,7 +205,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result := PollDeviceToken(ctx, client, ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
if result == nil {
|
||||
t.Fatal("PollDeviceToken() returned nil result")
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package jwt builds compact JWS tokens signed by a keysigner.Signer.
|
||||
//
|
||||
// It deliberately depends only on the standard library plus the existing
|
||||
// google/uuid dependency — no third-party JWT library is introduced, keeping
|
||||
// go.mod free of new dependencies. The actual signing (and, for ECDSA, the
|
||||
// ASN.1->r||s conversion) is delegated to the Signer implementation.
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// buildSignedJWT builds a compact JWS:
|
||||
//
|
||||
// base64url(header).base64url(claims).base64url(signature)
|
||||
//
|
||||
// alg is written into the header (it is part of the signed input) and verified
|
||||
// against the alg the signer reports, guarding against a header/key mismatch.
|
||||
// typ defaults to "JWT": the server's client_assertion generalizedValidation
|
||||
// REQUIRES `typ == "JWT"` (rejects otherwise with "malformed client assertion
|
||||
// jwt"), even though the spec examples (§8.1/§8.2) show only alg.
|
||||
func buildSignedJWT(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, alg string, header, claims map[string]any) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
|
||||
}
|
||||
if header == nil {
|
||||
header = map[string]any{}
|
||||
}
|
||||
header["alg"] = alg
|
||||
if _, ok := header["typ"]; !ok {
|
||||
header["typ"] = "JWT"
|
||||
}
|
||||
|
||||
hb, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: marshal header: %w", err)
|
||||
}
|
||||
cb, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: marshal claims: %w", err)
|
||||
}
|
||||
|
||||
signingInput := b64(hb) + "." + b64(cb)
|
||||
sig, gotAlg, err := signer.Sign(ctx, ref, []byte(signingInput))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: sign: %w", err)
|
||||
}
|
||||
if gotAlg != alg {
|
||||
return "", fmt.Errorf("jwt: signer alg %q does not match header alg %q", gotAlg, alg)
|
||||
}
|
||||
return signingInput + "." + b64(sig), nil
|
||||
}
|
||||
|
||||
// newJTI returns a random unique token identifier.
|
||||
func newJTI() string { return uuid.NewString() }
|
||||
|
||||
// attestationTTL bounds the attestation JWT's lifetime. The init nonce (60s,
|
||||
// single-use) is the real anti-replay constraint; this is a modest margin for
|
||||
// clock skew on top of the immediate init→sign→begin round-trip.
|
||||
const attestationTTL = 2 * time.Minute
|
||||
|
||||
// attestationClaims builds the registration attestation claim set per the App
|
||||
// Registration JWT spec: jti, iat, exp (all required) and the init-issued nonce.
|
||||
func attestationClaims(nonce string, now time.Time) map[string]any {
|
||||
return map[string]any{
|
||||
"jti": newJTI(),
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(attestationTTL).Unix(),
|
||||
"nonce": nonce,
|
||||
}
|
||||
}
|
||||
|
||||
// clientAssertionClaims builds an RFC 7523 client_assertion claim set used to
|
||||
// mint tokens in place of client_secret. aud is the brand's token endpoint URL.
|
||||
func clientAssertionClaims(clientID, aud string, now time.Time, ttl time.Duration) map[string]any {
|
||||
return map[string]any{
|
||||
"iss": clientID,
|
||||
"sub": clientID,
|
||||
"aud": aud,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
"jti": newJTI(),
|
||||
}
|
||||
}
|
||||
|
||||
// ClientAssertionType is the RFC 7523 client_assertion_type value used for JWT
|
||||
// bearer client authentication at the token endpoint.
|
||||
const ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
|
||||
// defaultAssertionTTL bounds a client_assertion's lifetime.
|
||||
const defaultAssertionTTL = 5 * time.Minute
|
||||
|
||||
// SignAttestation signs the registration attestation JWT. The public key is
|
||||
// embedded in the JWS "jwk" header so the registration backend can bind it to
|
||||
// the app during action=begin; the claims carry the server nonce as a
|
||||
// proof-of-possession challenge.
|
||||
func SignAttestation(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, nonce string, now time.Time) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
|
||||
}
|
||||
pub, err := signer.EnsureKey(ctx, ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: ensure key: %w", err)
|
||||
}
|
||||
alg, err := keysigner.AlgForKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
jwk, err := keysigner.PublicKeyJWK(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{"jwk": jwk}, attestationClaims(nonce, now))
|
||||
}
|
||||
|
||||
// SignClientAssertion mints a short-lived RFC 7523 client_assertion: it reads the
|
||||
// registered key (it must already exist — bound at registration; a missing key is
|
||||
// an error, not a reason to create a new unbound one), derives the JWS alg from
|
||||
// the public key, and signs an assertion whose audience is the brand's Open API
|
||||
// host. The server, holding the public key bound at registration, verifies it in
|
||||
// place of client_secret. The assertion header carries only alg (no jwk/kid);
|
||||
// the server locates the key via iss/sub = client_id.
|
||||
//
|
||||
// This is the model-independent glue: the assertion JWT is identical whether the
|
||||
// server augments an existing grant (device_code/refresh_token) with client
|
||||
// authentication or uses a dedicated jwt-bearer grant — only where the caller
|
||||
// attaches it differs.
|
||||
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, clientID, audience string, now time.Time) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
|
||||
}
|
||||
pub, err := signer.PublicKey(ctx, ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: public key: %w", err)
|
||||
}
|
||||
alg, err := keysigner.AlgForKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{}, clientAssertionClaims(clientID, audience, now, defaultAssertionTTL))
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// fakeSigner is a real in-memory ECDSA P-256 signer, so tests exercise the full
|
||||
// JWS path and the produced token is actually cryptographically verifiable.
|
||||
type fakeSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newFakeSigner(t *testing.T) *fakeSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fakeSigner{key: k}
|
||||
}
|
||||
|
||||
func (f *fakeSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// JOSE ES256: fixed-width big-endian r||s (32 bytes each for P-256).
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_VerifiableES256(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
tok, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{Label: "x"}, keysigner.AlgES256,
|
||||
map[string]any{}, clientAssertionClaims("cli_app", "https://accounts.example/token", now, 5*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("want 3 JWS parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
hb, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
t.Fatalf("header not base64url: %v", err)
|
||||
}
|
||||
var hdr map[string]any
|
||||
if err := json.Unmarshal(hb, &hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hdr["alg"] != "ES256" || hdr["typ"] != "JWT" {
|
||||
t.Errorf("header = %v, want alg=ES256 typ=JWT (server generalizedValidation requires typ)", hdr)
|
||||
}
|
||||
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" || claims["aud"] != "https://accounts.example/token" {
|
||||
t.Errorf("claims = %v", claims)
|
||||
}
|
||||
|
||||
// Cryptographically verify the signature against the signing input.
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
t.Fatalf("sig not base64url: %v", err)
|
||||
}
|
||||
if len(sig) != 64 {
|
||||
t.Fatalf("ES256 sig len = %d, want 64", len(sig))
|
||||
}
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
|
||||
t.Error("signature did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_NilSigner(t *testing.T) {
|
||||
if _, err := buildSignedJWT(context.Background(), nil, keysigner.KeyRef{}, "ES256", nil, nil); err == nil {
|
||||
t.Fatal("expected error for nil signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_AlgMismatch(t *testing.T) {
|
||||
f := newFakeSigner(t) // always reports ES256
|
||||
if _, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{}, keysigner.AlgRS256, nil, nil); err == nil {
|
||||
t.Fatal("expected error when header alg != signer alg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_MarshalErrors(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
|
||||
map[string]any{"bad": func() {}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "jwt: marshal header") {
|
||||
t.Fatalf("header marshal error = %v, want prefix %q", err, "jwt: marshal header")
|
||||
}
|
||||
|
||||
_, err = buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
|
||||
nil, map[string]any{"bad": make(chan int)})
|
||||
if err == nil || !strings.Contains(err.Error(), "jwt: marshal claims") {
|
||||
t.Fatalf("claims marshal error = %v, want prefix %q", err, "jwt: marshal claims")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignClientAssertion(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
now := time.Unix(1700000000, 0)
|
||||
const aud = "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token"
|
||||
|
||||
tok, err := SignClientAssertion(context.Background(), f, keysigner.KeyRef{Label: "k"}, "cli_app", aud, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("want 3 parts, got %d", len(parts))
|
||||
}
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["iss"] != "cli_app" || claims["aud"] != aud {
|
||||
t.Errorf("claims = %v", claims)
|
||||
}
|
||||
|
||||
// Signature must verify against the key's public half.
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
|
||||
t.Error("client_assertion signature did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignClientAssertion_NilSigner(t *testing.T) {
|
||||
if _, err := SignClientAssertion(context.Background(), nil, keysigner.KeyRef{}, "cli_app", "aud", time.Unix(0, 0)); err == nil {
|
||||
t.Fatal("expected error for nil signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAttestation(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
tok, err := SignAttestation(context.Background(), f, keysigner.KeyRef{Label: "k"}, "nonce-abc", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("want 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
var hdr map[string]any
|
||||
if err := json.Unmarshal(hb, &hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk, ok := hdr["jwk"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("attestation header missing jwk: %v", hdr)
|
||||
}
|
||||
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["use"] != "sig" {
|
||||
t.Errorf("jwk = %v", jwk)
|
||||
}
|
||||
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["nonce"] != "nonce-abc" {
|
||||
t.Errorf("nonce = %v", claims["nonce"])
|
||||
}
|
||||
// jti, iat, exp are all required by the attestation spec.
|
||||
iat, iatOK := claims["iat"].(float64)
|
||||
exp, expOK := claims["exp"].(float64)
|
||||
if !iatOK || !expOK || exp <= iat {
|
||||
t.Errorf("claims iat/exp invalid: iat=%v exp=%v", claims["iat"], claims["exp"])
|
||||
}
|
||||
if jti, _ := claims["jti"].(string); jti == "" {
|
||||
t.Error("claims jti empty")
|
||||
}
|
||||
|
||||
// Signature verifies against the embedded key.
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
|
||||
t.Error("attestation signature did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAttestation_NilSigner(t *testing.T) {
|
||||
if _, err := SignAttestation(context.Background(), nil, keysigner.KeyRef{}, "n", time.Unix(0, 0)); err == nil {
|
||||
t.Fatal("expected error for nil signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFactories(t *testing.T) {
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
a := attestationClaims("nonce-xyz", now)
|
||||
if a["nonce"] != "nonce-xyz" || a["iat"] != now.Unix() {
|
||||
t.Errorf("attestation claims = %v", a)
|
||||
}
|
||||
if a["exp"] != now.Add(attestationTTL).Unix() {
|
||||
t.Errorf("attestation exp = %v, want %v", a["exp"], now.Add(attestationTTL).Unix())
|
||||
}
|
||||
if jti, _ := a["jti"].(string); jti == "" {
|
||||
t.Error("attestation jti empty")
|
||||
}
|
||||
|
||||
c := clientAssertionClaims("cli_app", "aud", now, time.Minute)
|
||||
if c["exp"].(int64) != now.Add(time.Minute).Unix() {
|
||||
t.Errorf("client_assertion exp = %v", c["exp"])
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -38,10 +37,7 @@ type UATCallOptions struct {
|
||||
AppId string
|
||||
AppSecret string
|
||||
Domain core.LarkBrand
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // TEE key handle for private_key_jwt
|
||||
Signer keysigner.Signer // active signer for private_key_jwt
|
||||
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
|
||||
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
// UATStatus represents the status of a user access token.
|
||||
@@ -65,9 +61,6 @@ func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
|
||||
AppId: cfg.AppID,
|
||||
AppSecret: cfg.AppSecret,
|
||||
Domain: cfg.Brand,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
KeyLabel: cfg.KeyLabel,
|
||||
Signer: keysigner.Active(),
|
||||
ErrOut: errOut,
|
||||
}
|
||||
}
|
||||
@@ -200,14 +193,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", stored.RefreshToken)
|
||||
form.Set("client_id", opts.AppId)
|
||||
ca := ClientAuth{AppID: opts.AppId, AppSecret: opts.AppSecret, AuthMethod: opts.AuthMethod, Signer: opts.Signer, KeyLabel: opts.KeyLabel}
|
||||
usedAssertion, caErr := ca.applyClientAssertion(context.Background(), form, core.OpenAPIAudience(opts.Domain))
|
||||
if caErr != nil {
|
||||
return nil, caErr
|
||||
}
|
||||
if !usedAssertion {
|
||||
form.Set("client_secret", opts.AppSecret)
|
||||
}
|
||||
form.Set("client_secret", opts.AppSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
|
||||
@@ -38,23 +38,3 @@ func TestNewUATCallOptions(t *testing.T) {
|
||||
t.Error("ErrOut not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewUATCallOptions_PrivateKeyJWT verifies the auth-method fields propagate
|
||||
// so the refresh path can mint a client_assertion instead of sending a secret.
|
||||
func TestNewUATCallOptions_PrivateKeyJWT(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_pk",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
KeyLabel: "agent-key",
|
||||
}
|
||||
opts := NewUATCallOptions(cfg, &bytes.Buffer{})
|
||||
|
||||
if opts.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("AuthMethod = %q, want private_key_jwt", opts.AuthMethod)
|
||||
}
|
||||
if opts.KeyLabel != "agent-key" {
|
||||
t.Errorf("KeyLabel = %q, want agent-key", opts.KeyLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,16 +42,6 @@ func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
||||
}
|
||||
|
||||
// StdoutIsTerminal reports whether Out is an interactive terminal. Unlike
|
||||
// IsTerminal — which reflects stdin and drives prompt decisions — this is the
|
||||
// correct check for OUTPUT formatting: `cmd | jq` must still emit machine output
|
||||
// from an interactive shell (stdin is a TTY there, but stdout is the pipe).
|
||||
// Buffers (tests) and redirects are not *os.File terminals, so they yield false.
|
||||
func (s *IOStreams) StdoutIsTerminal() bool {
|
||||
f, ok := s.Out.(*os.File)
|
||||
return ok && term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
//
|
||||
//nolint:forbidigo // entry point for real stdio
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStdoutIsTerminal(t *testing.T) {
|
||||
// Buffer-backed output (tests, captured output) is never a terminal.
|
||||
if (&IOStreams{Out: &bytes.Buffer{}}).StdoutIsTerminal() {
|
||||
t.Error("bytes.Buffer Out should not be a terminal")
|
||||
}
|
||||
// An os.Pipe write end is an *os.File but not a terminal — mirrors `cmd | jq`,
|
||||
// the case the stdin-based IsTerminal would get wrong.
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
if (&IOStreams{Out: w}).StdoutIsTerminal() {
|
||||
t.Error("os.Pipe Out should not be a terminal")
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,6 @@ type AppUser struct {
|
||||
UserName string `json:"userName"`
|
||||
}
|
||||
|
||||
// Auth methods for app credentials. An empty AppConfig.AuthMethod means the
|
||||
// default, client_secret.
|
||||
const (
|
||||
AuthMethodClientSecret = "client_secret" // app_id + app_secret
|
||||
AuthMethodPrivateKeyJWT = "private_key_jwt" // TEE-signed client_assertion; no app secret
|
||||
)
|
||||
|
||||
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
|
||||
type AppConfig struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -53,15 +46,6 @@ type AppConfig struct {
|
||||
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
|
||||
StrictMode *StrictMode `json:"strictMode,omitempty"`
|
||||
Users []AppUser `json:"users"`
|
||||
|
||||
// AuthMethod selects how tokens are minted. Empty == AuthMethodClientSecret
|
||||
// (back-compat). AuthMethodPrivateKeyJWT uses a TEE-held key (see KeyRef) to
|
||||
// sign client_assertion JWTs instead of sending an app secret.
|
||||
AuthMethod string `json:"authMethod,omitempty"`
|
||||
// KeyRef references the non-exportable signing key for private_key_jwt.
|
||||
// Source is "tee" and ID is the backend key label; the actual key never
|
||||
// leaves the secure backend, so this is a handle, not secret material.
|
||||
KeyRef *SecretRef `json:"keyRef,omitempty"`
|
||||
}
|
||||
|
||||
// ProfileName returns the display name for this app config.
|
||||
@@ -177,9 +161,7 @@ type CliConfig struct {
|
||||
UserOpenId string
|
||||
UserName string
|
||||
Lang i18n.Lang
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
AuthMethod string // "" == client_secret; AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // resolved TEE key handle for private_key_jwt
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
}
|
||||
|
||||
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
|
||||
@@ -265,58 +247,31 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
|
||||
}
|
||||
|
||||
// Validate the auth method first so a malformed profile fails here rather
|
||||
// than silently degrading to client_secret (unknown method) or failing later
|
||||
// at token-signing. Empty stays empty — downstream treats it as client_secret
|
||||
// (back-compat).
|
||||
switch app.AuthMethod {
|
||||
case "", AuthMethodClientSecret, AuthMethodPrivateKeyJWT:
|
||||
default:
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q", app.AuthMethod).
|
||||
WithHint("supported: %s, %s (empty defaults to %s)", AuthMethodClientSecret, AuthMethodPrivateKeyJWT, AuthMethodClientSecret)
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// private_key_jwt carries no secret: validate the key handle and skip secret
|
||||
// resolution entirely, so a stale/broken AppSecret ref never produces a
|
||||
// confusing secret-resolution error for an otherwise-valid pkjwt profile.
|
||||
var secret string
|
||||
if app.AuthMethod == AuthMethodPrivateKeyJWT {
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "private_key_jwt requires a key handle (keyRef) but none is configured").
|
||||
WithHint("re-run: lark-cli config init --new --auth-method private_key_jwt")
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
if errs.IsTyped(err) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
var resolveErr error
|
||||
secret, resolveErr = ResolveSecretInput(app.AppSecret, kc)
|
||||
if resolveErr != nil {
|
||||
if errs.IsTyped(resolveErr) {
|
||||
return nil, resolveErr
|
||||
}
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(resolveErr) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "%s", resolveErr.Error()).WithCause(resolveErr)
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
Lang: app.Lang,
|
||||
AuthMethod: app.AuthMethod,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
if app.KeyRef != nil {
|
||||
cfg.KeyLabel = app.KeyRef.ID
|
||||
}
|
||||
if len(app.Users) > 0 {
|
||||
cfg.UserOpenId = app.Users[0].UserOpenId
|
||||
cfg.UserName = app.Users[0].UserName
|
||||
|
||||
@@ -133,108 +133,6 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_RejectsUnknownAuthMethod ensures an unsupported
|
||||
// authMethod fails at resolution rather than silently degrading to client_secret.
|
||||
func TestResolveConfigFromMulti_RejectsUnknownAuthMethod(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: PlainSecret("my-secret"),
|
||||
Brand: BrandFeishu,
|
||||
AuthMethod: "bogus_method",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown authMethod")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef ensures private_key_jwt
|
||||
// without a key handle fails at resolution rather than later at token-signing.
|
||||
func TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: SecretInput{}, // private_key_jwt carries no app secret
|
||||
Brand: BrandFeishu,
|
||||
AuthMethod: AuthMethodPrivateKeyJWT,
|
||||
// KeyRef intentionally nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for private_key_jwt without keyRef")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
// Control: same config WITH a keyRef resolves cleanly and sets KeyLabel.
|
||||
raw.Apps[0].KeyRef = &SecretRef{Source: "tee", ID: "larksuite-cli-agent"}
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with keyRef present: %v", err)
|
||||
}
|
||||
if cfg.KeyLabel != "larksuite-cli-agent" {
|
||||
t.Errorf("KeyLabel = %q, want larksuite-cli-agent", cfg.KeyLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_PKJWTSkipsSecretResolution ensures a private_key_jwt
|
||||
// profile that carries a stale/broken AppSecret ref still resolves cleanly: the
|
||||
// auth method is judged before any secret handling, so the stale ref is ignored
|
||||
// instead of producing a confusing secret-resolution failure.
|
||||
func TestResolveConfigFromMulti_PKJWTSkipsSecretResolution(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{{
|
||||
AppId: "cli_pk",
|
||||
// Stale keychain ref whose ID does not match appId — would trip
|
||||
// ValidateSecretKeyMatch / ResolveSecretInput if it were reached.
|
||||
AppSecret: SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_OTHER"}},
|
||||
Brand: BrandFeishu,
|
||||
AuthMethod: AuthMethodPrivateKeyJWT,
|
||||
KeyRef: &SecretRef{Source: "tee", ID: "agent-key"},
|
||||
Users: []AppUser{},
|
||||
}},
|
||||
}
|
||||
cfg, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("pkjwt with stale secret ref must skip secret resolution, got %v", err)
|
||||
}
|
||||
if cfg.AuthMethod != AuthMethodPrivateKeyJWT || cfg.KeyLabel != "agent-key" {
|
||||
t.Errorf("got authMethod=%q keyLabel=%q", cfg.AuthMethod, cfg.KeyLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef ensures the stricter keyRef
|
||||
// check (Source=="tee" && ID!="") rejects malformed handles.
|
||||
func TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef(t *testing.T) {
|
||||
for i, ref := range []*SecretRef{
|
||||
{Source: "keychain", ID: "x"}, // wrong source
|
||||
{Source: "tee", ID: ""}, // empty id
|
||||
} {
|
||||
raw := &MultiAppConfig{Apps: []AppConfig{{
|
||||
AppId: "cli_pk", Brand: BrandFeishu,
|
||||
AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: ref, Users: []AppUser{},
|
||||
}}}
|
||||
if _, err := ResolveConfigFromMulti(raw, stubKeychain{}, ""); err == nil {
|
||||
t.Errorf("case %d: expected ConfigError for bad keyRef", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
// Any other string is treated as a custom base URL.
|
||||
@@ -62,10 +60,3 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
// OpenAPIAudience returns the client_assertion `aud` value for the brand: the
|
||||
// bare Open API host per the App Authentication JWT spec — "open.feishu.cn" or
|
||||
// "open.larksuite.com" — not the full token endpoint URL.
|
||||
func OpenAPIAudience(brand LarkBrand) string {
|
||||
return strings.TrimPrefix(ResolveOpenBaseURL(brand), "https://")
|
||||
}
|
||||
|
||||
@@ -57,12 +57,3 @@ func TestResolveOpenBaseURL(t *testing.T) {
|
||||
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIAudience(t *testing.T) {
|
||||
if got := OpenAPIAudience(BrandFeishu); got != "open.feishu.cn" {
|
||||
t.Errorf("OpenAPIAudience(feishu) = %q, want open.feishu.cn", got)
|
||||
}
|
||||
if got := OpenAPIAudience(BrandLark); got != "open.larksuite.com" {
|
||||
t.Errorf("OpenAPIAudience(lark) = %q, want open.larksuite.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||
@@ -176,23 +175,6 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// private_key_jwt apps have no app secret: mint via the jwt-bearer grant
|
||||
// using a TEE-signed client_assertion instead.
|
||||
if acct.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
||||
signer := keysigner.Active()
|
||||
if signer == nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"profile uses private_key_jwt but no TEE key signer is available on this build").
|
||||
WithHint("install a build with the platform key-signer extension, or reconfigure the app to use an app secret")
|
||||
}
|
||||
token, err := FetchTATWithAssertion(ctx, httpClient, acct.Brand, acct.AppID, signer, acct.KeyLabel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenResult{Token: token}, nil
|
||||
}
|
||||
|
||||
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,13 +11,8 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||
@@ -105,96 +100,3 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
|
||||
}
|
||||
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||
}
|
||||
|
||||
// FetchTATWithAssertion mints a tenant access token for a private_key_jwt app via
|
||||
// the RFC 7523 jwt-bearer grant: it signs a short-lived client_assertion with the
|
||||
// TEE-held key and posts it to the unified OAuth token endpoint, replacing the
|
||||
// app_secret entirely.
|
||||
//
|
||||
// The unified v2 token endpoint returns the minted token as access_token
|
||||
// (tenant_access_token is accepted as a fallback).
|
||||
func FetchTATWithAssertion(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
|
||||
}
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Open + auth.PathOAuthTokenV2
|
||||
|
||||
assertion, err := jwt.SignClientAssertion(ctx, signer, keysigner.KeyRef{Label: keyLabel}, clientID, core.OpenAPIAudience(brand), time.Now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("client_assertion_type", jwt.ClientAssertionType)
|
||||
form.Set("client_assertion", assertion)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read token response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result) // best-effort; error body may not be JSON
|
||||
|
||||
token := result.AccessToken
|
||||
if token == "" {
|
||||
token = result.TenantAccessToken
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK && token != "" && result.Error == "" && result.Code == 0 {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Surface the server's reason, preferring the OAuth `error` code (e.g.
|
||||
// unauthorized_client) which is more diagnostic than the description alone.
|
||||
detail := result.ErrorDescription
|
||||
if detail == "" {
|
||||
detail = result.Msg
|
||||
}
|
||||
if detail == "" {
|
||||
detail = strings.TrimSpace(string(body))
|
||||
}
|
||||
if result.Error != "" {
|
||||
return "", classifyAssertionError(result.Error, resp.StatusCode, detail)
|
||||
}
|
||||
return "", fmt.Errorf("token endpoint HTTP %d (code=%d): %s", resp.StatusCode, result.Code, detail)
|
||||
}
|
||||
|
||||
// classifyAssertionError maps the OAuth token endpoint's `error` field to a
|
||||
// typed or untyped error. Only deterministic client-credential rejections get a
|
||||
// typed errs.ConfigError (so runProbePKJWT can tell "this key is not bound to
|
||||
// this app" apart from upstream noise); every other error (e.g.
|
||||
// temporarily_unavailable) stays untyped and is swallowed by the probe. detail
|
||||
// carries only the server's error_description / msg / body text — it never
|
||||
// echoes the client_assertion or private key (the assertion lives only in the
|
||||
// request form).
|
||||
func classifyAssertionError(oauthError string, httpStatus int, detail string) error {
|
||||
switch oauthError {
|
||||
case "invalid_client", "unauthorized_client", "invalid_grant":
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"token endpoint rejected the key (%s): %s", oauthError, detail)
|
||||
default:
|
||||
return fmt.Errorf("token endpoint HTTP %d (%s): %s", httpStatus, oauthError, detail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,15 @@ package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// stubRoundTripper lets us assert request shape and return canned responses.
|
||||
@@ -316,147 +307,3 @@ func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req2.Header = req.Header
|
||||
return http.DefaultTransport.RoundTrip(req2)
|
||||
}
|
||||
|
||||
// fakeTATSigner is a real in-memory ECDSA P-256 signer for assertion tests.
|
||||
type fakeTATSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newFakeTATSigner(t *testing.T) *fakeTATSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fakeTATSigner{key: k}
|
||||
}
|
||||
|
||||
func (f *fakeTATSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeTATSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeTATSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func TestFetchTATWithAssertion_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"access_token":"t-jwt","token_type":"Bearer","expires_in":7200}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "agent-key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "t-jwt" {
|
||||
t.Errorf("token = %q, want t-jwt", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
|
||||
form, err := url.ParseQuery(rt.gotBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
|
||||
t.Errorf("grant_type = %q", form.Get("grant_type"))
|
||||
}
|
||||
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
|
||||
}
|
||||
if form.Get("client_assertion") == "" {
|
||||
t.Error("client_assertion is empty")
|
||||
}
|
||||
if form.Has("client_secret") {
|
||||
t.Error("client_secret must NOT be sent for private_key_jwt")
|
||||
}
|
||||
|
||||
// The assertion's aud must be the bare Open host per the App Authentication
|
||||
// JWT spec — not the full token endpoint URL.
|
||||
jwtParts := strings.Split(form.Get("client_assertion"), ".")
|
||||
if len(jwtParts) != 3 {
|
||||
t.Fatalf("malformed client_assertion: %q", form.Get("client_assertion"))
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(jwtParts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("assertion payload not base64url: %v", err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["aud"] != "open.feishu.cn" {
|
||||
t.Errorf("client_assertion aud = %v, want open.feishu.cn", claims["aud"])
|
||||
}
|
||||
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" {
|
||||
t.Errorf("client_assertion iss/sub = %v/%v, want cli_app", claims["iss"], claims["sub"])
|
||||
}
|
||||
if form.Get("client_id") != "cli_app" {
|
||||
t.Errorf("client_id = %q", form.Get("client_id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTATWithAssertion_NilSigner(t *testing.T) {
|
||||
hc := &http.Client{Transport: &stubRoundTripper{respCode: 200, respBody: `{}`}}
|
||||
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", nil, "k"); err == nil {
|
||||
t.Fatal("expected error when signer is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTATWithAssertion_ServerError(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"error":"invalid_client","error_description":"unknown key"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k"); err == nil {
|
||||
t.Fatal("expected error for invalid_client response")
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic OAuth client rejections must be typed (ConfigError /
|
||||
// SubtypeInvalidClient) so runProbePKJWT can tell "the key is not bound to this
|
||||
// app" apart from transport noise.
|
||||
func TestFetchTATWithAssertion_DeterministicReject_Typed(t *testing.T) {
|
||||
for _, oauthErr := range []string{"invalid_client", "unauthorized_client", "invalid_grant"} {
|
||||
rt := &stubRoundTripper{respCode: 401, respBody: `{"error":"` + oauthErr + `","error_description":"bad key"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error", oauthErr)
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Errorf("%s: must be typed, got %T", oauthErr, err)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("%s: want ConfigError/InvalidClient, got %T %v", oauthErr, err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unrecognized OAuth errors and non-payload noise stay UNTYPED so the probe
|
||||
// treats them as upstream noise and stays silent.
|
||||
func TestFetchTATWithAssertion_AmbiguousError_Untyped(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"error":"temporarily_unavailable","error_description":"retry"}`,
|
||||
`{"code":99999,"msg":"weird"}`,
|
||||
`not json`,
|
||||
}
|
||||
for _, body := range cases {
|
||||
rt := &stubRoundTripper{respCode: 503, respBody: body}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
|
||||
if err == nil {
|
||||
t.Fatalf("body %q: expected error", body)
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("body %q: must be UNTYPED, got typed %T", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ type Account struct {
|
||||
UserName string
|
||||
Lang i18n.Lang
|
||||
SupportedIdentities uint8
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // resolved TEE key handle for private_key_jwt
|
||||
}
|
||||
|
||||
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
|
||||
@@ -71,8 +69,6 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
|
||||
UserName: cfg.UserName,
|
||||
Lang: cfg.Lang,
|
||||
SupportedIdentities: cfg.SupportedIdentities,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
KeyLabel: cfg.KeyLabel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +87,6 @@ func (a *Account) ToCliConfig() *core.CliConfig {
|
||||
UserName: a.UserName,
|
||||
Lang: a.Lang,
|
||||
SupportedIdentities: a.SupportedIdentities,
|
||||
AuthMethod: a.AuthMethod,
|
||||
KeyLabel: a.KeyLabel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,9 +82,7 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
|
||||
Hint: "check strict mode or the active credential provider",
|
||||
}
|
||||
}
|
||||
// private_key_jwt apps have no app secret — the bot/tenant token is minted via
|
||||
// a TEE-signed client_assertion — so absence of a secret is NOT "unconfigured".
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) && cfg.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app secret or bot token)",
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package keysigner defines the pluggable signing abstraction used by the
|
||||
// private_key_jwt registration and authentication flow.
|
||||
//
|
||||
// The open-source core only declares the Signer interface and pure-stdlib key
|
||||
// helpers. The platform implementations that hold a non-exportable private key
|
||||
// (TPM 2.0 via facebookincubator/sks on Linux/Windows, a non-extractable
|
||||
// Keychain key on macOS) live OUTSIDE this core — in a build-tagged module or
|
||||
// extension — and register themselves via Register from init(). This keeps
|
||||
// CGO-heavy and license-sensitive dependencies out of the open-source build.
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyRef identifies a non-exportable signing key held by a backend
|
||||
// (TEE/TPM/Keychain). It is a stable handle (label), never the key material.
|
||||
type KeyRef struct {
|
||||
// Label is the backend key label/tag (e.g. "larksuite-cli-agent").
|
||||
Label string
|
||||
}
|
||||
|
||||
// Signer signs JWS signing inputs with a non-exportable key.
|
||||
type Signer interface {
|
||||
// EnsureKey returns the public key for ref, creating the key if absent.
|
||||
EnsureKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
|
||||
// PublicKey returns the public key for ref without creating it.
|
||||
PublicKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
|
||||
// Sign signs signingInput and returns a JOSE-format signature plus the JWS
|
||||
// alg ("ES256"/"RS256"). Implementations apply the alg's hash and, for
|
||||
// ECDSA, MUST return the fixed-width r||s form required by RFC 7518 §3.4
|
||||
// (not ASN.1 DER), because the backend (TPM/Keychain) typically yields DER.
|
||||
Sign(ctx context.Context, ref KeyRef, signingInput []byte) (sig []byte, alg string, err error)
|
||||
}
|
||||
|
||||
// Supported JWS algorithms.
|
||||
const (
|
||||
AlgES256 = "ES256"
|
||||
AlgRS256 = "RS256"
|
||||
)
|
||||
|
||||
// DefaultKeyLabel is the backend key label lark-cli uses for its device signing
|
||||
// key. One non-exportable key is created on first private_key_jwt registration
|
||||
// and reused across subsequent app registrations on the same device.
|
||||
const DefaultKeyLabel = "larksuite-cli-agent"
|
||||
|
||||
// HardwareInfo describes the secure hardware backing a Signer, as reported by a
|
||||
// HardwareProber. It is advisory/diagnostic: it tells a user whether
|
||||
// private_key_jwt can use a real TEE on this device.
|
||||
type HardwareInfo struct {
|
||||
Backend string // backing technology, e.g. "tpm2" or "keychain"
|
||||
Available bool // the hardware is present and usable for signing
|
||||
VendorName string // hardware vendor/manufacturer, when known
|
||||
VendorInfo string // additional vendor detail, when known
|
||||
Reason string // when Available is false, a human-readable cause
|
||||
}
|
||||
|
||||
// HardwareProber is an optional capability a Signer may implement to report on
|
||||
// the secure hardware backing it (TPM/TEE vendor and availability) WITHOUT
|
||||
// creating or using a key. Probing never mutates key state.
|
||||
type HardwareProber interface {
|
||||
ProbeHardware(ctx context.Context) (HardwareInfo, error)
|
||||
}
|
||||
|
||||
// ProbeActiveHardware probes the active signer's secure hardware. ok is false
|
||||
// when there is no active signer or it does not implement HardwareProber — in
|
||||
// which case private_key_jwt is unsupported on this build. When ok is true, info
|
||||
// reports availability and, if unavailable, info.Reason explains why.
|
||||
func ProbeActiveHardware(ctx context.Context) (info HardwareInfo, ok bool, err error) {
|
||||
return probeHardware(ctx, Active())
|
||||
}
|
||||
|
||||
// probeHardware is the registry-independent core of ProbeActiveHardware, so it
|
||||
// can be unit-tested without touching the global signer.
|
||||
func probeHardware(ctx context.Context, s Signer) (HardwareInfo, bool, error) {
|
||||
p, ok := s.(HardwareProber)
|
||||
if !ok {
|
||||
return HardwareInfo{}, false, nil
|
||||
}
|
||||
info, err := p.ProbeHardware(ctx)
|
||||
return info, true, err
|
||||
}
|
||||
|
||||
// cleanProbeError renders err's message with redundant re-wraps collapsed. Some
|
||||
// backends (e.g. facebookincubator/sks) wrap an error twice with the SAME "%w"
|
||||
// prefix, yielding "P: P: cause"; this peels each outer layer whose only
|
||||
// contribution is to repeat the prefix already present in the wrapped error,
|
||||
// leaving a single "P: cause". A layer that adds genuinely new context is kept.
|
||||
func cleanProbeError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
for {
|
||||
inner := errors.Unwrap(err)
|
||||
if inner == nil {
|
||||
break
|
||||
}
|
||||
innerMsg := inner.Error()
|
||||
prefix, ok := strings.CutSuffix(msg, innerMsg)
|
||||
if !ok || prefix == "" || !strings.HasPrefix(innerMsg, prefix) {
|
||||
break
|
||||
}
|
||||
msg, err = innerMsg, inner
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// AlgForKey returns the JWS alg for a public key: EC P-256 -> ES256, RSA -> RS256.
|
||||
// The signer backend chooses the key type (the macOS keychain signer uses an
|
||||
// RSA-2048 key, hence RS256).
|
||||
func AlgForKey(pub crypto.PublicKey) (string, error) {
|
||||
switch k := pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
if k.Curve == elliptic.P256() {
|
||||
return AlgES256, nil
|
||||
}
|
||||
return "", fmt.Errorf("keysigner: unsupported EC curve %q (only P-256/ES256)", k.Curve.Params().Name)
|
||||
case *rsa.PublicKey:
|
||||
return AlgRS256, nil
|
||||
default:
|
||||
return "", fmt.Errorf("keysigner: unsupported public key type %T", pub)
|
||||
}
|
||||
}
|
||||
|
||||
// ecdsaDERToJOSE converts an ASN.1 DER-encoded ECDSA signature — the form most
|
||||
// TEE/TPM backends emit (e.g. facebookincubator/sks marshals the TPM's r,s with
|
||||
// asn1.Marshal) — into the fixed-width r||s form JWS requires for ES256
|
||||
// (RFC 7518 §3.4). byteLen is the curve coordinate size (32 for P-256), so the
|
||||
// result is exactly 2*byteLen bytes with r and s each left-zero-padded.
|
||||
//
|
||||
// This is intentionally part of the pure-stdlib core (not a platform signer) so
|
||||
// it can be unit-tested with a software key on any machine, including TPM-less CI.
|
||||
func ecdsaDERToJOSE(der []byte, byteLen int) ([]byte, error) {
|
||||
var sig struct{ R, S *big.Int }
|
||||
rest, err := asn1.Unmarshal(der, &sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: parse ECDSA DER signature: %w", err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
return nil, fmt.Errorf("keysigner: %d trailing byte(s) after ECDSA DER signature", len(rest))
|
||||
}
|
||||
if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 {
|
||||
return nil, fmt.Errorf("keysigner: ECDSA signature has non-positive r/s")
|
||||
}
|
||||
// Guard before FillBytes, which panics if the scalar does not fit in byteLen.
|
||||
if sig.R.BitLen() > byteLen*8 || sig.S.BitLen() > byteLen*8 {
|
||||
return nil, fmt.Errorf("keysigner: ECDSA r/s exceeds %d-byte coordinate", byteLen)
|
||||
}
|
||||
out := make([]byte, 2*byteLen)
|
||||
sig.R.FillBytes(out[:byteLen])
|
||||
sig.S.FillBytes(out[byteLen:])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EncodePublicKey marshals pub to PKIX DER and base64-encodes it (std encoding),
|
||||
// matching the public-key form the registration backend binds to the app.
|
||||
func EncodePublicKey(pub crypto.PublicKey) (string, error) {
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keysigner: encode public key: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(der), nil
|
||||
}
|
||||
|
||||
// PublicKeyJWK returns the RFC 7517 JSON Web Key for pub, used to embed the
|
||||
// public key in the attestation JWT's "jwk" header so the registration backend
|
||||
// can bind it to the app. EC keys use base64url fixed-width coordinates
|
||||
// (RFC 7518 §6.2.1); RSA keys use base64url-encoded modulus and exponent.
|
||||
func PublicKeyJWK(pub crypto.PublicKey) (map[string]any, error) {
|
||||
switch k := pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
if k.Curve != elliptic.P256() {
|
||||
return nil, fmt.Errorf("keysigner: JWK supports EC P-256 only, got %q", k.Curve.Params().Name)
|
||||
}
|
||||
const coordLen = 32 // P-256 field element size
|
||||
x := make([]byte, coordLen)
|
||||
y := make([]byte, coordLen)
|
||||
k.X.FillBytes(x)
|
||||
k.Y.FillBytes(y)
|
||||
return map[string]any{
|
||||
"use": "sig",
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"x": base64.RawURLEncoding.EncodeToString(x),
|
||||
"y": base64.RawURLEncoding.EncodeToString(y),
|
||||
}, nil
|
||||
case *rsa.PublicKey:
|
||||
return map[string]any{
|
||||
"use": "sig",
|
||||
"kty": "RSA",
|
||||
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("keysigner: unsupported public key type %T for JWK", pub)
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlgForKey(t *testing.T) {
|
||||
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alg, err := AlgForKey(ec.Public()); err != nil || alg != AlgES256 {
|
||||
t.Errorf("P-256: alg=%q err=%v, want ES256/nil", alg, err)
|
||||
}
|
||||
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alg, err := AlgForKey(rsaKey.Public()); err != nil || alg != AlgRS256 {
|
||||
t.Errorf("RSA: alg=%q err=%v, want RS256/nil", alg, err)
|
||||
}
|
||||
|
||||
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AlgForKey(ec384.Public()); err == nil {
|
||||
t.Error("P-384: expected unsupported-curve error")
|
||||
}
|
||||
|
||||
if _, err := AlgForKey("not a key"); err == nil {
|
||||
t.Error("string: expected unsupported-type error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePublicKeyRoundTrip(t *testing.T) {
|
||||
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enc, err := EncodePublicKey(ec.Public())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("not valid base64: %v", err)
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
t.Fatalf("not valid PKIX: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(pub, ec.Public()) {
|
||||
t.Error("public key did not round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyJWK_EC(t *testing.T) {
|
||||
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk, err := PublicKeyJWK(ec.Public())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" {
|
||||
t.Errorf("jwk = %v, want kty=EC crv=P-256", jwk)
|
||||
}
|
||||
if jwk["use"] != "sig" {
|
||||
t.Errorf("jwk use = %v, want sig", jwk["use"])
|
||||
}
|
||||
x, _ := jwk["x"].(string)
|
||||
xb, err := base64.RawURLEncoding.DecodeString(x)
|
||||
if err != nil || len(xb) != 32 {
|
||||
t.Errorf("x = %q (decoded %d bytes), want 32-byte base64url", x, len(xb))
|
||||
}
|
||||
if _, ok := jwk["y"].(string); !ok {
|
||||
t.Error("jwk missing y")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyJWK_RSA(t *testing.T) {
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk, err := PublicKeyJWK(rsaKey.Public())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jwk["kty"] != "RSA" || jwk["n"] == "" || jwk["e"] == "" {
|
||||
t.Errorf("jwk = %v, want kty=RSA with n,e", jwk)
|
||||
}
|
||||
if jwk["use"] != "sig" {
|
||||
t.Errorf("jwk use = %v, want sig", jwk["use"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyJWK_UnsupportedCurve(t *testing.T) {
|
||||
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := PublicKeyJWK(ec384.Public()); err == nil {
|
||||
t.Error("P-384: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestECDSADERToJOSE(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Iterate so we hit signatures whose r or s has its high bit set (ASN.1 pads
|
||||
// those with a leading 0x00) and whose scalars are short (need left-zero
|
||||
// padding) — verifying fixed-width conversion in both directions.
|
||||
for i := 0; i < 64; i++ {
|
||||
digest := sha256.Sum256([]byte{byte(i), byte(i >> 8), 'j', 'w', 't'})
|
||||
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jose, err := ecdsaDERToJOSE(der, 32)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
if len(jose) != 64 {
|
||||
t.Fatalf("iter %d: len(jose)=%d, want 64 (fixed-width r||s)", i, len(jose))
|
||||
}
|
||||
r := new(big.Int).SetBytes(jose[:32])
|
||||
s := new(big.Int).SetBytes(jose[32:])
|
||||
if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) {
|
||||
t.Fatalf("iter %d: converted r||s did not verify against the public key", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestECDSADERToJOSE_Errors(t *testing.T) {
|
||||
if _, err := ecdsaDERToJOSE([]byte{0x01, 0x02, 0x03}, 32); err == nil {
|
||||
t.Error("garbage DER: expected error")
|
||||
}
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte("trailing"))
|
||||
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ecdsaDERToJOSE(append(der, 0x00), 32); err == nil {
|
||||
t.Error("DER with trailing byte: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
type stubSigner struct{}
|
||||
|
||||
func (stubSigner) EnsureKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
|
||||
func (stubSigner) PublicKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
|
||||
func (stubSigner) Sign(context.Context, KeyRef, []byte) ([]byte, string, error) { return nil, "", nil }
|
||||
|
||||
func TestCleanProbeError(t *testing.T) {
|
||||
cause := errors.New("open /dev/tpmrm0: permission denied")
|
||||
const p = "sks: error fetching Secure Hardware Vendor Data: "
|
||||
|
||||
// sks double-wraps with the same %w prefix → collapse to a single prefix.
|
||||
doubled := fmt.Errorf(p+"%w", fmt.Errorf(p+"%w", cause))
|
||||
if got, want := cleanProbeError(doubled), p+cause.Error(); got != want {
|
||||
t.Errorf("doubled: got %q, want %q", got, want)
|
||||
}
|
||||
// Triple wrap collapses too.
|
||||
if got, want := cleanProbeError(fmt.Errorf(p+"%w", doubled)), p+cause.Error(); got != want {
|
||||
t.Errorf("tripled: got %q, want %q", got, want)
|
||||
}
|
||||
// A layer adding genuinely new context is preserved.
|
||||
if got, want := cleanProbeError(fmt.Errorf("load: %w", cause)), "load: "+cause.Error(); got != want {
|
||||
t.Errorf("distinct prefix: got %q, want %q", got, want)
|
||||
}
|
||||
// nil and unwrapped-leaf cases.
|
||||
if got := cleanProbeError(nil); got != "" {
|
||||
t.Errorf("nil: got %q, want empty", got)
|
||||
}
|
||||
if got := cleanProbeError(cause); got != cause.Error() {
|
||||
t.Errorf("leaf: got %q, want %q", got, cause.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type proberSigner struct {
|
||||
stubSigner
|
||||
info HardwareInfo
|
||||
}
|
||||
|
||||
func (p proberSigner) ProbeHardware(context.Context) (HardwareInfo, error) { return p.info, nil }
|
||||
|
||||
func TestProbeHardware(t *testing.T) {
|
||||
// nil signer and a signer that does not implement HardwareProber both yield ok=false.
|
||||
if _, ok, _ := probeHardware(context.Background(), nil); ok {
|
||||
t.Error("nil signer: ok should be false")
|
||||
}
|
||||
if _, ok, _ := probeHardware(context.Background(), stubSigner{}); ok {
|
||||
t.Error("non-prober signer: ok should be false")
|
||||
}
|
||||
|
||||
want := HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
|
||||
info, ok, err := probeHardware(context.Background(), proberSigner{info: want})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("prober: ok=%v err=%v, want true/nil", ok, err)
|
||||
}
|
||||
if info != want {
|
||||
t.Errorf("info = %+v, want %+v", info, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
if Active() != nil {
|
||||
t.Skip("a signer is already registered in this build")
|
||||
}
|
||||
Register(stubSigner{})
|
||||
if _, ok := Active().(stubSigner); !ok {
|
||||
t.Error("Active did not return the registered signer")
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
active Signer
|
||||
)
|
||||
|
||||
// Register sets the active Signer. It is typically called from the init() of a
|
||||
// build-tagged or extension package that provides the platform TEE/Keychain
|
||||
// implementation. The last registration wins (one backend per platform).
|
||||
func Register(s Signer) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
active = s
|
||||
}
|
||||
|
||||
// Active returns the registered Signer, or nil if none is available — in which
|
||||
// case private_key_jwt is unsupported on this build and only client_secret auth
|
||||
// can be used.
|
||||
func Active() Signer {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return active
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// macOS non-exportable Keychain signer (compiled into every darwin build).
|
||||
//
|
||||
// It does NOT use the Secure Enclave / hardware TEE (which would require
|
||||
// code-signing entitlements that are unfriendly to open source). Instead it
|
||||
// generates an RSA-2048 key in software, imports it into a dedicated app
|
||||
// keychain as NON-EXTRACTABLE (`security import -x`), then deletes the software
|
||||
// copy — so the private key can sign but can never be exported. Signing is
|
||||
// RSASSA-PKCS1v15-SHA256 (RS256).
|
||||
//
|
||||
// Unlike the original revision, this implementation calls the Security and
|
||||
// CoreFoundation frameworks via RUNTIME FFI (github.com/ebitengine/purego)
|
||||
// instead of cgo. The security model is identical (the private key is still a
|
||||
// non-extractable keychain key and every signature is produced by the OS via
|
||||
// SecKeyCreateSignature), but the binary builds with CGO_ENABLED=0 and can be
|
||||
// cross-compiled for darwin from any host — so release binaries no longer
|
||||
// require a native macOS build runner.
|
||||
//
|
||||
// Build with: go build (cgo-free; compiled into every darwin build, no tag)
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ---- Security / CoreFoundation runtime bindings (purego, no cgo) ----
|
||||
|
||||
const (
|
||||
cfFrameworkPath = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
||||
secFrameworkPath = "/System/Library/Frameworks/Security.framework/Security"
|
||||
|
||||
// kCFStringEncodingUTF8 (CFStringBuiltInEncodings).
|
||||
cfStringEncodingUTF8 = 0x08000100
|
||||
|
||||
// OSStatus values.
|
||||
errSecSuccess = 0
|
||||
)
|
||||
|
||||
var (
|
||||
ffiOnce sync.Once
|
||||
ffiErr error
|
||||
|
||||
cfDataCreate func(alloc uintptr, bytes *byte, length int) uintptr
|
||||
cfDataGetLength func(d uintptr) int
|
||||
cfDataGetBytePtr func(d uintptr) unsafe.Pointer
|
||||
cfStringCreate func(alloc uintptr, cstr *byte, encoding uint32) uintptr
|
||||
cfArrayCreate func(alloc uintptr, values *uintptr, numValues int, cb uintptr) uintptr
|
||||
cfDictCreateMutable func(alloc uintptr, capacity int, keyCB, valCB uintptr) uintptr
|
||||
cfDictSetValue func(dict, key, val uintptr)
|
||||
cfRelease func(ref uintptr)
|
||||
cfErrorGetCode func(e uintptr) int
|
||||
secKeychainOpen func(path *byte, out *uintptr) int32
|
||||
secItemCopyMatching func(query uintptr, result *uintptr) int32
|
||||
secItemUpdate func(query, attrs uintptr) int32
|
||||
secKeyCreateSignature func(key, algo, data uintptr, errOut *uintptr) uintptr
|
||||
|
||||
// CFTypeRef data-symbol constants (deref to obtain the held ref value).
|
||||
kSecClass uintptr
|
||||
kSecClassKey uintptr
|
||||
kSecAttrKeyClass uintptr
|
||||
kSecAttrKeyClassPrivate uintptr
|
||||
kSecAttrKeyType uintptr
|
||||
kSecAttrKeyTypeRSA uintptr
|
||||
kSecAttrApplicationLabel uintptr
|
||||
kSecReturnRef uintptr
|
||||
kSecMatchSearchList uintptr
|
||||
kSecAttrLabel uintptr
|
||||
kCFBooleanTrue uintptr
|
||||
algRSAPKCS1SHA256 uintptr
|
||||
|
||||
// Struct-symbol constants (passed BY ADDRESS, not dereferenced).
|
||||
cbTypeArray uintptr
|
||||
cbDictKey uintptr
|
||||
cbDictValue uintptr
|
||||
)
|
||||
|
||||
// loadFFI resolves the framework functions and constants once. Any failure
|
||||
// (framework missing, symbol absent) is returned to every caller so signing
|
||||
// fails cleanly rather than crashing.
|
||||
func loadFFI() error {
|
||||
ffiOnce.Do(func() {
|
||||
cf, err := purego.Dlopen(cfFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
ffiErr = fmt.Errorf("keysigner: dlopen CoreFoundation: %w", err)
|
||||
return
|
||||
}
|
||||
sec, err := purego.Dlopen(secFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
ffiErr = fmt.Errorf("keysigner: dlopen Security: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cfDataCreate, cf, "CFDataCreate")
|
||||
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
|
||||
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
|
||||
purego.RegisterLibFunc(&cfStringCreate, cf, "CFStringCreateWithCString")
|
||||
purego.RegisterLibFunc(&cfArrayCreate, cf, "CFArrayCreate")
|
||||
purego.RegisterLibFunc(&cfDictCreateMutable, cf, "CFDictionaryCreateMutable")
|
||||
purego.RegisterLibFunc(&cfDictSetValue, cf, "CFDictionarySetValue")
|
||||
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
|
||||
purego.RegisterLibFunc(&cfErrorGetCode, cf, "CFErrorGetCode")
|
||||
purego.RegisterLibFunc(&secKeychainOpen, sec, "SecKeychainOpen")
|
||||
purego.RegisterLibFunc(&secItemCopyMatching, sec, "SecItemCopyMatching")
|
||||
purego.RegisterLibFunc(&secItemUpdate, sec, "SecItemUpdate")
|
||||
purego.RegisterLibFunc(&secKeyCreateSignature, sec, "SecKeyCreateSignature")
|
||||
|
||||
// CFStringRef/CFBooleanRef constants: Dlsym gives the address of the
|
||||
// exported variable; deref once to read the ref it holds.
|
||||
derefs := []struct {
|
||||
dst *uintptr
|
||||
handle uintptr
|
||||
name string
|
||||
}{
|
||||
{&kSecClass, sec, "kSecClass"},
|
||||
{&kSecClassKey, sec, "kSecClassKey"},
|
||||
{&kSecAttrKeyClass, sec, "kSecAttrKeyClass"},
|
||||
{&kSecAttrKeyClassPrivate, sec, "kSecAttrKeyClassPrivate"},
|
||||
{&kSecAttrKeyType, sec, "kSecAttrKeyType"},
|
||||
{&kSecAttrKeyTypeRSA, sec, "kSecAttrKeyTypeRSA"},
|
||||
{&kSecAttrApplicationLabel, sec, "kSecAttrApplicationLabel"},
|
||||
{&kSecReturnRef, sec, "kSecReturnRef"},
|
||||
{&kSecMatchSearchList, sec, "kSecMatchSearchList"},
|
||||
{&kSecAttrLabel, sec, "kSecAttrLabel"},
|
||||
{&kCFBooleanTrue, cf, "kCFBooleanTrue"},
|
||||
{&algRSAPKCS1SHA256, sec, "kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256"},
|
||||
}
|
||||
for _, d := range derefs {
|
||||
sym, e := purego.Dlsym(d.handle, d.name)
|
||||
if e != nil || sym == 0 {
|
||||
ffiErr = fmt.Errorf("keysigner: dlsym %s: %v", d.name, e)
|
||||
return
|
||||
}
|
||||
// deref of a stable dylib data-symbol address (not Go-managed memory), so safe.
|
||||
*d.dst = *(*uintptr)(unsafe.Pointer(sym)) //nolint:govet // unsafeptr: see comment above
|
||||
}
|
||||
|
||||
// Callback structs are passed by address (no deref).
|
||||
addrs := []struct {
|
||||
dst *uintptr
|
||||
handle uintptr
|
||||
name string
|
||||
}{
|
||||
{&cbTypeArray, cf, "kCFTypeArrayCallBacks"},
|
||||
{&cbDictKey, cf, "kCFTypeDictionaryKeyCallBacks"},
|
||||
{&cbDictValue, cf, "kCFTypeDictionaryValueCallBacks"},
|
||||
}
|
||||
for _, a := range addrs {
|
||||
sym, e := purego.Dlsym(a.handle, a.name)
|
||||
if e != nil || sym == 0 {
|
||||
ffiErr = fmt.Errorf("keysigner: dlsym %s: %v", a.name, e)
|
||||
return
|
||||
}
|
||||
*a.dst = sym
|
||||
}
|
||||
})
|
||||
return ffiErr
|
||||
}
|
||||
|
||||
// cstr returns a pointer to a NUL-terminated copy of s. The backing array stays
|
||||
// alive while the returned pointer is reachable.
|
||||
func cstr(s string) *byte {
|
||||
b := append([]byte(s), 0)
|
||||
return &b[0]
|
||||
}
|
||||
|
||||
// cfBytes wraps Go bytes in a CFData (CFDataCreate copies the bytes). Caller
|
||||
// releases the returned CFDataRef.
|
||||
func cfBytes(b []byte) uintptr {
|
||||
var p *byte
|
||||
if len(b) > 0 {
|
||||
p = &b[0]
|
||||
}
|
||||
d := cfDataCreate(0, p, len(b))
|
||||
runtime.KeepAlive(b)
|
||||
return d
|
||||
}
|
||||
|
||||
// keychainSearchArray opens the dedicated keychain file and wraps it in a
|
||||
// CFArray for kSecMatchSearchList. Caller releases the returned array.
|
||||
//
|
||||
// NOTE: SecKeychainOpen / the file-based keychain are deprecated by Apple in
|
||||
// favor of the data-protection keychain. They still function on current macOS;
|
||||
// migrating off them is tracked separately and is independent of the cgo→purego
|
||||
// change (the original cgo version used the same APIs).
|
||||
func keychainSearchArray(keychainPath string) (uintptr, error) {
|
||||
var kc uintptr
|
||||
if st := secKeychainOpen(cstr(keychainPath), &kc); st != errSecSuccess {
|
||||
return 0, keychainError("open keychain", int(st))
|
||||
}
|
||||
vals := [1]uintptr{kc}
|
||||
arr := cfArrayCreate(0, &vals[0], 1, cbTypeArray)
|
||||
cfRelease(kc) // the array retains it
|
||||
if arr == 0 {
|
||||
return 0, fmt.Errorf("keysigner: CFArrayCreate(search list) failed")
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
// findPrivateKey locates the non-extractable private key by its application
|
||||
// label within the dedicated keychain. Caller releases the returned SecKeyRef.
|
||||
func findPrivateKey(appLabel []byte, keychainPath string) (uintptr, error) {
|
||||
search, err := keychainSearchArray(keychainPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer cfRelease(search)
|
||||
|
||||
labelData := cfBytes(appLabel)
|
||||
defer cfRelease(labelData)
|
||||
|
||||
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
|
||||
if q == 0 {
|
||||
return 0, fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
|
||||
}
|
||||
defer cfRelease(q)
|
||||
cfDictSetValue(q, kSecClass, kSecClassKey)
|
||||
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
|
||||
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
|
||||
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
|
||||
cfDictSetValue(q, kSecReturnRef, kCFBooleanTrue)
|
||||
cfDictSetValue(q, kSecMatchSearchList, search)
|
||||
|
||||
var keyRef uintptr
|
||||
if st := secItemCopyMatching(q, &keyRef); st != errSecSuccess {
|
||||
return 0, keychainError("find private key", int(st))
|
||||
}
|
||||
return keyRef, nil
|
||||
}
|
||||
|
||||
// securityBin is invoked by absolute path so a poisoned PATH cannot hijack it.
|
||||
const securityBin = "/usr/bin/security"
|
||||
|
||||
// keychainSigner implements Signer using a macOS non-exportable Keychain key.
|
||||
type keychainSigner struct{}
|
||||
|
||||
func init() { Register(keychainSigner{}) }
|
||||
|
||||
// ProbeHardware reports the macOS Keychain backend backing this signer. The
|
||||
// keychain signer is compiled into every darwin build and needs no special
|
||||
// hardware, so it reports available whenever the Security tooling is present.
|
||||
// It performs no key access, so it never prompts. Implementing HardwareProber
|
||||
// is what lets `doctor` report the signer as present rather than treating the
|
||||
// (prober-less) signer as "no TEE signer in this build".
|
||||
func (keychainSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
|
||||
info := HardwareInfo{Backend: "keychain", VendorName: "macOS Keychain"}
|
||||
// A missing security tool is a status (Available=false via Reason), not a
|
||||
// probe error — so we deliberately return a nil error here.
|
||||
if _, err := vfs.Stat(securityBin); err != nil {
|
||||
info.Reason = securityBin + " not found"
|
||||
return info, nil //nolint:nilerr // absence is reported via Reason, not as an error
|
||||
}
|
||||
info.Available = true
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (keychainSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
if md, err := readKeyMetadata(ref.Label); err == nil {
|
||||
return decodePublicKey(md.PublicKey)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return createKeychainKey(ref.Label)
|
||||
}
|
||||
|
||||
func (keychainSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
md, err := readKeyMetadata(ref.Label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodePublicKey(md.PublicKey)
|
||||
}
|
||||
|
||||
func (keychainSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
|
||||
if err := loadFFI(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
md, err := readKeyMetadata(ref.Label)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
appLabel, err := hex.DecodeString(md.AppLabel)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("keysigner: decode app label: %w", err)
|
||||
}
|
||||
if len(appLabel) == 0 {
|
||||
// Guard the &appLabel[0] pointer below against corrupted metadata.
|
||||
return nil, "", fmt.Errorf("keysigner: key metadata for %q has empty app label", ref.Label)
|
||||
}
|
||||
keychain, err := ensureKeychain()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
keyRef, err := findPrivateKey(appLabel, keychain)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer cfRelease(keyRef)
|
||||
|
||||
digest := sha256.Sum256(signingInput)
|
||||
digestData := cfBytes(digest[:])
|
||||
defer cfRelease(digestData)
|
||||
|
||||
var errRef uintptr
|
||||
sigRef := secKeyCreateSignature(keyRef, algRSAPKCS1SHA256, digestData, &errRef)
|
||||
if sigRef == 0 {
|
||||
code := 0
|
||||
if errRef != 0 {
|
||||
code = cfErrorGetCode(errRef)
|
||||
cfRelease(errRef)
|
||||
}
|
||||
return nil, "", fmt.Errorf("keysigner: SecKeyCreateSignature failed (CFError %d)", code)
|
||||
}
|
||||
defer cfRelease(sigRef)
|
||||
|
||||
n := cfDataGetLength(sigRef)
|
||||
bp := cfDataGetBytePtr(sigRef)
|
||||
out := make([]byte, n)
|
||||
copy(out, unsafe.Slice((*byte)(bp), n))
|
||||
// RS256: the SecKey PKCS1v15-SHA256 signature is the JOSE signature as-is.
|
||||
return out, AlgRS256, nil
|
||||
}
|
||||
|
||||
// keyMetadata records the public key + the keychain application-label used to
|
||||
// locate the non-extractable private key.
|
||||
type keyMetadata struct {
|
||||
PublicKey string `json:"public_key"` // PKIX DER, std base64 (see EncodePublicKey)
|
||||
AppLabel string `json:"app_label"` // hex(sha1(PKCS1 public key))
|
||||
}
|
||||
|
||||
func createKeychainKey(label string) (crypto.PublicKey, error) {
|
||||
metadataPath, err := keyMetadataPath(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: generate RSA key: %w", err)
|
||||
}
|
||||
appLabel := sha1.Sum(x509.MarshalPKCS1PublicKey(&privateKey.PublicKey))
|
||||
|
||||
pemFile, err := vfs.CreateTemp("", "lark-keysigner-*.pem")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: temp key file: %w", err)
|
||||
}
|
||||
pemPath := pemFile.Name()
|
||||
defer vfs.Remove(pemPath)
|
||||
if err := pemFile.Chmod(0600); err != nil {
|
||||
pemFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
if _, err := pemFile.WriteString("-----BEGIN RSA PRIVATE KEY-----\n" +
|
||||
base64Wrap(der) + "-----END RSA PRIVATE KEY-----\n"); err != nil {
|
||||
pemFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pemFile.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
executable, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: resolve executable: %w", err)
|
||||
}
|
||||
keychain, err := ensureKeychain()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// -x: import as NON-EXTRACTABLE; the software copy (pemPath) is then removed.
|
||||
importCmd := exec.Command(securityBin, "import", pemPath, "-k", keychain, "-t", "priv", "-f", "openssl", "-x", "-A", "-T", executable)
|
||||
if out, err := importCmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("keysigner: import non-extractable key: %w: %s", err, summarizeCmdOutput(out))
|
||||
}
|
||||
if err := setKeychainKeyLabel(appLabel[:], keychain, label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encodedPub, err := EncodePublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeKeyMetadata(metadataPath, keyMetadata{PublicKey: encodedPub, AppLabel: hex.EncodeToString(appLabel[:])}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &privateKey.PublicKey, nil
|
||||
}
|
||||
|
||||
func setKeychainKeyLabel(appLabel []byte, keychain, label string) error {
|
||||
if err := loadFFI(); err != nil {
|
||||
return err
|
||||
}
|
||||
search, err := keychainSearchArray(keychain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cfRelease(search)
|
||||
|
||||
labelData := cfBytes(appLabel)
|
||||
defer cfRelease(labelData)
|
||||
|
||||
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
|
||||
if q == 0 {
|
||||
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
|
||||
}
|
||||
defer cfRelease(q)
|
||||
cfDictSetValue(q, kSecClass, kSecClassKey)
|
||||
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
|
||||
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
|
||||
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
|
||||
cfDictSetValue(q, kSecMatchSearchList, search)
|
||||
|
||||
cfLabel := cfStringCreate(0, cstr(label), cfStringEncodingUTF8)
|
||||
if cfLabel == 0 {
|
||||
return fmt.Errorf("keysigner: CFStringCreateWithCString failed")
|
||||
}
|
||||
defer cfRelease(cfLabel)
|
||||
attrs := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
|
||||
if attrs == 0 {
|
||||
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(attrs) failed")
|
||||
}
|
||||
defer cfRelease(attrs)
|
||||
cfDictSetValue(attrs, kSecAttrLabel, cfLabel)
|
||||
|
||||
if st := secItemUpdate(q, attrs); st != errSecSuccess {
|
||||
return keychainError("set keychain key label", int(st))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodePublicKey(encoded string) (crypto.PublicKey, error) {
|
||||
der, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: decode public key: %w", err)
|
||||
}
|
||||
return x509.ParsePKIXPublicKey(der)
|
||||
}
|
||||
|
||||
// base64Wrap PEM-wraps DER bytes at 64 columns.
|
||||
func base64Wrap(der []byte) string {
|
||||
enc := base64.StdEncoding.EncodeToString(der)
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(enc); i += 64 {
|
||||
end := i + 64
|
||||
if end > len(enc) {
|
||||
end = len(enc)
|
||||
}
|
||||
b.WriteString(enc[i:end])
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func readKeyMetadata(label string) (*keyMetadata, error) {
|
||||
path, err := keyMetadataPath(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err // preserves os.ErrNotExist for EnsureKey
|
||||
}
|
||||
var md keyMetadata
|
||||
if err := json.Unmarshal(data, &md); err != nil {
|
||||
return nil, fmt.Errorf("keysigner: parse key metadata: %w", err)
|
||||
}
|
||||
return &md, nil
|
||||
}
|
||||
|
||||
func writeKeyMetadata(path string, md keyMetadata) error {
|
||||
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(md, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vfs.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func ensureKeychain() (string, error) {
|
||||
keychainPath, err := keychainFilePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
password, err := keychainPassword()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := vfs.Stat(keychainPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("keysigner: stat keychain: %w", err)
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(keychainPath), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"create-keychain", "-p", password, keychainPath},
|
||||
{"set-keychain-settings", keychainPath},
|
||||
{"unlock-keychain", "-p", password, keychainPath},
|
||||
} {
|
||||
if out, err := exec.Command(securityBin, args...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("keysigner: security %s: %w: %s", args[0], err, summarizeCmdOutput(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
return keychainPath, nil
|
||||
}
|
||||
|
||||
func keysignerDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keysigner: resolve config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "lark-cli", "keysigner"), nil
|
||||
}
|
||||
|
||||
func keychainFilePath() (string, error) {
|
||||
dir, err := keysignerDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "lark-cli.keychain"), nil
|
||||
}
|
||||
|
||||
func keychainPassword() (string, error) {
|
||||
dir, err := keysignerDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, "keychain.pass")
|
||||
if data, err := vfs.ReadFile(path); err == nil {
|
||||
if pw := strings.TrimSpace(string(data)); pw != "" {
|
||||
return pw, nil
|
||||
}
|
||||
return "", fmt.Errorf("keysigner: empty keychain password")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pw := hex.EncodeToString(buf)
|
||||
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := vfs.WriteFile(path, []byte(pw+"\n"), 0600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
func keyMetadataPath(label string) (string, error) {
|
||||
dir, err := keysignerDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := sha256.Sum256([]byte(label))
|
||||
return filepath.Join(dir, "keys", hex.EncodeToString(id[:])+".json"), nil
|
||||
}
|
||||
|
||||
// summarizeCmdOutput bounds external command output before it is embedded in
|
||||
// an error: first line only, capped at 200 chars.
|
||||
func summarizeCmdOutput(out []byte) string {
|
||||
s := strings.TrimSpace(string(out))
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
const maxLen = 200
|
||||
if len(s) > maxLen {
|
||||
s = s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func keychainError(operation string, status int) error {
|
||||
switch status {
|
||||
case -25299:
|
||||
return fmt.Errorf("keysigner: %s: key already exists", operation)
|
||||
case -25300:
|
||||
return fmt.Errorf("keysigner: %s: key not found", operation)
|
||||
case -2:
|
||||
return fmt.Errorf("keysigner: %s: allocation failed", operation)
|
||||
default:
|
||||
return fmt.Errorf("keysigner: %s: Security framework status %d", operation, status)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestKeychainSignerRegistered confirms the keychain_signer build self-registers
|
||||
// (init → Register), so keysigner.Active() is non-nil. No keychain access.
|
||||
func TestKeychainSignerRegistered(t *testing.T) {
|
||||
if _, ok := Active().(keychainSigner); !ok {
|
||||
t.Fatalf("Active() = %T, want keychainSigner (keychain_signer build must self-register)", Active())
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeychainSignerRoundTrip creates a real non-extractable RSA key, signs, and
|
||||
// verifies RS256 against the returned public key. Gated by LARK_KEYCHAIN_IT
|
||||
// because it mutates the dedicated lark-cli keychain store. The signer is now
|
||||
// cgo-free (purego runtime FFI), so it runs with CGO_ENABLED=0. Run with:
|
||||
//
|
||||
// LARK_KEYCHAIN_IT=1 go test -run RoundTrip ./internal/keysigner/
|
||||
func TestKeychainSignerRoundTrip(t *testing.T) {
|
||||
if os.Getenv("LARK_KEYCHAIN_IT") == "" {
|
||||
t.Skip("set LARK_KEYCHAIN_IT=1 to run (mutates the macOS keychain)")
|
||||
}
|
||||
s := keychainSigner{}
|
||||
ref := KeyRef{Label: "lark-cli-keychain-it"}
|
||||
|
||||
pub, err := s.EnsureKey(context.Background(), ref)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
t.Fatalf("public key = %T, want *rsa.PublicKey", pub)
|
||||
}
|
||||
if alg, err := AlgForKey(pub); err != nil || alg != AlgRS256 {
|
||||
t.Fatalf("AlgForKey = %q, %v; want RS256", alg, err)
|
||||
}
|
||||
|
||||
input := []byte("header.payload")
|
||||
sig, alg, err := s.Sign(context.Background(), ref, input)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if alg != AlgRS256 {
|
||||
t.Errorf("Sign alg = %q, want RS256", alg)
|
||||
}
|
||||
h := sha256.Sum256(input)
|
||||
if err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, h[:], sig); err != nil {
|
||||
t.Errorf("RS256 signature did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// TPM 2.0 signer (compiled into every linux and windows/amd64 build, no build
|
||||
// tag required), backed by github.com/facebookincubator/sks.
|
||||
//
|
||||
// sks holds a non-exportable ECDSA P-256 key in the platform TPM and signs
|
||||
// SHA-256 digests. On Linux it talks to /dev/tpmrm0; on Windows it uses the
|
||||
// Microsoft Platform Crypto Provider (CNG). Both backends return an ASN.1 DER
|
||||
// ECDSA signature, which we convert to the fixed-width r||s form JWS requires for
|
||||
// ES256 (see ecdsaDERToJOSE). One key is created on the first private_key_jwt
|
||||
// registration (DefaultKeyLabel) and reused for subsequent app registrations and
|
||||
// every client_assertion on the same device.
|
||||
//
|
||||
// Excluded from windows/arm64: the sks Windows dependency stack (go-ole) has no
|
||||
// arm64 VARIANT and fails to compile, so windows/arm64 falls back to
|
||||
// client_secret only (keysigner.Active() is nil). On darwin the keychain signer
|
||||
// is used instead. CGO is never required.
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/facebookincubator/flog"
|
||||
"github.com/facebookincubator/sks"
|
||||
)
|
||||
|
||||
// p256ByteLen is the P-256 coordinate width. sks regular keys are always ECDSA
|
||||
// P-256, so ES256 signatures are 2*p256ByteLen bytes of r||s.
|
||||
const p256ByteLen = 32
|
||||
|
||||
// keyTag is the sks key tag. Both the Linux and Windows sks backends address
|
||||
// keys by label and ignore the tag, but the macOS backend uses it, so we set a
|
||||
// stable namespaced value for forward compatibility.
|
||||
const keyTag = "com.larksuite.cli"
|
||||
|
||||
// sksSigner implements Signer (and HardwareProber) using a non-exportable
|
||||
// TPM 2.0 ECDSA key via sks.
|
||||
type sksSigner struct{}
|
||||
|
||||
func init() {
|
||||
Register(sksSigner{})
|
||||
// This sks version logs verbose TPM-operation chatter to stderr via flog (a
|
||||
// glog fork it owns exclusively) — e.g. "Loaded TPM device", "Found handle
|
||||
// for key" on every sign. The CLI does not use flog, so silence it
|
||||
// process-wide here; real failures are returned as errors, never relied upon
|
||||
// from these logs. (Newer sks switched to slog, but that lands only on its
|
||||
// go-1.24 line, which we avoid to keep the module on go 1.23.)
|
||||
flog.SetOutput(io.Discard)
|
||||
}
|
||||
|
||||
// EnsureKey returns the public key for ref, creating the TPM key if absent.
|
||||
// sks.NewKey is find-or-create: it returns the existing key when one is present.
|
||||
func (sksSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: ensure TPM key %q: %w", ref.Label, err)
|
||||
}
|
||||
defer key.Close()
|
||||
return ecdsaPublic(ref.Label, key.Public())
|
||||
}
|
||||
|
||||
// PublicKey returns the public key for ref without creating it. FromLabelTag does
|
||||
// not touch the TPM until Public() loads the sealed key; a missing key yields a
|
||||
// nil public key, which we surface as an error — at runtime the key MUST already
|
||||
// exist (it was bound to the app at registration), so we never silently mint a
|
||||
// new, unbound one here.
|
||||
func (sksSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
pub := sks.FromLabelTag(ref.Label).Public()
|
||||
if pub == nil {
|
||||
return nil, fmt.Errorf("keysigner: TPM key %q not found", ref.Label)
|
||||
}
|
||||
return ecdsaPublic(ref.Label, pub)
|
||||
}
|
||||
|
||||
// Sign signs signingInput with the TPM key and returns a JOSE-format ES256
|
||||
// signature (fixed-width r||s) plus its alg.
|
||||
func (sksSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
|
||||
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("keysigner: load TPM key %q: %w", ref.Label, err)
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
// ES256 signs the SHA-256 digest of the JWS signing input.
|
||||
digest := sha256.Sum256(signingInput)
|
||||
der, err := key.Sign(nil, digest[:], crypto.SHA256)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("keysigner: TPM sign with key %q: %w", ref.Label, err)
|
||||
}
|
||||
// Both sks backends emit ASN.1 DER; JWS ES256 requires fixed-width r||s
|
||||
// (RFC 7518 §3.4).
|
||||
rs, err := ecdsaDERToJOSE(der, p256ByteLen)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return rs, AlgES256, nil
|
||||
}
|
||||
|
||||
// ProbeHardware reports on the TPM backing this signer without touching any key.
|
||||
// A failure to reach the TPM (no device, permission denied, not TPM 2.0) is
|
||||
// reported as Available=false with Reason set, NOT as a Go error — the probe
|
||||
// still succeeded in determining that the TEE is currently unusable.
|
||||
func (sksSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
|
||||
info := HardwareInfo{Backend: "tpm2"}
|
||||
data, err := sks.GetSecureHardwareVendorData()
|
||||
if err != nil {
|
||||
info.Reason = cleanProbeError(err)
|
||||
return info, nil
|
||||
}
|
||||
info.VendorName = data.VendorName
|
||||
info.VendorInfo = data.VendorInfo
|
||||
info.Available = data.IsTPM20CompliantDevice
|
||||
if !info.Available {
|
||||
info.Reason = "secure hardware is not a TPM 2.0 compliant device"
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ecdsaPublic asserts that an sks public key is an ECDSA key (it always is for
|
||||
// regular sks keys) so the caller gets the concrete type AlgForKey/PublicKeyJWK expect.
|
||||
func ecdsaPublic(label string, pub crypto.PublicKey) (*ecdsa.PublicKey, error) {
|
||||
ecPub, ok := pub.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("keysigner: TPM key %q public is %T, want *ecdsa.PublicKey", label, pub)
|
||||
}
|
||||
return ecPub, nil
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/facebookincubator/flog"
|
||||
"github.com/facebookincubator/sks"
|
||||
)
|
||||
|
||||
// TestFlogSilenced verifies the mechanism init() relies on to keep sks's flog
|
||||
// TPM chatter off the CLI's stderr: SetOutput redirects flog, and io.Discard
|
||||
// drops it. Cleanup restores io.Discard so init()'s silencing holds for the
|
||||
// rest of the package's tests.
|
||||
func TestFlogSilenced(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
flog.SetOutput(&buf)
|
||||
t.Cleanup(func() { flog.SetOutput(io.Discard) })
|
||||
|
||||
flog.Info("captured-line")
|
||||
if !strings.Contains(buf.String(), "captured-line") {
|
||||
t.Fatalf("flog.SetOutput(buffer) did not capture output: %q", buf.String())
|
||||
}
|
||||
|
||||
flog.SetOutput(io.Discard)
|
||||
buf.Reset()
|
||||
flog.Info("should-be-discarded")
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("flog output not discarded: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// requireTEE skips the test unless the TPM is present and usable. On a Linux
|
||||
// machine with a TPM but a restrictive device owner (`/dev/tpmrm0` is `tss:tss`
|
||||
// by default), grant access with `sudo usermod -aG tss $USER` then re-login, or
|
||||
// run the test under sudo.
|
||||
func requireTEE(t *testing.T) {
|
||||
t.Helper()
|
||||
info, err := sksSigner{}.ProbeHardware(context.Background())
|
||||
if err != nil || !info.Available {
|
||||
reason := info.Reason
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
}
|
||||
t.Skipf("TEE not available (%s)", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSKSSignerRoundTrip exercises the full registration→assertion contract
|
||||
// against the real TPM: create the key, read it back without creating, derive
|
||||
// the JWS alg + JWK, sign, and verify the fixed-width r||s output.
|
||||
func TestSKSSignerRoundTrip(t *testing.T) {
|
||||
requireTEE(t)
|
||||
|
||||
var s sksSigner
|
||||
ctx := context.Background()
|
||||
ref := KeyRef{Label: "larksuite-cli-test"}
|
||||
|
||||
// Best-effort cleanup so the test key does not linger in the TPM-sealed store.
|
||||
t.Cleanup(func() {
|
||||
if k, err := sks.NewKey(ref.Label, keyTag, false, true, nil); err == nil {
|
||||
_ = k.Remove()
|
||||
_ = k.Close()
|
||||
}
|
||||
})
|
||||
|
||||
pub, err := s.EnsureKey(ctx, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
ecPub, ok := pub.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
t.Fatalf("EnsureKey returned %T, want *ecdsa.PublicKey", pub)
|
||||
}
|
||||
|
||||
// PublicKey (no-create) must return the same key bound at EnsureKey.
|
||||
pub2, err := s.PublicKey(ctx, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
if !ecPub.Equal(pub2) {
|
||||
t.Fatal("PublicKey returned a different key than EnsureKey")
|
||||
}
|
||||
|
||||
// The JWT layer derives alg + JWK from the public key; both must work.
|
||||
if alg, err := AlgForKey(pub); err != nil || alg != AlgES256 {
|
||||
t.Fatalf("AlgForKey = %q, %v; want ES256", alg, err)
|
||||
}
|
||||
if _, err := PublicKeyJWK(pub); err != nil {
|
||||
t.Fatalf("PublicKeyJWK: %v", err)
|
||||
}
|
||||
|
||||
// Sign a representative JWS signing input and verify the converted r||s.
|
||||
input := []byte("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJjbGkifQ")
|
||||
sig, alg, err := s.Sign(ctx, ref, input)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if alg != AlgES256 {
|
||||
t.Fatalf("Sign alg = %q, want ES256", alg)
|
||||
}
|
||||
if len(sig) != 2*p256ByteLen {
|
||||
t.Fatalf("len(sig) = %d, want %d (fixed-width r||s)", len(sig), 2*p256ByteLen)
|
||||
}
|
||||
digest := sha256.Sum256(input)
|
||||
r := new(big.Int).SetBytes(sig[:p256ByteLen])
|
||||
ss := new(big.Int).SetBytes(sig[p256ByteLen:])
|
||||
if !ecdsa.Verify(ecPub, digest[:], r, ss) {
|
||||
t.Fatal("TPM signature did not verify against the public key")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.56",
|
||||
"version": "1.0.55",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -24,10 +24,6 @@ build_target() {
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
# The platform key signers are compiled in by build constraint, no tags:
|
||||
# darwin keychain (//go:build darwin) and linux/windows-amd64 TPM
|
||||
# (//go:build linux || (windows && amd64)). windows/arm64 arch-excludes the TPM
|
||||
# signer (go-ole has no arm64) and falls back to client_secret only.
|
||||
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
|
||||
echo "Building ${goos}/${goarch} -> ${output}"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go
|
||||
|
||||
@@ -89,7 +89,7 @@ def main():
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
|
||||
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
|
||||
|
||||
@@ -223,12 +223,6 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
|
||||
func (ctx *RuntimeContext) IntArray(name string) []int {
|
||||
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -1182,8 +1176,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "int_array":
|
||||
cmd.Flags().IntSlice(fl.Name, nil, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -59,29 +56,3 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagIntArray(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
var got []int
|
||||
shortcut := Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "capture screenshots",
|
||||
Flags: []Flag{
|
||||
{Name: "slide-number", Type: "int_array"},
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
|
||||
got = runtime.IntArray("slide-number")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("slide-number = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,993 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertInteractiveEventContent(t *testing.T) {
|
||||
// invalid JSON → fallback
|
||||
if got := ConvertInteractiveEventContent("not-json", nil); got != "[interactive card]" {
|
||||
t.Fatalf("invalid JSON = %q, want [interactive card]", got)
|
||||
}
|
||||
// missing user_dsl → fallback
|
||||
if got := ConvertInteractiveEventContent(`{"other":"field"}`, nil); got != "[interactive card]" {
|
||||
t.Fatalf("missing user_dsl = %q, want [interactive card]", got)
|
||||
}
|
||||
// empty user_dsl → fallback
|
||||
if got := ConvertInteractiveEventContent(`{"user_dsl":""}`, nil); got != "[interactive card]" {
|
||||
t.Fatalf("empty user_dsl = %q, want [interactive card]", got)
|
||||
}
|
||||
// user_dsl that is not a string (wrong type) → fallback
|
||||
if got := ConvertInteractiveEventContent(`{"user_dsl":123}`, nil); got != "[interactive card]" {
|
||||
t.Fatalf("non-string user_dsl = %q, want [interactive card]", got)
|
||||
}
|
||||
// valid user-2 card → <card> output
|
||||
userDsl := `{"schema":"2.0","header":{"title":{"tag":"plain_text","content":"Hello"}},"body":{"elements":[{"tag":"markdown","content":"world"}]}}`
|
||||
rawContent := `{"user_dsl":"` + strings.ReplaceAll(userDsl, `"`, `\"`) + `"}`
|
||||
got := ConvertInteractiveEventContent(rawContent, nil)
|
||||
if !strings.HasPrefix(got, `<card title="Hello">`) {
|
||||
t.Fatalf("valid card = %q, want prefix <card title=\"Hello\">", got)
|
||||
}
|
||||
if !strings.Contains(got, "world") {
|
||||
t.Fatalf("valid card = %q, want to contain 'world'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func makeMentionCard(mdContent string) string {
|
||||
obj := map[string]interface{}{
|
||||
"schema": "2.0",
|
||||
"header": map[string]interface{}{
|
||||
"title": map[string]interface{}{"tag": "plain_text", "content": "T"},
|
||||
},
|
||||
"body": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{"tag": "markdown", "content": mdContent},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ := json.Marshal(obj)
|
||||
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestConvertInteractiveEventContentMentions(t *testing.T) {
|
||||
mentions := []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"name": "test-user",
|
||||
"id": map[string]interface{}{"open_id": "fake-uid-001"},
|
||||
},
|
||||
}
|
||||
|
||||
// quoted attrs: mention_key="key"
|
||||
got := ConvertInteractiveEventContent(makeMentionCard(`hi <at mention_key="@_user_1">n</at> done`), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("quoted mention_key not resolved, got: %s", got)
|
||||
}
|
||||
|
||||
// unquoted attrs (real Lark format): <at id=ou_xxx mention_key=@_user_1></at>
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_user_1></at> done`), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("unquoted mention_key not resolved, got: %s", got)
|
||||
}
|
||||
|
||||
// mentions_key variant (unquoted)
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at mentions_key=@_user_1></at> done`), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("unquoted mentions_key not resolved, got: %s", got)
|
||||
}
|
||||
|
||||
// degradation 1: no mention_key/mentions_key attr → fall back to @id (unquoted)
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001></at> done`), mentions)
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("no mention_key unquoted: expected @id fallback, got: %s", got)
|
||||
}
|
||||
|
||||
// degradation 2: mention_key not found in mentions → fall back to @id
|
||||
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_unknown></at> done`), mentions)
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("key not in mentions: expected @id fallback, got: %s", got)
|
||||
}
|
||||
|
||||
// multi-mention: ids=id1,id2,id3 mentions_key=k1,,k3
|
||||
// k1 hits → @name(id1), k2 empty → @id2 fallback, k3 not found → @id3 fallback
|
||||
got = ConvertInteractiveEventContent(
|
||||
makeMentionCard(`<at ids=fake-uid-001,fake-uid-002,fake-uid-003 mentions_key=@_user_1,,@_unknown></at>`),
|
||||
mentions,
|
||||
)
|
||||
want := "@test-user(fake-uid-001)@fake-uid-002@fake-uid-003"
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("multi-mention unquoted: want %q in output, got: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslConverterSchema(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
// user-2.ts: schema field present, header at root, body.elements
|
||||
schema2 := cardObj{
|
||||
"schema": "2.0",
|
||||
"header": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "Schema2 Title"},
|
||||
"subtitle": cardObj{"tag": "plain_text", "content": "Sub"},
|
||||
},
|
||||
"body": cardObj{
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "body text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := c.convert(schema2)
|
||||
if got != "<card title=\"Schema2 Title\" subtitle=\"Sub\">\nbody text\n</card>" {
|
||||
t.Fatalf("schema2 = %q", got)
|
||||
}
|
||||
|
||||
// user-1.ts: no schema field, i18n_header.zh_cn, elements at root
|
||||
schema1 := cardObj{
|
||||
"i18n_header": cardObj{
|
||||
"zh_cn": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "Schema1 Title"},
|
||||
},
|
||||
},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "hr"},
|
||||
},
|
||||
}
|
||||
got = c.convert(schema1)
|
||||
if got != "<card title=\"Schema1 Title\">\n---\n</card>" {
|
||||
t.Fatalf("schema1 = %q", got)
|
||||
}
|
||||
|
||||
// user-1.ts: no schema, direct header (real Lark event format)
|
||||
schema1Direct := cardObj{
|
||||
"header": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "Direct Header Title"},
|
||||
"subtitle": cardObj{"tag": "plain_text", "content": "Direct Sub"},
|
||||
},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "direct body"},
|
||||
},
|
||||
}
|
||||
got = c.convert(schema1Direct)
|
||||
if got != "<card title=\"Direct Header Title\" subtitle=\"Direct Sub\">\ndirect body\n</card>" {
|
||||
t.Fatalf("schema1 direct header = %q", got)
|
||||
}
|
||||
|
||||
// no header, no elements → fallback
|
||||
got = c.convert(cardObj{})
|
||||
if got != "[interactive card]" {
|
||||
t.Fatalf("empty card = %q, want [interactive card]", got)
|
||||
}
|
||||
|
||||
// card with title only → valid (not "[interactive card]")
|
||||
titleOnly := cardObj{
|
||||
"schema": "2.0",
|
||||
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "TitleOnly"}},
|
||||
"body": cardObj{"elements": []interface{}{}},
|
||||
}
|
||||
got = c.convert(titleOnly)
|
||||
if !strings.Contains(got, "TitleOnly") {
|
||||
t.Fatalf("title-only card = %q, want to contain TitleOnly", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslConverterDispatch(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
elem cardObj
|
||||
want string
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "plain_text",
|
||||
elem: cardObj{"tag": "plain_text", "content": "hello"},
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "markdown",
|
||||
elem: cardObj{"tag": "markdown", "content": "**bold**"},
|
||||
want: "**bold**",
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
elem: cardObj{"tag": "hr"},
|
||||
want: "---",
|
||||
},
|
||||
{
|
||||
name: "br",
|
||||
elem: cardObj{"tag": "br"},
|
||||
want: "\n",
|
||||
},
|
||||
{
|
||||
name: "img with img_key",
|
||||
elem: cardObj{
|
||||
"tag": "img",
|
||||
"img_key": "img_v3_abc",
|
||||
"alt": cardObj{"tag": "plain_text", "content": "Banner"},
|
||||
},
|
||||
want: "🖼️ Banner(img_key:img_v3_abc)",
|
||||
},
|
||||
{
|
||||
name: "img_combination",
|
||||
elem: cardObj{
|
||||
"tag": "img_combination",
|
||||
"img_list": []interface{}{
|
||||
cardObj{"img_key": "k1"},
|
||||
cardObj{"img_key": "k2"},
|
||||
},
|
||||
},
|
||||
want: "🖼️ 2 image(s)(keys:k1,k2)",
|
||||
},
|
||||
{
|
||||
name: "button with behaviors default_url",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Open"},
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com"},
|
||||
},
|
||||
},
|
||||
want: "[Open](https://example.com)",
|
||||
},
|
||||
{
|
||||
name: "button disabled",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Nope"},
|
||||
"disabled": true,
|
||||
},
|
||||
want: "[Nope ✗]",
|
||||
},
|
||||
{
|
||||
name: "button no url",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Submit"},
|
||||
},
|
||||
want: "[Submit]",
|
||||
},
|
||||
{
|
||||
name: "action wrapper (user-1 style)",
|
||||
elem: cardObj{
|
||||
"tag": "action",
|
||||
"actions": []interface{}{
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "A"}},
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "B"}},
|
||||
},
|
||||
},
|
||||
want: "[A] [B]",
|
||||
},
|
||||
{
|
||||
name: "overflow",
|
||||
elem: cardObj{
|
||||
"tag": "overflow",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Edit"}},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Delete"}},
|
||||
},
|
||||
},
|
||||
want: "⋮ Edit, Delete",
|
||||
},
|
||||
{
|
||||
name: "select_static no selection",
|
||||
elem: cardObj{
|
||||
"tag": "select_static",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
|
||||
},
|
||||
},
|
||||
want: "{Option1 / Option2 ▼}",
|
||||
},
|
||||
{
|
||||
name: "select_static with initial_option",
|
||||
elem: cardObj{
|
||||
"tag": "select_static",
|
||||
"initial_option": "2",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
|
||||
},
|
||||
},
|
||||
want: "{Option1 / ✓Option2}",
|
||||
},
|
||||
{
|
||||
name: "multi_select_static with selected_values",
|
||||
elem: cardObj{
|
||||
"tag": "multi_select_static",
|
||||
"selected_values": []interface{}{"A"},
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptA"}, "value": "A"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptB"}, "value": "B"},
|
||||
},
|
||||
},
|
||||
want: "{✓OptA / OptB}(multi)",
|
||||
},
|
||||
{
|
||||
name: "select_person no options no selection shows placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "select_person",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "请选择"},
|
||||
},
|
||||
want: "{请选择 ▼}",
|
||||
},
|
||||
{
|
||||
name: "select_person with initial_option synthesizes from ID",
|
||||
elem: cardObj{
|
||||
"tag": "select_person",
|
||||
"initial_option": "fake-open-id-001",
|
||||
},
|
||||
want: "{✓fake-open-id-001}",
|
||||
},
|
||||
{
|
||||
name: "multi_select_person with selected_values shows IDs and multi",
|
||||
elem: cardObj{
|
||||
"tag": "multi_select_person",
|
||||
"selected_values": []interface{}{"fake-open-id-001", "fake-open-id-002"},
|
||||
},
|
||||
want: "{✓fake-open-id-001 / ✓fake-open-id-002}(multi)",
|
||||
},
|
||||
{
|
||||
name: "multi_select_person no selection shows placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "multi_select_person",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "添加人员"},
|
||||
},
|
||||
want: "{添加人员 ▼}(multi)",
|
||||
},
|
||||
{
|
||||
name: "input with default_value",
|
||||
elem: cardObj{
|
||||
"tag": "input",
|
||||
"label": cardObj{"tag": "plain_text", "content": "Reason"},
|
||||
"default_value": "prefilled",
|
||||
},
|
||||
want: "Reason: prefilled___",
|
||||
},
|
||||
{
|
||||
name: "input with placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "input",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "Type here"},
|
||||
},
|
||||
want: "Type here_____",
|
||||
},
|
||||
{
|
||||
name: "date_picker with initial_date",
|
||||
elem: cardObj{
|
||||
"tag": "date_picker",
|
||||
"initial_date": "2026-01-01",
|
||||
},
|
||||
want: "📅 2026-01-01",
|
||||
},
|
||||
{
|
||||
name: "date_picker placeholder",
|
||||
elem: cardObj{
|
||||
"tag": "date_picker",
|
||||
"placeholder": cardObj{"tag": "plain_text", "content": "Pick date"},
|
||||
},
|
||||
want: "📅 Pick date",
|
||||
},
|
||||
{
|
||||
name: "picker_time with initial_time",
|
||||
elem: cardObj{
|
||||
"tag": "picker_time",
|
||||
"initial_time": "14:30",
|
||||
},
|
||||
want: "🕐 14:30",
|
||||
},
|
||||
{
|
||||
name: "checker unchecked",
|
||||
elem: cardObj{
|
||||
"tag": "checker",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Task A"},
|
||||
},
|
||||
want: "[ ] Task A",
|
||||
},
|
||||
{
|
||||
name: "checker checked",
|
||||
elem: cardObj{
|
||||
"tag": "checker",
|
||||
"checked": true,
|
||||
"text": cardObj{"tag": "plain_text", "content": "Task B"},
|
||||
},
|
||||
want: "[x] Task B",
|
||||
},
|
||||
{
|
||||
name: "chart with chart_spec",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"title": cardObj{"text": "Sales"},
|
||||
"type": "bar",
|
||||
"xField": "month",
|
||||
"yField": "value",
|
||||
"data": cardObj{"values": []interface{}{
|
||||
cardObj{"month": "Jan", "value": float64(10)},
|
||||
cardObj{"month": "Feb", "value": float64(20)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
|
||||
},
|
||||
{
|
||||
name: "chart with compound xField array",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"title": cardObj{"text": "Sales"},
|
||||
"type": "bar",
|
||||
"xField": []interface{}{"month", "category"},
|
||||
"yField": "value",
|
||||
"data": cardObj{"values": []interface{}{
|
||||
cardObj{"month": "Jan", "category": "A", "value": float64(10)},
|
||||
cardObj{"month": "Feb", "category": "B", "value": float64(20)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
|
||||
},
|
||||
{
|
||||
name: "chart no custom title uses type name",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"type": "pie",
|
||||
"categoryField": "label",
|
||||
"valueField": "val",
|
||||
"data": cardObj{"values": []interface{}{
|
||||
cardObj{"label": "A", "val": float64(1)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "📊 Pie chart\nSummary: A:1",
|
||||
},
|
||||
{
|
||||
name: "chart vchart array data format",
|
||||
elem: cardObj{
|
||||
"tag": "chart",
|
||||
"chart_spec": cardObj{
|
||||
"type": "bar",
|
||||
"xField": "x",
|
||||
"yField": "y",
|
||||
"data": []interface{}{
|
||||
cardObj{"id": "s1", "values": []interface{}{
|
||||
cardObj{"x": "Jan", "y": float64(5)},
|
||||
}},
|
||||
cardObj{"id": "s2", "values": []interface{}{
|
||||
cardObj{"x": "Feb", "y": float64(8)},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "📊 Bar chart\nSummary: Jan:5, Feb:8",
|
||||
},
|
||||
{
|
||||
name: "text_tag",
|
||||
elem: cardObj{
|
||||
"tag": "text_tag",
|
||||
"text": cardObj{"tag": "plain_text", "content": "新功能"},
|
||||
},
|
||||
want: "「新功能」",
|
||||
},
|
||||
{
|
||||
name: "avatar with user_id",
|
||||
elem: cardObj{"tag": "avatar", "user_id": "fake-open-id-001"},
|
||||
want: "👤(id:fake-open-id-001)",
|
||||
},
|
||||
{
|
||||
name: "avatar no user_id",
|
||||
elem: cardObj{"tag": "avatar"},
|
||||
want: "👤",
|
||||
},
|
||||
{
|
||||
name: "select_img no selection",
|
||||
elem: cardObj{
|
||||
"tag": "select_img",
|
||||
"options": []interface{}{
|
||||
cardObj{"value": "v1", "img_key": "img_k1"},
|
||||
cardObj{"value": "v2", "img_key": "img_k2"},
|
||||
},
|
||||
},
|
||||
want: "{🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
|
||||
},
|
||||
{
|
||||
name: "select_img with selected",
|
||||
elem: cardObj{
|
||||
"tag": "select_img",
|
||||
"selected_values": []interface{}{"v1"},
|
||||
"options": []interface{}{
|
||||
cardObj{"value": "v1", "img_key": "img_k1"},
|
||||
cardObj{"value": "v2", "img_key": "img_k2"},
|
||||
},
|
||||
},
|
||||
want: "{✓🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
|
||||
},
|
||||
{
|
||||
name: "repeat delegates to elements",
|
||||
elem: cardObj{
|
||||
"tag": "repeat",
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "item A"},
|
||||
cardObj{"tag": "markdown", "content": "item B"},
|
||||
},
|
||||
},
|
||||
want: "item A\nitem B",
|
||||
},
|
||||
{
|
||||
name: "audio with file_key",
|
||||
elem: cardObj{"tag": "audio", "file_key": "file_abc123"},
|
||||
want: "🎵 Audio(key:file_abc123)",
|
||||
},
|
||||
{
|
||||
name: "audio fallback audio_id",
|
||||
elem: cardObj{"tag": "audio", "audio_id": "audio_xyz"},
|
||||
want: "🎵 Audio(key:audio_xyz)",
|
||||
},
|
||||
{
|
||||
name: "video with file_key",
|
||||
elem: cardObj{"tag": "video", "file_key": "video_abc"},
|
||||
want: "🎬 Video(key:video_abc)",
|
||||
},
|
||||
{
|
||||
name: "custom_icon returns empty",
|
||||
elem: cardObj{"tag": "custom_icon", "img_key": "some_key"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "standard_icon returns empty",
|
||||
elem: cardObj{"tag": "standard_icon", "token": "alarm_outlined"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "button disabled with disabled_tips",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Submit"},
|
||||
"disabled": true,
|
||||
"disabled_tips": cardObj{"tag": "plain_text", "content": "Not allowed"},
|
||||
},
|
||||
want: "[Submit ✗](tips:\"Not allowed\")",
|
||||
},
|
||||
{
|
||||
name: "button with confirm",
|
||||
elem: cardObj{
|
||||
"tag": "button",
|
||||
"text": cardObj{"tag": "plain_text", "content": "Delete"},
|
||||
"confirm": cardObj{
|
||||
"title": cardObj{"tag": "plain_text", "content": "确认"},
|
||||
"text": cardObj{"tag": "plain_text", "content": "不可撤销"},
|
||||
},
|
||||
},
|
||||
want: "[Delete](confirm:\"确认: 不可撤销\")",
|
||||
},
|
||||
{
|
||||
name: "overflow with url",
|
||||
elem: cardObj{
|
||||
"tag": "overflow",
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Open"}, "url": "https://example.com"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Copy"}, "value": "copy"},
|
||||
},
|
||||
},
|
||||
want: "⋮ [Open](https://example.com), Copy(copy)",
|
||||
},
|
||||
{
|
||||
name: "select_static with initial_index",
|
||||
elem: cardObj{
|
||||
"tag": "select_static",
|
||||
"initial_index": float64(1),
|
||||
"options": []interface{}{
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "First"}, "value": "a"},
|
||||
cardObj{"text": cardObj{"tag": "plain_text", "content": "Second"}, "value": "b"},
|
||||
},
|
||||
},
|
||||
want: "{First / ✓Second}",
|
||||
},
|
||||
{
|
||||
name: "div text with notation size",
|
||||
elem: cardObj{
|
||||
"tag": "div",
|
||||
"text": cardObj{
|
||||
"tag": "plain_text",
|
||||
"content": "小字注释",
|
||||
"text_size": "notation",
|
||||
},
|
||||
},
|
||||
want: "📝 小字注释",
|
||||
},
|
||||
{
|
||||
name: "form",
|
||||
elem: cardObj{
|
||||
"tag": "form",
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "fill this"},
|
||||
},
|
||||
},
|
||||
want: "<form>\nfill this\n</form>",
|
||||
},
|
||||
{
|
||||
name: "collapsible_panel collapsed",
|
||||
elem: cardObj{
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": false,
|
||||
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "inner"},
|
||||
},
|
||||
},
|
||||
want: "▶ Details\n inner\n▲",
|
||||
},
|
||||
{
|
||||
name: "collapsible_panel expanded",
|
||||
elem: cardObj{
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": true,
|
||||
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "inner"},
|
||||
},
|
||||
},
|
||||
want: "▼ Details\n inner\n▲",
|
||||
},
|
||||
{
|
||||
name: "interactive_container with behaviors",
|
||||
elem: cardObj{
|
||||
"tag": "interactive_container",
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com"},
|
||||
},
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "Click here"},
|
||||
},
|
||||
},
|
||||
want: "<clickable url=\"https://example.com\">\nClick here\n</clickable>",
|
||||
},
|
||||
{
|
||||
name: "interactive_container no url",
|
||||
elem: cardObj{
|
||||
"tag": "interactive_container",
|
||||
"elements": []interface{}{
|
||||
cardObj{"tag": "markdown", "content": "No link"},
|
||||
},
|
||||
},
|
||||
want: "<clickable>\nNo link\n</clickable>",
|
||||
},
|
||||
{
|
||||
name: "column_set with buttons → space-joined",
|
||||
elem: cardObj{
|
||||
"tag": "column_set",
|
||||
"columns": []interface{}{
|
||||
cardObj{"tag": "column", "elements": []interface{}{
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "X"}},
|
||||
}},
|
||||
cardObj{"tag": "column", "elements": []interface{}{
|
||||
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "Y"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
want: "[X] [Y]",
|
||||
},
|
||||
{
|
||||
name: "person",
|
||||
elem: cardObj{"tag": "person", "user_id": "fake-open-id-002"},
|
||||
want: "fake-open-id-002",
|
||||
},
|
||||
{
|
||||
name: "unknown tag fallback to content",
|
||||
elem: cardObj{"tag": "mystery", "content": "mystery content"},
|
||||
want: "mystery content",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := c.convertElement(tt.elem, 0)
|
||||
if tt.contains != "" {
|
||||
if !strings.Contains(got, tt.contains) {
|
||||
t.Fatalf("convertElement(%s) = %q, want to contain %q", tt.name, got, tt.contains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslExtractButtonURL(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
// direct url field wins first
|
||||
got := c.extractButtonURL(cardObj{
|
||||
"url": "https://example.com/direct",
|
||||
"multi_url": cardObj{"url": "https://example.com/multi"},
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
|
||||
},
|
||||
})
|
||||
if got != "https://example.com/direct" {
|
||||
t.Fatalf("direct url = %q, want https://example.com/direct", got)
|
||||
}
|
||||
|
||||
// multi_url.url when no direct url
|
||||
got = c.extractButtonURL(cardObj{
|
||||
"multi_url": cardObj{"url": "https://example.com/multi"},
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
|
||||
},
|
||||
})
|
||||
if got != "https://example.com/multi" {
|
||||
t.Fatalf("multi_url = %q, want https://example.com/multi", got)
|
||||
}
|
||||
|
||||
// behaviors default_url as last resort
|
||||
got = c.extractButtonURL(cardObj{
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
|
||||
},
|
||||
})
|
||||
if got != "https://example.com/behavior" {
|
||||
t.Fatalf("behaviors = %q, want https://example.com/behavior", got)
|
||||
}
|
||||
|
||||
// non-open_url behavior is ignored
|
||||
got = c.extractButtonURL(cardObj{
|
||||
"behaviors": []interface{}{
|
||||
cardObj{"type": "callback", "default_url": "https://example.com/callback"},
|
||||
},
|
||||
})
|
||||
if got != "" {
|
||||
t.Fatalf("non-open_url = %q, want empty", got)
|
||||
}
|
||||
|
||||
// no url anywhere → empty
|
||||
got = c.extractButtonURL(cardObj{"text": cardObj{"content": "No URL"}})
|
||||
if got != "" {
|
||||
t.Fatalf("no url = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslExtractTableCellValue(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
// nil
|
||||
if got := c.extractUserDslTableCellValue(nil); got != "" {
|
||||
t.Fatalf("nil = %q, want empty", got)
|
||||
}
|
||||
// string
|
||||
if got := c.extractUserDslTableCellValue("hello"); got != "hello" {
|
||||
t.Fatalf("string = %q, want 'hello'", got)
|
||||
}
|
||||
// float64 integer
|
||||
if got := c.extractUserDslTableCellValue(float64(42)); got != "42" {
|
||||
t.Fatalf("int float = %q, want '42'", got)
|
||||
}
|
||||
// float64 decimal
|
||||
if got := c.extractUserDslTableCellValue(float64(3.14)); got != "3.14" {
|
||||
t.Fatalf("float = %q, want '3.14'", got)
|
||||
}
|
||||
// []interface{} with text tags → 「text」 format
|
||||
got := c.extractUserDslTableCellValue([]interface{}{
|
||||
cardObj{"text": "S2", "color": "blue"},
|
||||
cardObj{"text": "M1", "color": "red"},
|
||||
})
|
||||
if got != "「S2」 「M1」" {
|
||||
t.Fatalf("tag array = %q, want '「S2」 「M1」'", got)
|
||||
}
|
||||
// cardObj with content field
|
||||
got = c.extractUserDslTableCellValue(cardObj{"content": "cell content"})
|
||||
if got != "cell content" {
|
||||
t.Fatalf("cardObj with content = %q, want 'cell content'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDslConvertTable(t *testing.T) {
|
||||
c := &userDslConverter{}
|
||||
|
||||
got := c.convertTable(cardObj{
|
||||
"columns": []interface{}{
|
||||
cardObj{"display_name": "客户名称", "name": "customer_name"},
|
||||
cardObj{"display_name": "规模", "name": "scale"},
|
||||
cardObj{"display_name": "金额", "name": "arr"},
|
||||
},
|
||||
"rows": []interface{}{
|
||||
cardObj{
|
||||
"customer_name": "飞书科技",
|
||||
"scale": []interface{}{cardObj{"text": "S2", "color": "blue"}},
|
||||
"arr": float64(16800),
|
||||
},
|
||||
},
|
||||
})
|
||||
want := "| 客户名称 | 规模 | 金额 |\n|------|------|------|\n| 飞书科技 | 「S2」 | 16800 |"
|
||||
if got != want {
|
||||
t.Fatalf("convertTable() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// no columns → empty
|
||||
if got := c.convertTable(cardObj{}); got != "" {
|
||||
t.Fatalf("no columns = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkMdMentionResolution(t *testing.T) {
|
||||
mentions := []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"name": "test-user",
|
||||
"id": map[string]interface{}{"open_id": "fake-uid-001"},
|
||||
},
|
||||
}
|
||||
|
||||
// lark_md in div.text — the real Lark event format (C01 case)
|
||||
card := map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "div",
|
||||
"text": map[string]interface{}{
|
||||
"tag": "lark_md",
|
||||
"content": "Hello <at id=fake-uid-001></at> check this.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ := json.Marshal(card)
|
||||
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
got := ConvertInteractiveEventContent(string(raw), mentions)
|
||||
if strings.Contains(got, "<at") {
|
||||
t.Fatalf("div.text lark_md: raw <at> tag not resolved, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("div.text lark_md: @id not in output, got: %s", got)
|
||||
}
|
||||
|
||||
// lark_md in note.elements (C02 case)
|
||||
card = map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "note",
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "lark_md",
|
||||
"content": "Note: <at id=fake-uid-001></at> check.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ = json.Marshal(card)
|
||||
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
got = ConvertInteractiveEventContent(string(raw), mentions)
|
||||
if strings.Contains(got, "<at") {
|
||||
t.Fatalf("note lark_md: raw <at> tag not resolved, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "@fake-uid-001") {
|
||||
t.Fatalf("note lark_md: @id not in output, got: %s", got)
|
||||
}
|
||||
|
||||
// mention_key resolution via mentions map
|
||||
card = map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "div",
|
||||
"text": map[string]interface{}{
|
||||
"tag": "lark_md",
|
||||
"content": `Hi <at mention_key="@_user_1">n</at> done.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
dslBytes, _ = json.Marshal(card)
|
||||
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
|
||||
got = ConvertInteractiveEventContent(string(raw), mentions)
|
||||
if !strings.Contains(got, "@test-user(fake-uid-001)") {
|
||||
t.Fatalf("div.text lark_md mention_key: want @test-user(fake-uid-001), got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertUserDslCardEndToEnd(t *testing.T) {
|
||||
// user-2.ts format — matches structure of docs/user-dsl/user-example-2.json
|
||||
schema2JSON := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "飞书卡片组件展示"},
|
||||
"template": "blue"
|
||||
},
|
||||
"body": {
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": "### 基础文本"},
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "img",
|
||||
"img_key": "img_v3_02122_abc",
|
||||
"alt": {"tag": "plain_text", "content": "示例图片"}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "主要按钮"},
|
||||
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
|
||||
},
|
||||
{
|
||||
"tag": "table",
|
||||
"columns": [
|
||||
{"display_name": "名称", "name": "name"},
|
||||
{"display_name": "数值", "name": "value"}
|
||||
],
|
||||
"rows": [
|
||||
{"name": "项目A", "value": 100},
|
||||
{"name": "项目B", "value": 200}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
got := convertUserDslCard(schema2JSON, nil)
|
||||
|
||||
if !strings.HasPrefix(got, `<card title="飞书卡片组件展示">`) {
|
||||
t.Fatalf("e2e schema2: missing card title prefix, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "### 基础文本") {
|
||||
t.Fatal("e2e schema2: missing markdown content")
|
||||
}
|
||||
if !strings.Contains(got, "---") {
|
||||
t.Fatal("e2e schema2: missing hr")
|
||||
}
|
||||
if !strings.Contains(got, "🖼️ 示例图片(img_key:img_v3_02122_abc)") {
|
||||
t.Fatalf("e2e schema2: missing image, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[主要按钮](https://example.com)") {
|
||||
t.Fatalf("e2e schema2: missing button, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "| 名称 | 数值 |") {
|
||||
t.Fatal("e2e schema2: missing table header")
|
||||
}
|
||||
if !strings.Contains(got, "| 项目A | 100 |") {
|
||||
t.Fatalf("e2e schema2: missing table row, got: %s", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "</card>") {
|
||||
t.Fatalf("e2e schema2: missing </card> suffix, got: %s", got)
|
||||
}
|
||||
|
||||
// user-1.ts format
|
||||
schema1JSON := `{
|
||||
"i18n_header": {
|
||||
"zh_cn": {
|
||||
"title": {"tag": "plain_text", "content": "Schema1 卡片"},
|
||||
"template": "blue"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": "Hello **World**"},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "跳转"},
|
||||
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
got = convertUserDslCard(schema1JSON, nil)
|
||||
if !strings.HasPrefix(got, `<card title="Schema1 卡片">`) {
|
||||
t.Fatalf("e2e schema1: missing card title, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Hello **World**") {
|
||||
t.Fatal("e2e schema1: missing markdown")
|
||||
}
|
||||
if !strings.Contains(got, "[跳转](https://example.com)") {
|
||||
t.Fatalf("e2e schema1: missing button, got: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestExtractPostBlocksText(t *testing.T) {
|
||||
}
|
||||
|
||||
got := extractPostBlocksText(blocks)
|
||||
want := "hello @Alice [docs](https://example.com)\n"
|
||||
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
|
||||
if got != want {
|
||||
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -39,16 +39,16 @@ func (postConverter) Convert(ctx *ConvertContext) string {
|
||||
if title, _ := body["title"].(string); title != "" {
|
||||
parts = append(parts, title)
|
||||
}
|
||||
// Prefer content_v2 blocks; fallback to content blocks
|
||||
blocks := selectContentBlocks(body)
|
||||
for _, para := range blocks {
|
||||
elems, _ := para.([]interface{})
|
||||
var line strings.Builder
|
||||
for _, el := range elems {
|
||||
elem, _ := el.(map[string]interface{})
|
||||
line.WriteString(renderPostElem(elem))
|
||||
if blocks, _ := body["content"].([]interface{}); len(blocks) > 0 {
|
||||
for _, para := range blocks {
|
||||
elems, _ := para.([]interface{})
|
||||
var line strings.Builder
|
||||
for _, el := range elems {
|
||||
elem, _ := el.(map[string]interface{})
|
||||
line.WriteString(renderPostElem(elem))
|
||||
}
|
||||
parts = append(parts, line.String())
|
||||
}
|
||||
parts = append(parts, line.String())
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(strings.Join(parts, "\n"))
|
||||
@@ -58,17 +58,6 @@ func (postConverter) Convert(ctx *ConvertContext) string {
|
||||
return ResolveMentionKeys(result, ctx.MentionMap)
|
||||
}
|
||||
|
||||
// selectContentBlocks returns content_v2 blocks when present and non-empty;
|
||||
// otherwise falls back to content blocks. This implements the content_v2
|
||||
// priority rule for post messages.
|
||||
func selectContentBlocks(body map[string]interface{}) []interface{} {
|
||||
if v2, ok := body["content_v2"].([]interface{}); ok && len(v2) > 0 {
|
||||
return v2
|
||||
}
|
||||
blocks, _ := body["content"].([]interface{})
|
||||
return blocks
|
||||
}
|
||||
|
||||
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
|
||||
if _, ok := parsed["content"]; ok {
|
||||
return parsed
|
||||
@@ -125,14 +114,10 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
var rendered string
|
||||
switch {
|
||||
case userId == "@_all" || userId == "all":
|
||||
rendered = `<at user_id="all"></at>`
|
||||
rendered = "@all"
|
||||
default:
|
||||
if name, _ := el["user_name"].(string); name != "" {
|
||||
if userId != "" && strings.HasPrefix(userId, "ou") {
|
||||
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
|
||||
} else {
|
||||
rendered = "@" + name
|
||||
}
|
||||
rendered = "@" + name
|
||||
} else {
|
||||
rendered = "@" + userId
|
||||
}
|
||||
@@ -153,7 +138,7 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
case "img":
|
||||
key, _ := el["image_key"].(string)
|
||||
if key != "" {
|
||||
return fmt.Sprintf("", key)
|
||||
return fmt.Sprintf("[Image: %s]", key)
|
||||
}
|
||||
return "[Image]"
|
||||
case "media":
|
||||
|
||||
@@ -93,13 +93,9 @@ func TestRenderPostElem(t *testing.T) {
|
||||
}{
|
||||
{name: "text", el: map[string]interface{}{"tag": "text", "text": "hello"}, want: "hello"},
|
||||
{name: "link", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com"}, want: "[doc](https://example.com)"},
|
||||
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: `<at user_id="all"></at>`},
|
||||
{name: "mention user with id", el: map[string]interface{}{"tag": "at", "user_id": "ou_user_1", "user_name": "Alice"}, want: `<at user_id="ou_user_1">Alice</at>`},
|
||||
{name: "mention user name only", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
|
||||
{name: "mention user id only", el: map[string]interface{}{"tag": "at", "user_id": "@_user_1"}, want: "@@_user_1"},
|
||||
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: ""},
|
||||
{name: "image no key", el: map[string]interface{}{"tag": "img"}, want: "[Image]"},
|
||||
{name: "md text", el: map[string]interface{}{"tag": "md", "text": "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}, want: "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"},
|
||||
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: "@all"},
|
||||
{name: "mention user", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
|
||||
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "[Image: img_123]"},
|
||||
{name: "media", el: map[string]interface{}{"tag": "media", "file_key": "file_123"}, want: "[Media: file_123]"},
|
||||
{name: "code block", el: map[string]interface{}{"tag": "code_block", "language": "go", "text": "fmt.Println(1)"}, want: "\n```go\nfmt.Println(1)\n```\n"},
|
||||
{name: "hr", el: map[string]interface{}{"tag": "hr"}, want: "\n---\n"},
|
||||
@@ -148,87 +144,3 @@ func TestRenderPostElemEmotionStyleMd(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectContentBlocks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]interface{}
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "content_v2 present and non-empty",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": []interface{}{[]interface{}{map[string]interface{}{"tag": "md", "text": "new"}}},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 empty array",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": []interface{}{},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 nil",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "content_v2 wrong type",
|
||||
body: map[string]interface{}{
|
||||
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
|
||||
"content_v2": "not_an_array",
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "both missing",
|
||||
body: map[string]interface{}{},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := selectContentBlocks(tt.body)
|
||||
if len(got) != tt.want {
|
||||
t.Fatalf("selectContentBlocks() len = %d, want %d", len(got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostConverterConvertContentV2(t *testing.T) {
|
||||
// AC-M1-H1: content_v2 present → use content_v2 blocks (md passthrough)
|
||||
ctx := &ConvertContext{
|
||||
RawContent: `{"content_v2":[[{"tag":"md","text":"##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}]],"content":[[{"tag":"text","text":"old path"}]]}`,
|
||||
}
|
||||
want := "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"
|
||||
if got := (postConverter{}).Convert(ctx); got != want {
|
||||
t.Fatalf("postConverter.Convert(content_v2) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// AC-M1-H2: no content_v2 → use content blocks with new at/img format
|
||||
ctx2 := &ConvertContext{
|
||||
RawContent: `{"content":[[{"tag":"at","user_id":"ou_xxx","user_name":"Bob"},{"tag":"text","text":" "},{"tag":"img","image_key":"img_123"}]]}`,
|
||||
Mentions: []interface{}{map[string]interface{}{"key": "ou_xxx", "id": "ou_bob", "name": "Bob"}},
|
||||
}
|
||||
want2 := `<at user_id="ou_xxx">Bob</at> `
|
||||
if got := (postConverter{}).Convert(ctx2); got != want2 {
|
||||
t.Fatalf("postConverter.Convert(content) = %q, want %q", got, want2)
|
||||
}
|
||||
|
||||
// AC-M1-E1: content_v2 empty → fallback to content
|
||||
ctx3 := &ConvertContext{
|
||||
RawContent: `{"content_v2":[],"content":[[{"tag":"text","text":"fallback path"}]]}`,
|
||||
}
|
||||
want3 := "fallback path"
|
||||
if got := (postConverter{}).Convert(ctx3); got != want3 {
|
||||
t.Fatalf("postConverter.Convert(empty content_v2) = %q, want %q", got, want3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// batchCreateKR represents a key result in the batch create input.
|
||||
type batchCreateKR struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
}
|
||||
|
||||
// batchCreateObjective represents an objective in the batch create input.
|
||||
type batchCreateObjective struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
}
|
||||
|
||||
// createdObjective tracks a created objective and its KR IDs for output.
|
||||
// KRs are automatically deleted by the backend when the objective is deleted (no need to delete them separately during rollback).
|
||||
type createdObjective struct {
|
||||
ObjectiveID string
|
||||
KRIDs []string // for output response only, not used in rollback
|
||||
}
|
||||
|
||||
// parseBatchCreateInput parses and validates the JSON input.
|
||||
func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
var objectives []batchCreateObjective
|
||||
if err := json.Unmarshal([]byte(input), &objectives); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be valid JSON array: %s", err).WithParam("--input").WithCause(err)
|
||||
}
|
||||
if len(objectives) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must contain at least one objective").WithParam("--input")
|
||||
}
|
||||
for i, obj := range objectives {
|
||||
if strings.TrimSpace(obj.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
|
||||
}
|
||||
for j, kr := range obj.KRs {
|
||||
if strings.TrimSpace(kr.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
|
||||
}
|
||||
}
|
||||
}
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// buildContentBlock converts text and mentions to a ContentBlock.
|
||||
func buildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
|
||||
|
||||
// Add text element
|
||||
textElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
}
|
||||
elements = append(elements, textElem)
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range mentions {
|
||||
mentionElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &mention,
|
||||
},
|
||||
}
|
||||
elements = append(elements, mentionElem)
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
content := buildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
queryParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return "", wrapOkrNetworkErr(err, "failed to create objective")
|
||||
}
|
||||
|
||||
objectiveID, ok := data["objective_id"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
|
||||
}
|
||||
return objectiveID, nil
|
||||
}
|
||||
|
||||
// createKR calls the API to create a key result.
|
||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
||||
content := buildContentBlock(kr.Text, kr.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
queryParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return "", wrapOkrNetworkErr(err, "failed to create key result")
|
||||
}
|
||||
|
||||
krID, ok := data["key_result_id"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
|
||||
}
|
||||
return krID, nil
|
||||
}
|
||||
|
||||
// deleteObjective deletes an objective (used for rollback).
|
||||
func deleteObjective(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) error {
|
||||
queryParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
"yes": true,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s", objectiveID)
|
||||
_, err := runtime.CallAPITyped("DELETE", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to delete objective %s during rollback", objectiveID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollback deletes created objectives in reverse order.
|
||||
// KRs are automatically deleted by the backend when the objective is deleted.
|
||||
func rollback(ctx context.Context, runtime *common.RuntimeContext, created []createdObjective) []error {
|
||||
var errsList []error
|
||||
|
||||
// Iterate in reverse order
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
obj := created[i]
|
||||
|
||||
// Delete the objective (backend automatically deletes its KRs)
|
||||
if err := deleteObjective(ctx, runtime, obj.ObjectiveID); err != nil {
|
||||
//nolint:forbidigo // intermediate wrap for rollback error collection; final error is typed via buildRollbackError
|
||||
errsList = append(errsList, fmt.Errorf("objective %s: %w", obj.ObjectiveID, err))
|
||||
}
|
||||
|
||||
// Rate limiting between deletions
|
||||
if i > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
return errsList
|
||||
}
|
||||
|
||||
// OKRBatchCreate batch creates objectives and their key results.
|
||||
var OKRBatchCreate = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+batch-create",
|
||||
Description: "Batch create OKR objectives and key results with rollback on failure",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
|
||||
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
|
||||
input := runtime.Str("input")
|
||||
if err := common.RejectDangerousCharsTyped("--input", input); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseBatchCreateInput(input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Objective creation
|
||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
objParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
objPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
apis = apis.
|
||||
POST(objPath).
|
||||
Params(objParams).
|
||||
Body(objBody).
|
||||
Desc(fmt.Sprintf("Create objective[%d]: %s", i, obj.Text))
|
||||
|
||||
// KR creations
|
||||
for j, kr := range obj.KRs {
|
||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
||||
krBody := map[string]interface{}{
|
||||
"content": krContent,
|
||||
}
|
||||
krParams := map[string]interface{}{
|
||||
"objective_id": "<objective_id_from_previous_call>",
|
||||
"user_id_type": userIDType,
|
||||
}
|
||||
krPath := "/open-apis/okr/v2/objectives/<objective_id>/key_results"
|
||||
apis = apis.
|
||||
POST(krPath).
|
||||
Params(krParams).
|
||||
Body(krBody).
|
||||
Desc(fmt.Sprintf("Create objective[%d].krs[%d]: %s", i, j, kr.Text))
|
||||
}
|
||||
}
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
objectives, err := parseBatchCreateInput(runtime.Str("input"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var created []createdObjective
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Rate limiting between objectives
|
||||
if i > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Create objective
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
|
||||
if err != nil {
|
||||
if len(created) == 0 {
|
||||
return err
|
||||
}
|
||||
rollbackErrs := rollback(ctx, runtime, created)
|
||||
return buildRollbackError(err, rollbackErrs, created)
|
||||
}
|
||||
|
||||
createdObj := createdObjective{
|
||||
ObjectiveID: objectiveID,
|
||||
}
|
||||
|
||||
// Create KRs
|
||||
for j, kr := range obj.KRs {
|
||||
// Rate limiting between KRs
|
||||
if j > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
krID, err := createKR(ctx, runtime, objectiveID, userIDType, kr)
|
||||
if err != nil {
|
||||
created = append(created, createdObj)
|
||||
rollbackErrs := rollback(ctx, runtime, created)
|
||||
return buildRollbackError(err, rollbackErrs, created)
|
||||
}
|
||||
|
||||
createdObj.KRIDs = append(createdObj.KRIDs, krID)
|
||||
}
|
||||
|
||||
created = append(created, createdObj)
|
||||
}
|
||||
|
||||
// Build response
|
||||
respCreated := make([]map[string]interface{}, 0, len(created))
|
||||
for _, obj := range created {
|
||||
respCreated = append(respCreated, map[string]interface{}{
|
||||
"objective_id": obj.ObjectiveID,
|
||||
"krs": obj.KRIDs,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": map[string]interface{}{"created": respCreated},
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Successfully created %d objective(s)\n", len(created))
|
||||
for i, obj := range created {
|
||||
fmt.Fprintf(w, "Objective[%d] ID: %s (%d KR(s))\n", i, obj.ObjectiveID, len(obj.KRIDs))
|
||||
for j, krID := range obj.KRIDs {
|
||||
fmt.Fprintf(w, " KR[%d] ID: %s\n", j, krID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildRollbackError constructs an error that includes both the original failure
|
||||
// and any rollback failures, with a list of residual IDs that could not be cleaned up.
|
||||
// KRs are automatically deleted by the backend when the objective is deleted, so we only
|
||||
// need to track objective IDs for residual cleanup.
|
||||
func buildRollbackError(originalErr error, rollbackErrs []error, created []createdObjective) error {
|
||||
var residualIDs []string
|
||||
|
||||
// Only collect residual IDs when rollback had failures
|
||||
// If rollback succeeded (len(rollbackErrs) == 0), all objectives were deleted
|
||||
if len(rollbackErrs) > 0 {
|
||||
for _, obj := range created {
|
||||
residualIDs = append(residualIDs, fmt.Sprintf("objective:%s", obj.ObjectiveID))
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("batch create failed, rolling back: %v", originalErr)
|
||||
if len(rollbackErrs) > 0 {
|
||||
var rollbackMsgs []string
|
||||
for _, e := range rollbackErrs {
|
||||
rollbackMsgs = append(rollbackMsgs, e.Error())
|
||||
}
|
||||
msg += fmt.Sprintf("; rollback also had %d failure(s): %s", len(rollbackErrs), strings.Join(rollbackMsgs, "; "))
|
||||
}
|
||||
if len(residualIDs) > 0 {
|
||||
msg += fmt.Sprintf("; residual objectives that may need manual cleanup (KRs auto-deleted with objective): %s", strings.Join(residualIDs, ", "))
|
||||
}
|
||||
|
||||
// Preserve the original error's type information if it's already a typed error
|
||||
if prob, ok := errs.ProblemOf(originalErr); ok {
|
||||
switch prob.Category {
|
||||
case errs.CategoryAPI:
|
||||
return errs.NewAPIError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
case errs.CategoryNetwork:
|
||||
return errs.NewNetworkError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
case errs.CategoryValidation:
|
||||
return errs.NewValidationError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
case errs.CategoryInternal:
|
||||
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
default:
|
||||
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
|
||||
}
|
||||
}
|
||||
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithCause(originalErr)
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-batch-create",
|
||||
AppSecret: "secret-okr-batch-create",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRBatchCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
const validBatchCreateInput = `[
|
||||
{"text":"Objective 1","mention":["ou_123"],"krs":[{"text":"KR 1.1","mention":["ou_456"]}]},
|
||||
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
|
||||
]`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--input", validBatchCreateInput,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("expected --cycle-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "abc",
|
||||
"--input", validBatchCreateInput,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --cycle-id")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_MissingInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "input") {
|
||||
t.Fatalf("expected --input required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidInputJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --input JSON")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyInputArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", "[]",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --input array")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyObjectiveText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty objective text")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].text") {
|
||||
t.Fatalf("expected error to mention objective[0].text, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":""}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty KR text")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].krs[0].text") {
|
||||
t.Fatalf("expected error to mention objective[0].krs[0].text, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInput,
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --user-id-type")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--user-id-type" {
|
||||
t.Fatalf("expected param --user-id-type, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "200",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "101",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/101/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "201",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/101/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "202",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInput,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestBatchCreateDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInput,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
|
||||
t.Fatalf("dry-run output should contain objective creation API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "POST") {
|
||||
t.Fatalf("dry-run output should contain POST method, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/") || !strings.Contains(output, "/key_results") {
|
||||
t.Fatalf("dry-run output should contain KR creation API path, got: %s", output)
|
||||
}
|
||||
// Verify content is in the body
|
||||
if !strings.Contains(output, "Objective 1") {
|
||||
t.Fatalf("dry-run output should contain objective text, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "KR 1.1") {
|
||||
t.Fatalf("dry-run output should contain KR text, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "200",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
ok, _ := data["ok"].(bool)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true in output")
|
||||
}
|
||||
dataField, _ := data["data"].(map[string]interface{})
|
||||
created, _ := dataField["created"].([]interface{})
|
||||
if len(created) != 1 {
|
||||
t.Fatalf("expected 1 created objective, got %d", len(created))
|
||||
}
|
||||
obj, _ := created[0].(map[string]interface{})
|
||||
if obj["objective_id"] != "100" {
|
||||
t.Fatalf("expected objective_id=100, got %v", obj["objective_id"])
|
||||
}
|
||||
krs, _ := obj["krs"].([]interface{})
|
||||
if len(krs) != 1 || krs[0] != "200" {
|
||||
t.Fatalf("expected krs=[200], got %v", krs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1"}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
// Should be a typed error from the API
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != "api" {
|
||||
t.Fatalf("expected api category, got %q", prob.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_APIErrorOnKR_TriggersRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
// First objective creation succeeds
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
// KR creation fails
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
// Rollback: delete the created objective
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/okr/v2/objectives/100",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for KR creation failure")
|
||||
}
|
||||
// Error should mention rollback
|
||||
if !strings.Contains(err.Error(), "rolling back") && !strings.Contains(err.Error(), "rollback") {
|
||||
t.Fatalf("expected error to mention rollback, got: %v", err)
|
||||
}
|
||||
// Assert typed error metadata
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected api category (preserved from original error), got %q", prob.Category)
|
||||
}
|
||||
// Assert cause preservation
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected error to wrap APIError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, apiErr) {
|
||||
t.Fatal("expected errors.Is to find the wrapped APIError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_RollbackDeleteFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
// Objective creation succeeds
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
// KR creation fails
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
// Rollback delete also fails
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/okr/v2/objectives/100",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 9999999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for KR creation failure")
|
||||
}
|
||||
// Error should mention residual resources
|
||||
if !strings.Contains(err.Error(), "residual") && !strings.Contains(err.Error(), "manual cleanup") {
|
||||
t.Fatalf("expected error to mention residual resources, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective:100") {
|
||||
t.Fatalf("expected error to list residual objective ID, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for helper functions ---
|
||||
|
||||
func TestParseBatchCreateInput_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := `[{"text":"Obj 1","mention":["ou_123"],"krs":[{"text":"KR 1","mention":["ou_456"]}]}]`
|
||||
objs, err := parseBatchCreateInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("expected 1 objective, got %d", len(objs))
|
||||
}
|
||||
if objs[0].Text != "Obj 1" {
|
||||
t.Fatalf("expected text 'Obj 1', got %q", objs[0].Text)
|
||||
}
|
||||
if len(objs[0].Mention) != 1 || objs[0].Mention[0] != "ou_123" {
|
||||
t.Fatalf("expected mention ['ou_123'], got %v", objs[0].Mention)
|
||||
}
|
||||
if len(objs[0].KRs) != 1 {
|
||||
t.Fatalf("expected 1 KR, got %d", len(objs[0].KRs))
|
||||
}
|
||||
if objs[0].KRs[0].Text != "KR 1" {
|
||||
t.Fatalf("expected KR text 'KR 1', got %q", objs[0].KRs[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
block := cb.Blocks[0]
|
||||
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
|
||||
t.Fatalf("expected paragraph block type")
|
||||
}
|
||||
if block.Paragraph == nil {
|
||||
t.Fatal("expected non-nil paragraph")
|
||||
}
|
||||
// Should have 3 elements: 1 text + 2 mentions
|
||||
if len(block.Paragraph.Elements) != 3 {
|
||||
t.Fatalf("expected 3 paragraph elements, got %d", len(block.Paragraph.Elements))
|
||||
}
|
||||
// First element should be textRun
|
||||
if block.Paragraph.Elements[0].ParagraphElementType == nil ||
|
||||
*block.Paragraph.Elements[0].ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected first element to be textRun")
|
||||
}
|
||||
if block.Paragraph.Elements[0].TextRun == nil || *block.Paragraph.Elements[0].TextRun.Text != "Test text" {
|
||||
t.Fatalf("expected text 'Test text', got %v", block.Paragraph.Elements[0].TextRun)
|
||||
}
|
||||
// Second and third should be mentions
|
||||
for i := 1; i <= 2; i++ {
|
||||
if block.Paragraph.Elements[i].ParagraphElementType == nil ||
|
||||
*block.Paragraph.Elements[i].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatalf("expected element %d to be mention", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// parseIndicatorValue parses and validates the indicator value.
|
||||
func parseIndicatorValue(valueStr string) (float64, error) {
|
||||
value, err := strconv.ParseFloat(valueStr, 64)
|
||||
if err != nil {
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value").WithCause(err)
|
||||
}
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < -99999999999 || value > 99999999999 {
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// fetchIndicatorID fetches the indicator ID for an objective or key result.
|
||||
// The indicators.list API returns a single indicator object (not a list),
|
||||
// which always exists (may be a default empty indicator).
|
||||
func fetchIndicatorID(ctx context.Context, runtime *common.RuntimeContext, level string, id string) (string, error) {
|
||||
var path string
|
||||
var params map[string]interface{}
|
||||
|
||||
if level == "objective" {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
|
||||
params = map[string]interface{}{"page_size": 100}
|
||||
} else {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
|
||||
params = map[string]interface{}{"page_size": 100}
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", path, params, nil)
|
||||
if err != nil {
|
||||
return "", wrapOkrNetworkErr(err, "failed to fetch indicators")
|
||||
}
|
||||
|
||||
// Parse response to get indicator ID
|
||||
// Response format: {"indicator": {"id": "...", ...}} (single object, not a list)
|
||||
indicator, ok := data["indicator"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator field not found in response")
|
||||
}
|
||||
|
||||
indicatorID, ok := indicator["id"].(string)
|
||||
if !ok || indicatorID == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator ID not found or empty")
|
||||
}
|
||||
|
||||
return indicatorID, nil
|
||||
}
|
||||
|
||||
// OKRIndicatorUpdate updates the current value of an indicator for an objective or key result.
|
||||
var OKRIndicatorUpdate = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+indicator-update",
|
||||
Description: "Update the indicator current value for an objective or key result",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "level to update: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
|
||||
{Name: "id", Desc: "objective or key result ID (int64), Required."},
|
||||
{Name: "value", Desc: "new current value for the indicator (number), Required."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
|
||||
}
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
id := runtime.Str("id")
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id is required").WithParam("--id")
|
||||
}
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id must be a valid int64").WithParam("--id")
|
||||
}
|
||||
if runtime.Str("value") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--value is required").WithParam("--value")
|
||||
}
|
||||
if _, err := parseIndicatorValue(runtime.Str("value")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
level := runtime.Str("level")
|
||||
id := runtime.Str("id")
|
||||
value, _ := parseIndicatorValue(runtime.Str("value"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
var listPath string
|
||||
if level == "objective" {
|
||||
listPath = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
|
||||
} else {
|
||||
listPath = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
|
||||
}
|
||||
|
||||
// First API: fetch indicator list
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(map[string]interface{}{"page_size": 100}).
|
||||
Desc(fmt.Sprintf("Fetch indicators for the %s to get indicator ID", level))
|
||||
|
||||
// Second API: patch indicator value
|
||||
patchPath := "/open-apis/okr/v2/indicators/:indicator_id"
|
||||
patchBody := map[string]interface{}{
|
||||
"current_value": value,
|
||||
}
|
||||
apis = apis.
|
||||
PATCH(patchPath).
|
||||
Body(patchBody).
|
||||
Set("indicator_id", "<indicator_id_from_list>").
|
||||
Desc("Update indicator current value")
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
id := runtime.Str("id")
|
||||
value, err := parseIndicatorValue(runtime.Str("value"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Fetch indicator ID
|
||||
indicatorID, err := fetchIndicatorID(ctx, runtime, level, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Update indicator value
|
||||
patchPath := fmt.Sprintf("/open-apis/okr/v2/indicators/%s", indicatorID)
|
||||
patchBody := map[string]interface{}{
|
||||
"current_value": value,
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped("PATCH", patchPath, nil, patchBody)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update indicator value")
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := map[string]interface{}{
|
||||
"indicator_id": indicatorID,
|
||||
"current_value": value,
|
||||
"level": level,
|
||||
"target_id": id,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Indicator [%s]\n", indicatorID)
|
||||
fmt.Fprintf(w, " Level: %s\n", level)
|
||||
fmt.Fprintf(w, " Target ID: %s\n", id)
|
||||
fmt.Fprintf(w, " Current Value: %v\n", value)
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func indicatorUpdateTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-indicator-update",
|
||||
AppSecret: "secret-okr-indicator-update",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runIndicatorUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRIndicatorUpdate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestIndicatorUpdateValidate_MissingLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_InvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "invalid",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid level")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_MissingID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "id") {
|
||||
t.Fatalf("expected --id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_InvalidID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "not-a-number",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid id")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--id" {
|
||||
t.Fatalf("expected param --id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_MissingValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "value") {
|
||||
t.Fatalf("expected --value required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_InvalidValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "not-a-number",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid value")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--value" {
|
||||
t.Fatalf("expected param --value, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateValidate_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "75.5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestIndicatorUpdateExecute_Objectives_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return false
|
||||
}
|
||||
val, ok := data["current_value"].(float64)
|
||||
return ok && val == 75.5
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "75.5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateExecute_KeyResults_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/key_results/456/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-789",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-789",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "key-result",
|
||||
"--id", "456",
|
||||
"--value", "100",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateExecute_FetchAPIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators - API error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 9999,
|
||||
"msg": "fetch error",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for fetch API failure")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, apiErr) {
|
||||
t.Fatal("errors.Is should find the APIError in the chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorUpdateExecute_PatchAPIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
|
||||
// Mock fetch indicators
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123/indicators",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"indicator": map[string]interface{}{
|
||||
"id": "ind-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock patch indicator - API error
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/okr/v2/indicators/ind-456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 9999,
|
||||
"msg": "patch error",
|
||||
},
|
||||
})
|
||||
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
|
||||
"+indicator-update",
|
||||
"--level", "objective",
|
||||
"--id", "123",
|
||||
"--value", "50",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for patch API failure")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, apiErr) {
|
||||
t.Fatal("errors.Is should find the APIError in the chain")
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseIndicatorValue tests ---
|
||||
|
||||
func TestParseIndicatorValue_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []string{"0", "100", "75.5", "-10", "0.001", "99999999999"}
|
||||
for _, v := range tests {
|
||||
result, err := parseIndicatorValue(v)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for %q, got: %v", v, err)
|
||||
}
|
||||
_ = result
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIndicatorValue_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []string{"", "abc", "1e100000", "100000000000"}
|
||||
for _, v := range tests {
|
||||
_, err := parseIndicatorValue(v)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// reorderItem is the interface for items that have an ID.
|
||||
type reorderItem interface {
|
||||
GetID() string
|
||||
}
|
||||
|
||||
// reorderOp represents a single reorder operation.
|
||||
type reorderOp struct {
|
||||
ID string `json:"id"`
|
||||
Position int32 `json:"position"`
|
||||
}
|
||||
|
||||
// parseReorderOps parses and validates the --ops JSON array.
|
||||
func parseReorderOps(opsStr string) ([]reorderOp, error) {
|
||||
var ops []reorderOp
|
||||
if err := json.Unmarshal([]byte(opsStr), &ops); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must be valid JSON array: %s", err).WithParam("--ops").WithCause(err)
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must contain at least one operation").WithParam("--ops")
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
seenPos := make(map[int32]bool)
|
||||
for i, op := range ops {
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].id is required and cannot be empty", i).WithParam("--ops")
|
||||
}
|
||||
if op.Position <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].position must be a positive integer", i).WithParam("--ops")
|
||||
}
|
||||
if seen[op.ID] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --ops", op.ID).WithParam("--ops")
|
||||
}
|
||||
if seenPos[op.Position] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate position %d in --ops", op.Position).WithParam("--ops")
|
||||
}
|
||||
seen[op.ID] = true
|
||||
seenPos[op.Position] = true
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// fetchObjectives fetches all objectives in a cycle.
|
||||
func fetchObjectives(ctx context.Context, runtime *common.RuntimeContext, cycleID string) ([]Objective, error) {
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
var objectives []Objective
|
||||
page := 0
|
||||
|
||||
for {
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return nil, wrapOkrNetworkErr(err, "failed to fetch objectives")
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var obj Objective
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
objectives = append(objectives, obj)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// Sort objectives by position
|
||||
sort.Slice(objectives, func(i, j int) bool {
|
||||
pi := int32(0)
|
||||
if objectives[i].Position != nil {
|
||||
pi = *objectives[i].Position
|
||||
}
|
||||
pj := int32(0)
|
||||
if objectives[j].Position != nil {
|
||||
pj = *objectives[j].Position
|
||||
}
|
||||
return pi < pj
|
||||
})
|
||||
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// fetchKeyResults fetches all key results for an objective.
|
||||
func fetchKeyResults(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) ([]KeyResult, error) {
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
var keyResults []KeyResult
|
||||
page := 0
|
||||
|
||||
for {
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return nil, wrapOkrNetworkErr(err, "failed to fetch key results")
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// Sort key results by position
|
||||
sort.Slice(keyResults, func(i, j int) bool {
|
||||
pi := int32(0)
|
||||
if keyResults[i].Position != nil {
|
||||
pi = *keyResults[i].Position
|
||||
}
|
||||
pj := int32(0)
|
||||
if keyResults[j].Position != nil {
|
||||
pj = *keyResults[j].Position
|
||||
}
|
||||
return pi < pj
|
||||
})
|
||||
|
||||
return keyResults, nil
|
||||
}
|
||||
|
||||
// buildReorderedIDs builds the complete ordered ID list from current items and reorder ops.
|
||||
// Positions are treated as 1-indexed placement keys stored in a map (safe for large values).
|
||||
// Items are first placed at user-specified positions, remaining items fill empty slots
|
||||
// in original order starting from position 1, and final output is sorted by position.
|
||||
func buildReorderedIDs[T reorderItem](items []T, ops []reorderOp, total int) ([]string, error) {
|
||||
// Create a map of ID to current position
|
||||
idToPos := make(map[string]int)
|
||||
for i, item := range items {
|
||||
idToPos[item.GetID()] = i
|
||||
}
|
||||
|
||||
// Validate all ops IDs exist
|
||||
for _, op := range ops {
|
||||
if _, ok := idToPos[op.ID]; !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "id %q not found in current list", op.ID).WithParam("--ops")
|
||||
}
|
||||
}
|
||||
|
||||
// Use map to store position -> ID (1-indexed, safe for large position values)
|
||||
posToID := make(map[int]string)
|
||||
used := make(map[string]bool)
|
||||
for _, op := range ops {
|
||||
posToID[int(op.Position)] = op.ID
|
||||
used[op.ID] = true
|
||||
}
|
||||
|
||||
// Collect unused items in original order
|
||||
var unused []string
|
||||
for _, item := range items {
|
||||
id := item.GetID()
|
||||
if !used[id] {
|
||||
unused = append(unused, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill empty slots starting from position 1, in original order
|
||||
unusedIdx := 0
|
||||
for pos := 1; unusedIdx < len(unused); pos++ {
|
||||
if _, occupied := posToID[pos]; !occupied {
|
||||
posToID[pos] = unused[unusedIdx]
|
||||
unusedIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all positions, sort them, and build result in position order
|
||||
positions := make([]int, 0, len(posToID))
|
||||
for pos := range posToID {
|
||||
positions = append(positions, pos)
|
||||
}
|
||||
sort.Ints(positions)
|
||||
|
||||
result := make([]string, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
result = append(result, posToID[pos])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetID implements the interface for Objective.
|
||||
func (o Objective) GetID() string { return o.ID }
|
||||
|
||||
// GetID implements the interface for KeyResult.
|
||||
func (k KeyResult) GetID() string { return k.ID }
|
||||
|
||||
// OKRReorder adjusts the position of objectives or key results.
|
||||
var OKRReorder = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+reorder",
|
||||
Description: "Adjust the position (order) of OKR objectives or key results",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "level to reorder: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64), Required."},
|
||||
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
|
||||
{Name: "ops", Desc: "JSON array of reorder operations: [{\"id\":\"...\",\"position\":1}], Required.", Input: []string{common.File, common.Stdin}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if strings.TrimSpace(level) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
|
||||
}
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if strings.TrimSpace(cycleID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
|
||||
if level == "key-result" {
|
||||
objID := runtime.Str("objective-id")
|
||||
if objID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
|
||||
}
|
||||
}
|
||||
|
||||
opsStr := runtime.Str("ops")
|
||||
if strings.TrimSpace(opsStr) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops is required").WithParam("--ops")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--ops", opsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseReorderOps(opsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, _ := parseReorderOps(runtime.Str("ops"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
if level == "objective" {
|
||||
// First fetch objectives
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all objectives in the cycle to determine current order")
|
||||
|
||||
// Then reorder
|
||||
reorderParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
// Build sample body with placeholder IDs
|
||||
objectiveIDs := make([]string, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
objectiveIDs = append(objectiveIDs, op.ID)
|
||||
}
|
||||
reorderBody := map[string]interface{}{
|
||||
"objective_ids": objectiveIDs,
|
||||
}
|
||||
reorderPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
|
||||
apis = apis.
|
||||
PUT(reorderPath).
|
||||
Params(reorderParams).
|
||||
Body(reorderBody).
|
||||
Desc("Update objective positions (full list sent, not just changes)")
|
||||
} else {
|
||||
// key-result level
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all key results for the objective to determine current order")
|
||||
|
||||
reorderParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
// Build sample body with placeholder IDs
|
||||
keyResultIDs := make([]string, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
keyResultIDs = append(keyResultIDs, op.ID)
|
||||
}
|
||||
reorderBody := map[string]interface{}{
|
||||
"key_result_ids": keyResultIDs,
|
||||
}
|
||||
reorderPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
|
||||
apis = apis.
|
||||
PUT(reorderPath).
|
||||
Params(reorderParams).
|
||||
Body(reorderBody).
|
||||
Desc("Update key result positions (full list sent, not just changes)")
|
||||
}
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, err := parseReorderOps(runtime.Str("ops"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var reorderedIDs []string
|
||||
var total int
|
||||
|
||||
if level == "objective" {
|
||||
objectives, err := fetchObjectives(ctx, runtime, cycleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(objectives)
|
||||
|
||||
reorderedIDs, err = buildReorderedIDs(objectives, ops, total)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Submit reorder
|
||||
params := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"objective_ids": reorderedIDs,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update objective positions")
|
||||
}
|
||||
} else {
|
||||
// key-result level
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(keyResults)
|
||||
|
||||
reorderedIDs, err = buildReorderedIDs(keyResults, ops, total)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Submit reorder
|
||||
params := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"key_result_ids": reorderedIDs,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update key result positions")
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := map[string]interface{}{
|
||||
"level": level,
|
||||
"cycle_id": cycleID,
|
||||
"total": total,
|
||||
"ordered": reorderedIDs,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Successfully reordered %d %s(s)\n", total, level)
|
||||
fmt.Fprintln(w, "New order:")
|
||||
for i, id := range reorderedIDs {
|
||||
fmt.Fprintf(w, " Position %d: %s\n", i+1, id)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,712 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// testReorderItem implements reorderItem for testing.
|
||||
type testReorderItem struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func (t testReorderItem) GetID() string { return t.id }
|
||||
|
||||
func reorderTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-reorder",
|
||||
AppSecret: "secret-okr-reorder",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runReorderShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRReorder.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestReorderValidate_MissingLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_InvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "invalid",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --level")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("expected --cycle-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --objective-id when --level=key-result")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_MissingOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "ops") {
|
||||
t.Fatalf("expected --ops required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_InvalidOpsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --ops JSON")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_EmptyOpsArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", "[]",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --ops array")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_DuplicateID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1},{"id":"1","position":2}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate id in --ops")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate id") {
|
||||
t.Fatalf("expected error to mention duplicate id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_DuplicatePosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":1},{"id":"2","position":1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate position in --ops")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate position") {
|
||||
t.Fatalf("expected error to mention duplicate position, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderValidate_NegativePosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":0}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for position <= 0")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestReorderDryRun_Objectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
|
||||
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GET") {
|
||||
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_position") {
|
||||
t.Fatalf("dry-run output should contain position update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "PUT") {
|
||||
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "objective_ids") {
|
||||
t.Fatalf("dry-run output should contain objective_ids in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderDryRun_KeyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
|
||||
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_position") {
|
||||
t.Fatalf("dry-run output should contain key_results position update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "key_result_ids") {
|
||||
t.Fatalf("dry-run output should contain key_result_ids in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestReorderExecute_Objectives_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "3", "position": 3, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock reorder
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "3", "position": 1},
|
||||
map[string]interface{}{"id": "1", "position": 2},
|
||||
map[string]interface{}{"id": "2", "position": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"3","position":1}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "objective" {
|
||||
t.Fatalf("expected level=objective, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
ordered, _ := data["ordered"].([]interface{})
|
||||
if len(ordered) != 3 {
|
||||
t.Fatalf("expected 3 items in ordered list, got %d", len(ordered))
|
||||
}
|
||||
// First should be 3, then 1, then 2
|
||||
if ordered[0] != "3" || ordered[1] != "1" || ordered[2] != "2" {
|
||||
t.Fatalf("expected ordered [3,1,2], got %v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderExecute_KeyResults_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch key results
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "kr1", "position": 1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "kr2", "position": 2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock reorder
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results_position",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "kr2", "position": 1},
|
||||
map[string]interface{}{"id": "kr1", "position": 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--ops", `[{"id":"kr2","position":1}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "key-result" {
|
||||
t.Fatalf("expected level=key-result, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
ordered, _ := data["ordered"].([]interface{})
|
||||
if len(ordered) != 2 {
|
||||
t.Fatalf("expected 2 items in ordered list, got %d", len(ordered))
|
||||
}
|
||||
if ordered[0] != "kr2" || ordered[1] != "kr1" {
|
||||
t.Fatalf("expected ordered [kr2,kr1], got %v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderExecute_PositionOutOfRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch objectives (only 2 items)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return false
|
||||
}
|
||||
ids, ok := data["objective_ids"].([]interface{})
|
||||
if !ok || len(ids) != 2 {
|
||||
return false
|
||||
}
|
||||
// position 5 should be clamped to position 2 (last), so order is [2, 1]
|
||||
return ids[0] == "2" && ids[1] == "1"
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"1","position":5}]`, // position 5 exceeds total of 2, should clamp to last
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for out-of-range position (should clamp): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderExecute_IDNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runReorderShortcut(t, f, stdout, []string{
|
||||
"+reorder",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--ops", `[{"id":"999","position":1}]`, // ID 999 doesn't exist
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent ID")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--ops" {
|
||||
t.Fatalf("expected param --ops, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected error to mention not found, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for helper functions ---
|
||||
|
||||
func TestParseReorderOps_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := parseReorderOps(`[{"id":"1","position":2},{"id":"2","position":1}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("expected 2 ops, got %d", len(ops))
|
||||
}
|
||||
if ops[0].ID != "1" || ops[0].Position != 2 {
|
||||
t.Fatalf("expected op[0] = {1,2}, got %+v", ops[0])
|
||||
}
|
||||
if ops[1].ID != "2" || ops[1].Position != 1 {
|
||||
t.Fatalf("expected op[1] = {2,1}, got %+v", ops[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []Objective{
|
||||
{ID: "1"},
|
||||
{ID: "2"},
|
||||
{ID: "3"},
|
||||
{ID: "4"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "4", Position: 1},
|
||||
{ID: "2", Position: 3},
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 4 at pos1, 1 at pos2 (unchanged), 2 at pos3, 3 at pos4
|
||||
expected := []string{"4", "1", "2", "3"}
|
||||
if len(result) != len(expected) {
|
||||
t.Fatalf("expected %d items, got %d", len(expected), len(result))
|
||||
}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_SingleClampToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "1", Position: 99}, // position 99 exceeds total of 4, should clamp to last
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 2 at pos1, 3 at pos2, 4 at pos3, 1 at pos4 (clamped)
|
||||
expected := []string{"2", "3", "4", "1"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_MultipleClampToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "1", Position: 10}, // position 10 exceeds total of 5
|
||||
{ID: "2", Position: 20}, // position 20 exceeds total of 5
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 3 at pos1, 4 at pos2, 5 at pos3, 1 at pos4 (clamped, pos10 < pos20), 2 at pos5 (clamped)
|
||||
expected := []string{"3", "4", "5", "1", "2"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_MixedClamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
|
||||
}
|
||||
ops := []reorderOp{
|
||||
{ID: "5", Position: 1}, // normal position
|
||||
{ID: "1", Position: 99}, // clamped to end
|
||||
{ID: "2", Position: 50}, // clamped to end, but position 50 < 99, so comes before 1
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 5 at pos1, 3 at pos2, 4 at pos3, 2 at pos4 (clamped pos50), 1 at pos5 (clamped pos99)
|
||||
expected := []string{"5", "3", "4", "2", "1"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReorderedIDs_LargePositionSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
items := []testReorderItem{
|
||||
{id: "1"}, {id: "2"}, {id: "3"},
|
||||
}
|
||||
// Very large position should not cause memory issues with map-based implementation
|
||||
ops := []reorderOp{
|
||||
{ID: "1", Position: 100000000}, // 10^8, would be dangerous with slice
|
||||
}
|
||||
result, err := buildReorderedIDs(items, ops, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expected: 2 at pos1, 3 at pos2, 1 at pos3 (clamped to end)
|
||||
expected := []string{"2", "3", "1"}
|
||||
for i, id := range expected {
|
||||
if result[i] != id {
|
||||
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// weightItem is the interface for items that have ID and weight.
|
||||
type weightItem interface {
|
||||
GetID() string
|
||||
GetWeight() float64
|
||||
}
|
||||
|
||||
// weightOp represents a single weight assignment.
|
||||
type weightOp struct {
|
||||
ID string `json:"id"`
|
||||
Weight float64 `json:"weight"`
|
||||
}
|
||||
|
||||
// parseWeightOps parses and validates the --weights JSON array.
|
||||
func parseWeightOps(weightsStr string) ([]weightOp, error) {
|
||||
var ops []weightOp
|
||||
if err := json.Unmarshal([]byte(weightsStr), &ops); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must be valid JSON array: %s", err).WithParam("--weights").WithCause(err)
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must contain at least one weight assignment").WithParam("--weights")
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var sum float64
|
||||
for i, op := range ops {
|
||||
if strings.TrimSpace(op.ID) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].id is required and cannot be empty", i).WithParam("--weights")
|
||||
}
|
||||
if op.Weight < 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be non-negative", i).WithParam("--weights")
|
||||
}
|
||||
if op.Weight > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be <= 1", i).WithParam("--weights")
|
||||
}
|
||||
// Check for at most 3 decimal places
|
||||
if math.Round(op.Weight*1000)/1000 != op.Weight {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must have at most 3 decimal places", i).WithParam("--weights")
|
||||
}
|
||||
if seen[op.ID] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --weights", op.ID).WithParam("--weights")
|
||||
}
|
||||
seen[op.ID] = true
|
||||
sum += op.Weight
|
||||
}
|
||||
|
||||
// Sum must be <= 1
|
||||
if sum > 1+1e-9 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "sum of weights must be <= 1, got %.6f", sum).WithParam("--weights")
|
||||
}
|
||||
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// formatWeight formats a fixed-point weight value as a json.Number with exactly 3 decimal places.
|
||||
// This ensures precise JSON serialization and avoids float64 precision issues.
|
||||
func formatWeight(fp int64) json.Number {
|
||||
return json.Number(fmt.Sprintf("%d.%03d", fp/1000, fp%1000))
|
||||
}
|
||||
|
||||
// normalizeWeights normalizes weights using fixed-point arithmetic (×1000).
|
||||
// - Specified weights are used as-is (already validated to 3 decimal places).
|
||||
// - Remaining weight (1 - sum_specified) is distributed to unspecified items
|
||||
// proportionally based on their original weights.
|
||||
// - Fixed-point arithmetic ensures exact sum = 1, with residual added to the last item.
|
||||
// - Weights are returned as json.Number to avoid float64 precision issues in JSON serialization.
|
||||
func normalizeWeights[T weightItem](
|
||||
items []T,
|
||||
ops []weightOp,
|
||||
) ([]map[string]interface{}, error) {
|
||||
const scale = 1000 // fixed-point scale for 3 decimal places
|
||||
|
||||
// Build map of specified weights (as fixed-point integers)
|
||||
specified := make(map[string]int64)
|
||||
var specifiedSum int64
|
||||
for _, op := range ops {
|
||||
fp := int64(math.Round(op.Weight * scale))
|
||||
specified[op.ID] = fp
|
||||
specifiedSum += fp
|
||||
}
|
||||
|
||||
// Calculate remaining weight to distribute (as fixed-point)
|
||||
remaining := scale - specifiedSum
|
||||
if remaining < 0 {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "weight calculation error: remaining weight is negative")
|
||||
}
|
||||
|
||||
// Collect unspecified items and their original weights
|
||||
type itemWithWeight struct {
|
||||
item T
|
||||
fp int64 // original weight as fixed-point
|
||||
}
|
||||
var unspecified []itemWithWeight
|
||||
var originalUnspecifiedSum int64
|
||||
|
||||
for _, item := range items {
|
||||
id := item.GetID()
|
||||
if _, ok := specified[id]; ok {
|
||||
continue
|
||||
}
|
||||
origWeight := item.GetWeight()
|
||||
if origWeight < 0 {
|
||||
origWeight = 0
|
||||
}
|
||||
fp := int64(math.Round(origWeight * scale))
|
||||
unspecified = append(unspecified, itemWithWeight{item: item, fp: fp})
|
||||
originalUnspecifiedSum += fp
|
||||
}
|
||||
|
||||
// Distribute remaining weight proportionally
|
||||
result := make([]map[string]interface{}, 0, len(items))
|
||||
var resultSum int64
|
||||
|
||||
// First add specified items in original order
|
||||
for _, item := range items {
|
||||
id := item.GetID()
|
||||
if fp, ok := specified[id]; ok {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": id,
|
||||
"weight": formatWeight(fp),
|
||||
})
|
||||
resultSum += fp
|
||||
}
|
||||
}
|
||||
|
||||
// Then distribute to unspecified items
|
||||
if len(unspecified) > 0 && remaining > 0 {
|
||||
if originalUnspecifiedSum == 0 {
|
||||
// All original weights are zero, distribute evenly
|
||||
perItem := remaining / int64(len(unspecified))
|
||||
residual := remaining - perItem*int64(len(unspecified))
|
||||
|
||||
for i, uw := range unspecified {
|
||||
fp := perItem
|
||||
// Add residual to the last unspecified item
|
||||
if i == len(unspecified)-1 {
|
||||
fp += residual
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": uw.item.GetID(),
|
||||
"weight": formatWeight(fp),
|
||||
})
|
||||
resultSum += fp
|
||||
}
|
||||
} else {
|
||||
// Distribute proportionally based on original weights
|
||||
var distributed int64
|
||||
for i, uw := range unspecified {
|
||||
var fp int64
|
||||
if i == len(unspecified)-1 {
|
||||
// Last item gets the remainder to ensure exact sum
|
||||
fp = remaining - distributed
|
||||
} else {
|
||||
// Proportional distribution
|
||||
fp = int64(float64(remaining) * float64(uw.fp) / float64(originalUnspecifiedSum))
|
||||
distributed += fp
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": uw.item.GetID(),
|
||||
"weight": formatWeight(fp),
|
||||
})
|
||||
resultSum += fp
|
||||
}
|
||||
}
|
||||
} else if remaining > 0 {
|
||||
// All items were specified, add residual to the last item
|
||||
if len(result) > 0 {
|
||||
lastIdx := len(result) - 1
|
||||
// Parse current weight as fixed-point and add residual
|
||||
var lastFP int64
|
||||
if lastWeight, ok := result[lastIdx]["weight"].(json.Number); ok {
|
||||
if f, err := lastWeight.Float64(); err == nil {
|
||||
lastFP = int64(math.Round(f * scale))
|
||||
}
|
||||
}
|
||||
result[lastIdx]["weight"] = formatWeight(lastFP + remaining)
|
||||
resultSum += remaining
|
||||
}
|
||||
}
|
||||
|
||||
// Verify sum is exactly 1.0
|
||||
if resultSum != scale {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"weight normalization error: sum is %.6f, expected 1.0", float64(resultSum)/scale)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetWeight implements the interface for Objective.
|
||||
func (o Objective) GetWeight() float64 {
|
||||
if o.Weight == nil {
|
||||
return 0
|
||||
}
|
||||
return *o.Weight
|
||||
}
|
||||
|
||||
// GetWeight implements the interface for KeyResult.
|
||||
func (k KeyResult) GetWeight() float64 {
|
||||
if k.Weight == nil {
|
||||
return 0
|
||||
}
|
||||
return *k.Weight
|
||||
}
|
||||
|
||||
// OKRWeight adjusts the weight of objectives or key results.
|
||||
var OKRWeight = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+weight",
|
||||
Description: "Adjust the weight of OKR objectives or key results",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "level to adjust: objective | key-result", Enum: []string{"objective", "key-result"}, Required: true},
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
|
||||
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
|
||||
{Name: "weights", Desc: "JSON array of weight assignments: [{\"id\":\"...\",\"weight\":0.5}]", Input: []string{common.File, common.Stdin}, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
|
||||
if level == "key-result" {
|
||||
objID := runtime.Str("objective-id")
|
||||
if objID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
|
||||
}
|
||||
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
|
||||
}
|
||||
}
|
||||
|
||||
weightsStr := runtime.Str("weights")
|
||||
if err := common.RejectDangerousCharsTyped("--weights", weightsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseWeightOps(weightsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, _ := parseWeightOps(runtime.Str("weights"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
|
||||
if level == "objective" {
|
||||
// First fetch objectives
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all objectives in the cycle to get current weights for normalization")
|
||||
|
||||
// Then update weights
|
||||
weightParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
// Build sample body
|
||||
objectiveWeights := make([]map[string]interface{}, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
objectiveWeights = append(objectiveWeights, map[string]interface{}{
|
||||
"objective_id": op.ID,
|
||||
"weight": op.Weight,
|
||||
})
|
||||
}
|
||||
weightBody := map[string]interface{}{
|
||||
"objective_weights": objectiveWeights,
|
||||
}
|
||||
weightPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
|
||||
apis = apis.
|
||||
PUT(weightPath).
|
||||
Params(weightParams).
|
||||
Body(weightBody).
|
||||
Desc("Update objective weights (full list sent after normalization)")
|
||||
} else {
|
||||
// key-result level
|
||||
listParams := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
|
||||
apis = apis.
|
||||
GET(listPath).
|
||||
Params(listParams).
|
||||
Desc("Fetch all key results for the objective to get current weights for normalization")
|
||||
|
||||
weightParams := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
// Build sample body
|
||||
keyResultWeights := make([]map[string]interface{}, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
keyResultWeights = append(keyResultWeights, map[string]interface{}{
|
||||
"key_result_id": op.ID,
|
||||
"weight": op.Weight,
|
||||
})
|
||||
}
|
||||
weightBody := map[string]interface{}{
|
||||
"key_result_weights": keyResultWeights,
|
||||
}
|
||||
weightPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
|
||||
apis = apis.
|
||||
PUT(weightPath).
|
||||
Params(weightParams).
|
||||
Body(weightBody).
|
||||
Desc("Update key result weights (full list sent after normalization)")
|
||||
}
|
||||
|
||||
return apis
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
ops, err := parseWeightOps(runtime.Str("weights"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var normalizedWeights []map[string]interface{}
|
||||
var total int
|
||||
|
||||
if level == "objective" {
|
||||
objectives, err := fetchObjectives(ctx, runtime, cycleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(objectives)
|
||||
|
||||
// Validate all specified IDs exist
|
||||
objIDs := make(map[string]bool)
|
||||
for _, obj := range objectives {
|
||||
objIDs[obj.ID] = true
|
||||
}
|
||||
for _, op := range ops {
|
||||
if !objIDs[op.ID] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "objective id %q not found in cycle", op.ID).WithParam("--weights")
|
||||
}
|
||||
}
|
||||
|
||||
normalizedWeights, err = normalizeWeights(objectives, ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build position map for sorting
|
||||
posMap := make(map[string]int32)
|
||||
for _, obj := range objectives {
|
||||
if obj.Position != nil {
|
||||
posMap[obj.ID] = *obj.Position
|
||||
}
|
||||
}
|
||||
|
||||
// Submit weight update
|
||||
params := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
}
|
||||
objectiveWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
|
||||
for _, w := range normalizedWeights {
|
||||
objectiveWeights = append(objectiveWeights, map[string]interface{}{
|
||||
"objective_id": w["id"],
|
||||
"weight": w["weight"],
|
||||
})
|
||||
}
|
||||
// Sort by position to match API requirements
|
||||
sort.Slice(objectiveWeights, func(i, j int) bool {
|
||||
idI := objectiveWeights[i]["objective_id"].(string)
|
||||
idJ := objectiveWeights[j]["objective_id"].(string)
|
||||
return posMap[idI] < posMap[idJ]
|
||||
})
|
||||
body := map[string]interface{}{
|
||||
"objective_weights": objectiveWeights,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update objective weights")
|
||||
}
|
||||
} else {
|
||||
// key-result level
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total = len(keyResults)
|
||||
|
||||
// Validate all specified IDs exist
|
||||
krIDs := make(map[string]bool)
|
||||
for _, kr := range keyResults {
|
||||
krIDs[kr.ID] = true
|
||||
}
|
||||
for _, op := range ops {
|
||||
if !krIDs[op.ID] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "key_result id %q not found in objective", op.ID).WithParam("--weights")
|
||||
}
|
||||
}
|
||||
|
||||
normalizedWeights, err = normalizeWeights(keyResults, ops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build position map for sorting
|
||||
posMap := make(map[string]int32)
|
||||
for _, kr := range keyResults {
|
||||
if kr.Position != nil {
|
||||
posMap[kr.ID] = *kr.Position
|
||||
}
|
||||
}
|
||||
|
||||
// Submit weight update
|
||||
params := map[string]interface{}{
|
||||
"objective_id": objectiveID,
|
||||
}
|
||||
keyResultWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
|
||||
for _, w := range normalizedWeights {
|
||||
keyResultWeights = append(keyResultWeights, map[string]interface{}{
|
||||
"key_result_id": w["id"],
|
||||
"weight": w["weight"],
|
||||
})
|
||||
}
|
||||
// Sort by position to match API requirements
|
||||
sort.Slice(keyResultWeights, func(i, j int) bool {
|
||||
idI := keyResultWeights[i]["key_result_id"].(string)
|
||||
idJ := keyResultWeights[j]["key_result_id"].(string)
|
||||
return posMap[idI] < posMap[idJ]
|
||||
})
|
||||
body := map[string]interface{}{
|
||||
"key_result_weights": keyResultWeights,
|
||||
}
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
|
||||
_, err = runtime.CallAPITyped("PUT", path, params, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to update key result weights")
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := map[string]interface{}{
|
||||
"level": level,
|
||||
"cycle_id": cycleID,
|
||||
"total": total,
|
||||
"weights": normalizedWeights,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Successfully updated weights for %d %s(s)\n", total, level)
|
||||
fmt.Fprintln(w, "Weights:")
|
||||
for _, weightEntry := range normalizedWeights {
|
||||
fmt.Fprintf(w, " %s: %v\n", weightEntry["id"], weightEntry["weight"])
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// getWeightFloat extracts a float64 weight from either float64 or json.Number.
|
||||
func getWeightFloat(v interface{}) float64 {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case json.Number:
|
||||
f, _ := val.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func weightTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-weight",
|
||||
AppSecret: "secret-okr-weight",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runWeightShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRWeight.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestWeightValidate_MissingLevel(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_InvalidLevel(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "invalid",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --level")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_MissingCycleID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("expected --cycle-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --objective-id when --level=key-result")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_MissingWeights(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
// cobra Required:true reports flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "weights") {
|
||||
t.Fatalf("expected --weights required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_InvalidWeightsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --weights JSON")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_EmptyWeightsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", "[]",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --weights array")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_NegativeWeight(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":-0.1}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative weight")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "non-negative") {
|
||||
t.Fatalf("expected error to mention non-negative, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_WeightGreaterThanOne(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":1.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for weight > 1")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_TooManyDecimalPlaces(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.1234}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for weight with more than 3 decimal places")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "3 decimal places") {
|
||||
t.Fatalf("expected error to mention 3 decimal places, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_SumGreaterThanOne(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.6},{"id":"2","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for sum of weights > 1")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sum of weights") {
|
||||
t.Fatalf("expected error to mention sum of weights, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightValidate_DuplicateID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.3},{"id":"1","weight":0.4}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate id in --weights")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate id") {
|
||||
t.Fatalf("expected error to mention duplicate id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestWeightDryRun_Objectives(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.5},{"id":"2","weight":0.5}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
|
||||
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GET") {
|
||||
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_weight") {
|
||||
t.Fatalf("dry-run output should contain weight update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "PUT") {
|
||||
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "objective_weights") {
|
||||
t.Fatalf("dry-run output should contain objective_weights in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightDryRun_KeyResults(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--weights", `[{"id":"kr1","weight":0.5},{"id":"kr2","weight":0.5}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
|
||||
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_weight") {
|
||||
t.Fatalf("dry-run output should contain key_results weight update API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "key_result_weights") {
|
||||
t.Fatalf("dry-run output should contain key_result_weights in body, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestWeightExecute_Objectives_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
w1 := 0.5
|
||||
w2 := 0.5
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "2", "weight": &w2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock weight update
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives_weight",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "weight": 0.7},
|
||||
map[string]interface{}{"id": "2", "weight": 0.3},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"1","weight":0.7}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "objective" {
|
||||
t.Fatalf("expected level=objective, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
weights, _ := data["weights"].([]interface{})
|
||||
if len(weights) != 2 {
|
||||
t.Fatalf("expected 2 items in weights list, got %d", len(weights))
|
||||
}
|
||||
|
||||
// Verify sum is exactly 1.0
|
||||
var sum float64
|
||||
for _, w := range weights {
|
||||
wm, _ := w.(map[string]interface{})
|
||||
weightVal := getWeightFloat(wm["weight"])
|
||||
sum += weightVal
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightExecute_KeyResults_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
// Mock fetch key results
|
||||
w1 := 0.3
|
||||
w2 := 0.3
|
||||
w3 := 0.4
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "kr1", "weight": &w1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "kr2", "weight": &w2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
map[string]interface{}{"id": "kr3", "weight": &w3, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Mock weight update
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results_weight",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--weights", `[{"id":"kr1","weight":0.5}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["level"] != "key-result" {
|
||||
t.Fatalf("expected level=key-result, got %v", data["level"])
|
||||
}
|
||||
if data["cycle_id"] != "123" {
|
||||
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
|
||||
}
|
||||
weights, _ := data["weights"].([]interface{})
|
||||
|
||||
// Verify sum is exactly 1.0
|
||||
var sum float64
|
||||
for _, w := range weights {
|
||||
wm, _ := w.(map[string]interface{})
|
||||
weightVal := getWeightFloat(wm["weight"])
|
||||
sum += weightVal
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightExecute_IDNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
|
||||
// Mock fetch objectives
|
||||
w1 := 0.5
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runWeightShortcut(t, f, stdout, []string{
|
||||
"+weight",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--weights", `[{"id":"999","weight":0.5}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent ID")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if prob.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if !errors.Is(err, validationErr) {
|
||||
t.Fatal("errors.Is should find the ValidationError in the chain")
|
||||
}
|
||||
if validationErr.Param != "--weights" {
|
||||
t.Fatalf("expected param --weights, got %q", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected error to mention not found, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for helper functions ---
|
||||
|
||||
func TestParseWeightOps_Valid(t *testing.T) {
|
||||
ops, err := parseWeightOps(`[{"id":"1","weight":0.3},{"id":"2","weight":0.7}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("expected 2 ops, got %d", len(ops))
|
||||
}
|
||||
if ops[0].ID != "1" || math.Abs(ops[0].Weight-0.3) > 1e-9 {
|
||||
t.Fatalf("expected op[0] = {1,0.3}, got %+v", ops[0])
|
||||
}
|
||||
if ops[1].ID != "2" || math.Abs(ops[1].Weight-0.7) > 1e-9 {
|
||||
t.Fatalf("expected op[1] = {2,0.7}, got %+v", ops[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeights_AllSpecified(t *testing.T) {
|
||||
w1 := 0.0
|
||||
w2 := 0.0
|
||||
items := []Objective{
|
||||
{ID: "1", Weight: &w1},
|
||||
{ID: "2", Weight: &w2},
|
||||
}
|
||||
ops := []weightOp{
|
||||
{ID: "1", Weight: 0.3},
|
||||
{ID: "2", Weight: 0.7},
|
||||
}
|
||||
result, err := normalizeWeights(items, ops)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(result))
|
||||
}
|
||||
// Sum should be exactly 1.0
|
||||
var sum float64
|
||||
for _, r := range result {
|
||||
sum += getWeightFloat(r["weight"])
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum = 1.0, got %.10f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeights_PartialSpecified_Proportional(t *testing.T) {
|
||||
w1 := 0.5
|
||||
w2 := 0.3
|
||||
w3 := 0.2
|
||||
items := []Objective{
|
||||
{ID: "1", Weight: &w1},
|
||||
{ID: "2", Weight: &w2},
|
||||
{ID: "3", Weight: &w3},
|
||||
}
|
||||
ops := []weightOp{
|
||||
{ID: "1", Weight: 0.4}, // Specify 0.4 for item 1
|
||||
}
|
||||
result, err := normalizeWeights(items, ops)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(result))
|
||||
}
|
||||
// Sum should be exactly 1.0
|
||||
var sum float64
|
||||
for _, r := range result {
|
||||
sum += getWeightFloat(r["weight"])
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum = 1.0, got %.10f", sum)
|
||||
}
|
||||
// Item 1 should have weight 0.4
|
||||
var item1Weight float64
|
||||
for _, r := range result {
|
||||
if r["id"] == "1" {
|
||||
item1Weight = getWeightFloat(r["weight"])
|
||||
break
|
||||
}
|
||||
}
|
||||
if math.Abs(item1Weight-0.4) > 1e-9 {
|
||||
t.Fatalf("expected item 1 weight = 0.4, got %.10f", item1Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeights_ZeroOriginalWeights(t *testing.T) {
|
||||
w1 := 0.0
|
||||
w2 := 0.0
|
||||
items := []Objective{
|
||||
{ID: "1", Weight: &w1},
|
||||
{ID: "2", Weight: &w2},
|
||||
}
|
||||
ops := []weightOp{
|
||||
{ID: "1", Weight: 0.5},
|
||||
}
|
||||
result, err := normalizeWeights(items, ops)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(result))
|
||||
}
|
||||
// Sum should be exactly 1.0
|
||||
var sum float64
|
||||
for _, r := range result {
|
||||
sum += getWeightFloat(r["weight"])
|
||||
}
|
||||
if math.Abs(sum-1.0) > 1e-9 {
|
||||
t.Fatalf("expected sum = 1.0, got %.10f", sum)
|
||||
}
|
||||
// When original weights are zero, remaining should be distributed evenly
|
||||
var item2Weight float64
|
||||
for _, r := range result {
|
||||
if r["id"] == "2" {
|
||||
item2Weight = getWeightFloat(r["weight"])
|
||||
break
|
||||
}
|
||||
}
|
||||
if math.Abs(item2Weight-0.5) > 1e-9 {
|
||||
t.Fatalf("expected item 2 weight = 0.5 (even distribution), got %.10f", item2Weight)
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,5 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
OKRBatchCreate,
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,5 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesScreenshot,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
|
||||
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
|
||||
// local files. The raw API returns Base64 image payloads; this shortcut keeps
|
||||
// those payloads out of stdout so agents only see small file metadata.
|
||||
var SlidesScreenshot = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
{Name: "output-name", Desc: "file name stem for --content render output"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
renderMode := runtime.Changed("content")
|
||||
if renderMode {
|
||||
if strings.TrimSpace(runtime.Str("content")) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
|
||||
}
|
||||
} else {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasSlideScreenshotSelector(runtime) {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
}
|
||||
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if runtime.Changed("content") {
|
||||
return dryRunRenderScreenshot(runtime)
|
||||
}
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
|
||||
}
|
||||
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if len(slideIDs) > 0 {
|
||||
body["slide_ids"] = slideIDs
|
||||
}
|
||||
if len(slideNumbers) > 0 {
|
||||
body["slide_numbers"] = slideNumbers
|
||||
}
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Body(body)
|
||||
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("content") {
|
||||
return executeRenderScreenshot(runtime)
|
||||
}
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
query := larkcore.QueryParams{}
|
||||
body := map[string]interface{}{}
|
||||
if len(slideIDs) > 0 {
|
||||
body["slide_ids"] = slideIDs
|
||||
}
|
||||
if len(slideNumbers) > 0 {
|
||||
body["slide_numbers"] = slideNumbers
|
||||
}
|
||||
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body)
|
||||
if err != nil {
|
||||
return enrichSlidesScreenshotSelectorError(err, slideNumbers)
|
||||
}
|
||||
|
||||
saved, err := saveSlideScreenshots(runtime, data, safeOutputDir, presentationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"output_dir": outputDir,
|
||||
"screenshots": saved,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
content := runtime.Str("content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
|
||||
}
|
||||
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
|
||||
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
|
||||
Body(map[string]interface{}{
|
||||
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
|
||||
})
|
||||
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution")
|
||||
}
|
||||
|
||||
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
|
||||
"content": content,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved, err := saveRenderedSlideScreenshot(runtime, data, safeOutputDir, runtime.Str("output-name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"output_dir": outputDir,
|
||||
"screenshots": saved,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSlideIDs(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range values {
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSlideNumbers(values []int) ([]int, error) {
|
||||
out := make([]int, 0, len(values))
|
||||
seen := map[int]struct{}{}
|
||||
for _, n := range values {
|
||||
if n < 1 {
|
||||
return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer")
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
|
||||
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
|
||||
}
|
||||
|
||||
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
|
||||
if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil {
|
||||
return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err)
|
||||
}
|
||||
return outputDir, nil
|
||||
}
|
||||
|
||||
func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
|
||||
return validateScreenshotOutputDir(runtime, outputDir)
|
||||
}
|
||||
|
||||
func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string) ([]map[string]interface{}, error) {
|
||||
items := common.GetSlice(data, "slide_images")
|
||||
if len(items) == 0 {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
|
||||
}
|
||||
saved := make([]map[string]interface{}, 0, len(items))
|
||||
for i, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
|
||||
}
|
||||
item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "")
|
||||
if err != nil {
|
||||
if isSlidesScreenshotPassthroughError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
|
||||
}
|
||||
saved = append(saved, item)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) {
|
||||
item := common.GetMap(data, "slide_image")
|
||||
if item == nil {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
|
||||
}
|
||||
saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide")
|
||||
if err != nil {
|
||||
if isSlidesScreenshotPassthroughError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
|
||||
}
|
||||
return []map[string]interface{}{saved}, nil
|
||||
}
|
||||
|
||||
func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) {
|
||||
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
|
||||
ext, label, err := slideScreenshotFormat(item)
|
||||
if err != nil {
|
||||
return nil, slidesScreenshotImageDataError(slideID, "%s", err)
|
||||
}
|
||||
encoded := strings.TrimSpace(common.GetString(item, "data"))
|
||||
if encoded == "" {
|
||||
return nil, slidesScreenshotImageDataError(slideID, "empty image data")
|
||||
}
|
||||
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err)
|
||||
}
|
||||
fileBase := strings.TrimSpace(outputName)
|
||||
if fileBase == "" {
|
||||
fileBase = slideID
|
||||
}
|
||||
if fileBase == "" {
|
||||
fileBase = fallbackName
|
||||
}
|
||||
path, err := writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"slide_id": slideID,
|
||||
"slide_number": slideScreenshotInt(item, "slide_number"),
|
||||
"format": label,
|
||||
"path": path,
|
||||
"size": len(imageBytes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string {
|
||||
presentationID = strings.TrimSpace(presentationID)
|
||||
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
|
||||
slideNumber := slideScreenshotInt(item, "slide_number")
|
||||
if presentationID != "" {
|
||||
switch {
|
||||
case slideNumber > 0 && slideID != "":
|
||||
return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID)
|
||||
case slideNumber > 0:
|
||||
return fmt.Sprintf("%s_p%03d", presentationID, slideNumber)
|
||||
case slideID != "":
|
||||
return fmt.Sprintf("%s_%s", presentationID, slideID)
|
||||
}
|
||||
}
|
||||
if slideID != "" {
|
||||
return slideID
|
||||
}
|
||||
if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 {
|
||||
return fmt.Sprintf("slide-%d", slideNumber)
|
||||
}
|
||||
return fmt.Sprintf("slide-%d", index+1)
|
||||
}
|
||||
|
||||
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
|
||||
format := slideScreenshotInt(item, "format")
|
||||
switch format {
|
||||
case 1:
|
||||
return "png", "png", nil
|
||||
case 2:
|
||||
return "jpg", "jpeg", nil
|
||||
default:
|
||||
return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format)
|
||||
}
|
||||
}
|
||||
|
||||
func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if slideID != "" {
|
||||
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg)
|
||||
}
|
||||
|
||||
func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if slideID != "" {
|
||||
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause)
|
||||
}
|
||||
|
||||
func slideScreenshotInt(item map[string]interface{}, key string) int {
|
||||
n, ok := util.ToFloat64(item[key])
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
ApiPath: apiPath,
|
||||
QueryParams: query,
|
||||
}
|
||||
if body != nil {
|
||||
req.Body = body
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, errs.WrapInternal(err)
|
||||
}
|
||||
data, err := runtime.ClassifyAPIResponse(resp)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" {
|
||||
data["log_id"] = logID
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error {
|
||||
if len(slideNumbers) == 0 {
|
||||
return err
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if p.Hint == "" {
|
||||
p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id."
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data))
|
||||
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isSlidesScreenshotPassthroughError(err error) bool {
|
||||
_, ok := errs.ProblemOf(err)
|
||||
return ok
|
||||
}
|
||||
|
||||
func summarizeScreenshotAPIData(v interface{}) interface{} {
|
||||
switch x := v.(type) {
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]interface{}, len(x))
|
||||
for k, val := range x {
|
||||
out[k] = summarizeScreenshotAPIData(val)
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]interface{}, 0, len(x))
|
||||
for i, val := range x {
|
||||
if i >= 20 {
|
||||
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
|
||||
break
|
||||
}
|
||||
out = append(out, summarizeScreenshotAPIData(val))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
if len(x) > 512 {
|
||||
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
|
||||
}
|
||||
return x
|
||||
default:
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
func safeScreenshotFileBase(base string) string {
|
||||
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
|
||||
name = strings.Trim(name, "._-")
|
||||
if name == "" {
|
||||
name = "slide"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) {
|
||||
base := safeScreenshotFileBase(fileBase)
|
||||
for i := 0; i < 1000; i++ {
|
||||
candidateBase := base
|
||||
if i > 0 {
|
||||
candidateBase = fmt.Sprintf("%s_%d", base, i+1)
|
||||
}
|
||||
path := filepath.Join(outputDir, candidateBase+"."+ext)
|
||||
if _, err := runtime.FileIO().Stat(path); err == nil {
|
||||
continue
|
||||
} else if !isScreenshotFileNotExist(err) {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", path, err).WithCause(err)
|
||||
}
|
||||
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil {
|
||||
return "", common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", path, err).WithCause(err)
|
||||
}
|
||||
return resolvedPath, nil
|
||||
}
|
||||
path := filepath.Join(outputDir, base+"."+ext)
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", path)
|
||||
}
|
||||
|
||||
func isScreenshotFileNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
imageBytes := []byte("png-bytes")
|
||||
jpegBytes := []byte("jpeg-bytes")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"slide_number": 2,
|
||||
"format": 2,
|
||||
"data": base64.StdEncoding.EncodeToString(jpegBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--output-dir", "shots",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "shots", "pres_abc_slide_1.png")
|
||||
gotBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read screenshot: %v", err)
|
||||
}
|
||||
if string(gotBytes) != string(imageBytes) {
|
||||
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
|
||||
}
|
||||
jpegPath := filepath.Join(dir, "shots", "pres_abc_p002_slide_2.jpg")
|
||||
gotJPEGBytes, err := os.ReadFile(jpegPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read jpeg screenshot: %v", err)
|
||||
}
|
||||
if string(gotJPEGBytes) != string(jpegBytes) {
|
||||
t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes)
|
||||
}
|
||||
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
|
||||
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 2 {
|
||||
t.Fatalf("screenshots = %#v, want two items", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["slide_id"] != "slide_1" {
|
||||
t.Fatalf("slide_id = %v, want slide_1", item["slide_id"])
|
||||
}
|
||||
gotPath := item["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, filepath.Join("shots", "pres_abc_slide_1.png")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_slide_1.png suffix", item["path"])
|
||||
}
|
||||
item2, _ := items[1].(map[string]interface{})
|
||||
if item2["format"] != "jpeg" {
|
||||
t.Fatalf("format = %v, want jpeg", item2["format"])
|
||||
}
|
||||
gotPath2 := item2["path"].(string)
|
||||
if !filepath.IsAbs(gotPath2) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath2)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath2, filepath.Join("shots", "pres_abc_p002_slide_2.jpg")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_p002_slide_2.jpg suffix", item2["path"])
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" {
|
||||
t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_number": 2,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideNumbers []int `json:"slide_numbers"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 {
|
||||
t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers)
|
||||
}
|
||||
path := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_p002.png")
|
||||
if _, err := os.ReadFile(path); err != nil {
|
||||
t.Fatalf("read screenshot without slide_id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
outputDir := filepath.Join(dir, "shots")
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
t.Fatalf("create output dir: %v", err)
|
||||
}
|
||||
existingPath := filepath.Join(outputDir, "pres_abc_p002.png")
|
||||
if err := os.WriteFile(existingPath, []byte("existing"), 0o644); err != nil {
|
||||
t.Fatalf("write existing screenshot: %v", err)
|
||||
}
|
||||
|
||||
imageBytes := []byte("new-png")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_number": 2,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--output-dir", "shots",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
gotExisting, err := os.ReadFile(existingPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read existing screenshot: %v", err)
|
||||
}
|
||||
if string(gotExisting) != "existing" {
|
||||
t.Fatalf("existing screenshot = %q, want unchanged", gotExisting)
|
||||
}
|
||||
newPath := filepath.Join(outputDir, "pres_abc_p002_2.png")
|
||||
gotNew, err := os.ReadFile(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read deduplicated screenshot: %v", err)
|
||||
}
|
||||
if string(gotNew) != string(imageBytes) {
|
||||
t.Fatalf("deduplicated screenshot = %q, want %q", gotNew, imageBytes)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "pres_abc_p002_2.png")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_p002_2.png suffix", item["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") {
|
||||
t.Fatalf("error = %v, want missing selector error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
content := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`
|
||||
if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write input xml: %v", err)
|
||||
}
|
||||
imageBytes := []byte("rendered-png")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/slide_image/render",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_image": map[string]interface{}{
|
||||
"slide_id": "render_slide",
|
||||
"slide_number": 1,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", "@slide.xml",
|
||||
"--output-dir", "shots",
|
||||
"--output-name", "preview",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "shots", "preview.png")
|
||||
gotBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read rendered screenshot: %v", err)
|
||||
}
|
||||
if string(gotBytes) != string(imageBytes) {
|
||||
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
|
||||
}
|
||||
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
|
||||
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if body.Content != content {
|
||||
t.Fatalf("content = %q, want input XML", body.Content)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) {
|
||||
t.Fatalf("path = %v, want shots/preview.png suffix", item["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--slide-id", "slide_1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
|
||||
t.Fatalf("error = %v, want content/slide selector conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--presentation cannot be used with --content") {
|
||||
t.Fatalf("error = %v, want presentation/content conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) {
|
||||
t.Run("list", func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") {
|
||||
t.Fatalf("dry-run missing list endpoint: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "slide_numbers") {
|
||||
t.Fatalf("dry-run missing slide_numbers body: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render", func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/slide_image/render") {
|
||||
t.Fatalf("dry-run missing render endpoint: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "base64_output") {
|
||||
t.Fatalf("dry-run missing base64 suppression note: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--output-dir", "../outside",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsafe output dir")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--output-dir invalid") {
|
||||
t.Fatalf("error = %v, want output-dir validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"X-Tt-Logid": {"log-123"},
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"unexpected": "shape",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "pJJ",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
if p.LogID != "log-123" {
|
||||
t.Fatalf("log_id = %v, want log-123", p.LogID)
|
||||
}
|
||||
if !strings.Contains(p.Message, "unexpected:shape") {
|
||||
t.Fatalf("message = %q, want raw_data summary", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotSlideNumberAPIErrorAddsHint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"X-Tt-Logid": {"log-slide-number"},
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992402,
|
||||
"msg": "field validation failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "25",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
if p.LogID != "log-slide-number" {
|
||||
t.Fatalf("log_id = %v, want log-slide-number", p.LogID)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--slide-id") {
|
||||
t.Fatalf("hint = %q, want --slide-id guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func (ab *authBridge) handleLogin(w http.ResponseWriter, _ *http.Request, body [
|
||||
len(strings.Fields(scope)), req.Domains, clientID)
|
||||
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(
|
||||
context.Background(), ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand, scope, io.Discard,
|
||||
ab.httpCl, ab.appID, ab.appSecret, ab.brand, scope, io.Discard,
|
||||
)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadGateway, "device authorization failed: "+err.Error())
|
||||
@@ -255,7 +255,7 @@ func (ab *authBridge) handlePoll(w http.ResponseWriter, r *http.Request, body []
|
||||
}()
|
||||
|
||||
result := larkauth.PollDeviceToken(
|
||||
ctx, ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand,
|
||||
ctx, ab.httpCl, ab.appID, ab.appSecret, ab.brand,
|
||||
req.DeviceCode, 5, 600, io.Discard,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
# 权限治理 Command Patterns
|
||||
|
||||
本文只提供 `permission_governance` workflow 的具体 `lark-cli` 命令样例。只有进入对应 state 且需要拼装命令时才读取本文;命令可用范围仍以 [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) 的 `Command Map` 为准。
|
||||
|
||||
## 目录
|
||||
|
||||
- `目标解析`
|
||||
- `目标发现`
|
||||
- `事实读取`
|
||||
- `写前确认与执行`
|
||||
|
||||
## 目标解析
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
```
|
||||
|
||||
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。
|
||||
|
||||
## 目标发现
|
||||
|
||||
发现 Wiki space / node 下目标:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
|
||||
|
||||
发现 Drive folder 下目标:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","page_size":200}' \
|
||||
--as user --format json
|
||||
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","page_size":200,"page_token":"<PAGE_TOKEN>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 事实读取
|
||||
|
||||
读取 metadata:
|
||||
|
||||
```bash
|
||||
lark-cli drive metas batch_query \
|
||||
--data '{"request_docs":[{"doc_token":"<token>","doc_type":"<type>"}],"with_url":true}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
读取 public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
按需读取访问统计:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.statistics get \
|
||||
--params '{"file_token":"<token>","file_type":"<type>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
按需读取最近访问记录:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.view_records list \
|
||||
--params '{"file_token":"<token>","file_type":"<type>","page_size":50}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 写前确认与执行
|
||||
|
||||
patch 前检查 manage-public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.members auth \
|
||||
--params '{"token":"<token>","type":"<type>","action":"manage_public"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
patch 前读取当前 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.permission.public.patch --format json
|
||||
```
|
||||
|
||||
只 patch 当前 schema 支持的字段;对 Wiki 目标,必须省略 schema 明确标注为 Wiki 不支持的字段。
|
||||
|
||||
显式确认后 patch public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public patch \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--data '{"link_share_entity":"closed","external_access":false}' \
|
||||
--as user --yes --format json
|
||||
```
|
||||
|
||||
显式确认后申请访问权限:
|
||||
|
||||
```bash
|
||||
lark-cli drive +apply-permission \
|
||||
--token '<url>' \
|
||||
--perm view --remark '<reason>' --as user --format json
|
||||
|
||||
lark-cli drive +apply-permission \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--perm view --remark '<reason>' --as user --format json
|
||||
```
|
||||
|
||||
owner 转移前读取当前 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.permission.members.transfer_owner --format json
|
||||
```
|
||||
|
||||
显式确认后转移 owner:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.members transfer_owner \
|
||||
--params '{"token":"<token>","type":"<type>","need_notification":true,"remove_old_owner":false,"old_owner_perm":"full_access","stay_put":true}' \
|
||||
--data '{"member_id":"<new_owner_open_id>","member_type":"openid"}' \
|
||||
--as user --yes --format json
|
||||
```
|
||||
|
||||
`member_type` 只能使用当前 schema 支持的值:`email`、`openid`、`userid`、`appid`。如果用户只给姓名,必须先解析为明确身份或要求用户补充;不要猜测 `member_id`。批量 owner 转移必须逐个目标顺序执行。
|
||||
|
||||
secure label 写前枚举可用标签:
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-list \
|
||||
--page-size 10 --lang zh \
|
||||
--as user --format json
|
||||
|
||||
lark-cli drive +secure-label-list \
|
||||
--page-size 10 --page-token '<PAGE_TOKEN>' --lang zh \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
当用户给出的是标签名称、密级文案或不确定的 label ID 时,必须先枚举并解析为 `label-id`;写入确认里展示目标标签名称和 ID。找不到唯一标签时,停止并让用户选择,不要猜测。
|
||||
|
||||
显式确认后更新 secure label:
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<url>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
```
|
||||
@@ -1,424 +0,0 @@
|
||||
# 权限治理输出模板
|
||||
|
||||
本文只提供 `permission_governance` workflow 的用户可见输出模板。默认先给简短摘要;只有用户要求完整表格、需要写入确认,或结果大到需要结构化展示时才读取本文。
|
||||
|
||||
## 目录
|
||||
|
||||
- `输出策略`
|
||||
- `Semantic Rendering`
|
||||
- `定位与治理动作`
|
||||
- `单目标公开性判断`
|
||||
- `多目标明确列表诊断`
|
||||
- `审计摘要`
|
||||
- `容器安全诊断报告摘要`
|
||||
- `可操作风险清单`
|
||||
- `治理选择交互`
|
||||
- `权限设置清单`
|
||||
- `访问复核清单`
|
||||
- `整改 dry-run`
|
||||
- `批量权限申请确认`
|
||||
- `owner 转移确认`
|
||||
- `确认请求`
|
||||
- `最终摘要`
|
||||
|
||||
## 输出策略
|
||||
|
||||
- 单目标默认输出审计摘要。
|
||||
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
|
||||
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
|
||||
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
|
||||
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
|
||||
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
|
||||
- 容器安全诊断里不要把 `external_access=true` / `external_access_entity=open` 简写成“高风险”或“外部泄露”;用户可见说法应为“允许对外分享,需 owner 复核;这不等于已经存在外部协作者”。
|
||||
- 风险对象展示按规模渐进披露:1-10 个全部展示;11-30 个展示全部高优先级待复核对象,中 / 低优先级只做分组摘要;31-100 个按高优先级待复核分组展示 Top 5 和数量;100+ 个只展示分组统计和 Top 样例。
|
||||
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
|
||||
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。
|
||||
- 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。
|
||||
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
|
||||
|
||||
## Semantic Rendering
|
||||
|
||||
面向用户的主结论优先渲染 `per_target_permission_assessment` 中的语义状态,并使用用户当前语言;底层字段名只在 raw evidence、排障或完整清单中保留。下表给出字段值到业务表达的标准映射;其他语言应表达同等业务含义。
|
||||
|
||||
字段来源边界:下表同时覆盖官方 OpenAPI 语义和当前 / 未来 CLI schema。只有实际响应或当前 schema 返回的字段和值,才可渲染为确定状态;当前 installed CLI 未返回的字段(例如 `copy_entity`、`manage_collaborator_entity`、`external_access_entity`)或未出现的枚举值,只能在 raw response / schema 实际出现时使用,缺失时必须按 unknown / unsupported 处理,不要臆造。
|
||||
|
||||
| Raw field / value | Semantic State | 中文说法 | English phrasing |
|
||||
|-------------------|----------------|----------|------------------|
|
||||
| `link_share_entity=anyone_readable` | `link_access=public_readable` | 互联网上获得链接的任何人可阅读 | Anyone on the internet with the link can read |
|
||||
| `link_share_entity=anyone_editable` | `link_access=public_editable` | 互联网上获得链接的任何人可编辑 | Anyone on the internet with the link can edit |
|
||||
| `link_share_entity=partner_tenant_readable` | `link_access=partner_readable` | 关联组织内知道链接可读 | People in partner tenants with the link can read |
|
||||
| `link_share_entity=partner_tenant_editable` | `link_access=partner_editable` | 关联组织内知道链接可编辑 | People in partner tenants with the link can edit |
|
||||
| `link_share_entity=tenant_readable` | `link_access=tenant_readable` | 公司内知道链接可读 | People in the tenant with the link can read |
|
||||
| `link_share_entity=tenant_editable` | `link_access=tenant_editable` | 公司内知道链接可编辑 | People in the tenant with the link can edit |
|
||||
| link sharing empty / disabled | `link_access=closed` | 未开启链接分享 | Link sharing is disabled |
|
||||
| `external_access_entity=open` or `external_access=true` | `external_sharing=open` | 允许分享到组织外;不等于已经存在外部协作者 | External sharing is open; this does not mean external collaborators already exist |
|
||||
| `external_access_entity=allow_share_partner_tenant` | `external_sharing=partner_only` | 仅允许分享到关联组织 | Sharing is allowed only with partner tenants |
|
||||
| `external_access_entity=closed` or `external_access=false` | `external_sharing=closed` | 当前不允许分享到组织外 | External sharing is disabled |
|
||||
| `invite_external=true` | `external_invitation=enabled` | 当前允许邀请外部用户 | Inviting external users is enabled |
|
||||
| `invite_external=false` | `external_invitation=disabled` | 当前不允许邀请外部用户 | Inviting external users is disabled |
|
||||
| `share_entity=anyone` | `collaborator_org_scope=all_viewers_or_editors` | 所有可阅读或可编辑者可查看、添加、移除协作者 | All viewers or editors can view, add, and remove collaborators |
|
||||
| `share_entity=same_tenant` | `collaborator_org_scope=tenant_viewers_or_editors` | 组织内可阅读或可编辑者可查看、添加、移除协作者 | Tenant viewers or editors can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_can_view` | `collaborator_permission_scope=viewer` | 拥有可阅读权限的协作者可查看、添加、移除协作者 | Collaborators with view permission can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_can_edit` | `collaborator_permission_scope=editor` | 拥有可编辑权限的协作者可查看、添加、移除协作者 | Collaborators with edit permission can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_full_access` | `collaborator_permission_scope=full_access` | 拥有可管理权限的协作者可查看、添加、移除协作者 | Collaborators with full-access permission can view, add, and remove collaborators |
|
||||
| `copy_entity=anyone_can_view` | `copy_scope=viewer` | 拥有可阅读权限的用户可复制内容 | Users with view permission can copy content |
|
||||
| `copy_entity=anyone_can_edit` | `copy_scope=editor` | 拥有可编辑权限的用户可复制内容 | Users with edit permission can copy content |
|
||||
| `copy_entity=only_full_access` | `copy_scope=full_access` | 仅拥有可管理权限的协作者可复制内容 | Only collaborators with full-access permission can copy content |
|
||||
| `security_entity=anyone_can_view` | `security_scope=viewer` | 拥有可阅读权限的用户可创建副本、打印、下载 | Users with view permission can create copies, print, and download |
|
||||
| `security_entity=anyone_can_edit` | `security_scope=editor` | 拥有可编辑权限的用户可创建副本、打印、下载 | Users with edit permission can create copies, print, and download |
|
||||
| `security_entity=only_full_access` | `security_scope=full_access` | 仅拥有可管理权限的用户可创建副本、打印、下载 | Only users with full-access permission can create copies, print, and download |
|
||||
| `comment_entity=anyone_can_view` | `comment_scope=viewer` | 拥有可阅读权限的用户可评论 | Users with view permission can comment |
|
||||
| `comment_entity=anyone_can_edit` | `comment_scope=editor` | 拥有可编辑权限的用户可评论 | Users with edit permission can comment |
|
||||
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
|
||||
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
|
||||
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
|
||||
|
||||
## 定位与治理动作
|
||||
|
||||
风险对象必须能让用户直接定位和处理:
|
||||
|
||||
- 摘要中的每个优先处理对象必须包含 `risk_id`、`path/title`、`URL`、`type`、owner、sec_label、风险原因、关键证据和建议动作。
|
||||
- 完整清单、访问复核清单、整改 dry-run 和写入确认都必须包含 URL。缺少 URL 时,展示 token / node_token,并说明 URL 未能获取。
|
||||
- 同名文档、shortcut 或副本必须用 path + URL 区分;不要只输出 title。
|
||||
- 完整风险清单中的每条记录必须有稳定 `risk_id`,格式为 `PG-001`、`PG-002`。`risk_id` 在同一次诊断和后续 dry-run / 确认 / 验证中保持不变。
|
||||
- 即使摘要只展示 Top 样例,也必须给样例分配稳定 `risk_id`;不能输出无法选择的标题列表。
|
||||
- 建议动作必须和风险类型绑定:互联网公开链接优先建议关闭链接分享或收紧为组织内;允许对外分享优先建议 owner 复核或关闭对外分享;缺少密级标签优先建议补齐密级;复制 / 下载 / 评论范围只在用户 policy 明确时建议收紧。
|
||||
- 写入动作只能作为下一步选项或确认请求出现。不要在诊断摘要里暗示已经执行缩权。
|
||||
|
||||
## 单目标公开性判断
|
||||
|
||||
当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,使用此模板。默认渲染 `target_count=1` 的 `per_target_permission_assessment`,跟随用户当前语言,不直接展示底层字段名;用户要求 raw evidence 时,再追加字段证据。
|
||||
|
||||
中文模板:
|
||||
|
||||
```text
|
||||
结论:<不是对外公开 / 存在互联网公开链接 / 允许对外分享>。
|
||||
|
||||
目标:<title>
|
||||
URL:<url-or-token-if-url-unavailable>
|
||||
类型:<type>
|
||||
|
||||
当前链接访问范围:<render link_access>
|
||||
对外分享:<render external_sharing>
|
||||
外部邀请:<render external_invitation or omit if unknown because field is absent>
|
||||
协作者管理(组织维度):<render collaborator_org_scope>
|
||||
协作者管理(权限维度):<render collaborator_permission_scope or omit if unknown because field is absent>
|
||||
复制内容:<render copy_scope or omit if unknown because field is absent>
|
||||
创建副本 / 打印 / 下载:<render security_scope>
|
||||
评论:<render comment_scope>
|
||||
Wiki 继承限制:<render lock_state or omit if unknown because field is absent>
|
||||
|
||||
检查边界:<render check_scope>
|
||||
```
|
||||
|
||||
English template:
|
||||
|
||||
```text
|
||||
Conclusion: <Not publicly accessible on the internet / A public internet link is enabled / External sharing is enabled>.
|
||||
|
||||
Target: <title>
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
Type: <type>
|
||||
|
||||
Current link access: <render link_access>
|
||||
External sharing: <render external_sharing>
|
||||
External invitations: <render external_invitation or omit if unknown because field is absent>
|
||||
Collaborator management by tenant: <render collaborator_org_scope>
|
||||
Collaborator management by permission: <render collaborator_permission_scope or omit if unknown because field is absent>
|
||||
Copy content: <render copy_scope or omit if unknown because field is absent>
|
||||
Create copies / print / download: <render security_scope>
|
||||
Comments: <render comment_scope>
|
||||
Wiki inheritance lock: <render lock_state or omit if unknown because field is absent>
|
||||
|
||||
Check boundary: <render check_scope>
|
||||
```
|
||||
|
||||
Raw evidence, only when requested:
|
||||
|
||||
```text
|
||||
Evidence fields:
|
||||
- link_share_entity=<value>
|
||||
- external_access_entity=<value>
|
||||
- external_access=<value>
|
||||
- invite_external=<value>
|
||||
- share_entity=<value>
|
||||
- manage_collaborator_entity=<value>
|
||||
- copy_entity=<value>
|
||||
- security_entity=<value>
|
||||
- comment_entity=<value>
|
||||
- lock_switch=<value>
|
||||
```
|
||||
|
||||
## 多目标明确列表诊断
|
||||
|
||||
当 `target_scope=explicit_list` 时,使用此模板。该场景不执行容器递归发现;对用户提供的每个 URL / token 逐个生成 `per_target_permission_assessment`,再按风险分组聚合。权限语义和单目标、容器诊断完全复用,不新增判断模型。
|
||||
|
||||
```text
|
||||
已完成只读权限诊断,没有做任何权限修改。
|
||||
|
||||
一句话结论:<N> 个目标中,<risk_count> 个存在待复核权限风险;<internet_public_count> 个存在互联网公开链接候选,<external_access_count> 个允许对外分享,<unknown_count> 个无法完整判断。
|
||||
|
||||
覆盖情况:
|
||||
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
|
||||
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
|
||||
逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
|
||||
|
||||
- <risk_id-or-item_id> <path-or-title> (<type>)
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
结论:<not_public / public_link_enabled / external_sharing_enabled / policy_review / unknown>
|
||||
关键权限:<render link_access>; <render external_sharing>; <render security_scope>; <render comment_scope>
|
||||
密级:<sec_label_name-or-missing-or-unknown>
|
||||
待复核原因:<risk reason or none>
|
||||
建议动作:<recommended action or no action>
|
||||
|
||||
分组摘要:
|
||||
- 互联网公开链接候选:<count>;允许对外分享:<count>;公司内链接可访问 / 可编辑:<count>
|
||||
- 复制 / 下载 / 打印 / 评论待策略确认:<count>;无法判断:<count and reason summary>
|
||||
|
||||
建议下一步:
|
||||
- 处理明确的 <risk_id>,先生成只读 dry-run。
|
||||
- 生成完整风险清单 artifact,后续可按 `risk_id`、风险分组、URL 或 `selected=true` 选择治理范围;只看权限设置时改用 `权限设置清单`。
|
||||
```
|
||||
|
||||
## 摘要清单展开规则
|
||||
|
||||
容器安全诊断的摘要必须兼顾可读性和可治理性。不要用固定 Top N 代替可处理清单。
|
||||
|
||||
| 风险对象数 | 摘要默认展示 | 必须提供的下一步 |
|
||||
|------------|--------------|------------------|
|
||||
| `0` | 只展示覆盖情况、未覆盖能力和剩余限制 | 如需更细审计,可生成权限设置清单 |
|
||||
| `1-10` | 展示全部风险对象 | 可直接按 `risk_id` 生成 dry-run 或写入确认 |
|
||||
| `11-30` | 展示全部高优先级待复核对象;中 / 低优先级做分组摘要 | 生成完整风险清单 artifact,或按风险分组生成 dry-run |
|
||||
| `31-100` | 每个高优先级待复核分组展示 Top 5,附未展示数量 | 生成 Markdown / CSV / 飞书文档完整风险清单 |
|
||||
| `100+` | 只展示分组统计、Top 样例和覆盖限制,不内联长表 | 强烈建议生成结构化风险清单后再选择治理范围 |
|
||||
|
||||
高优先级待复核对象包括:互联网公开链接、允许对外分享、允许对外分享且缺少 / 低于 policy 密级标签、公司内可编辑链接。协作者管理范围较宽默认归入中优先级待复核;只有用户 policy 明确要求严格协作者管理时才提升优先级。复制 / 下载 / 打印、评论范围在用户未提供明确 policy 时归入“待策略确认”,不要挤占高优先级清单。
|
||||
|
||||
摘要中的每个待复核对象必须包含 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、关键证据和建议动作。对同一底层文档的多个 Wiki 入口或 shortcut,必须用 URL 区分;如果建议合并治理,在建议动作里说明它们指向同一底层对象。
|
||||
|
||||
## 审计摘要
|
||||
|
||||
```text
|
||||
目标:<title> (<type>)
|
||||
URL:<url-or-token-if-url-unavailable>
|
||||
结论:<合规 / 待确认风险 / 无法完整判断>
|
||||
证据:
|
||||
- link_share_entity=<value>
|
||||
- external_access_entity=<value>
|
||||
- external_access=<value>
|
||||
- invite_external=<value>
|
||||
- share_entity=<value>
|
||||
- manage_collaborator_entity=<value>
|
||||
- copy_entity=<value>
|
||||
- security_entity=<value>
|
||||
- comment_entity=<value>
|
||||
- lock_switch=<value>
|
||||
- sec_label_name=<value-or-missing>
|
||||
限制:<unsupported_checks or none>
|
||||
建议动作:<read-only next step or proposed remediation>
|
||||
```
|
||||
|
||||
## 容器安全诊断报告摘要
|
||||
|
||||
```text
|
||||
已完成只读安全诊断,没有做任何权限修改。
|
||||
|
||||
一句话结论:<未发现互联网公开链接 / 存在互联网公开链接候选风险>;<external_access_count> 个文档允许对外分享,<missing_label_count> 个文档缺少密级标签。建议优先复核 <top_priority_group_or_paths>。
|
||||
|
||||
覆盖情况:
|
||||
- 当前身份可见目标:<visible_count>
|
||||
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
|
||||
- 读取失败 / 已删除 / 无权限:<failed_count>
|
||||
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
|
||||
|
||||
风险分级:
|
||||
- 高优先级待复核:<internet_public_count> 个互联网公开链接候选;<external_access_count> 个允许对外分享;其中 <external_without_label_count> 个同时缺少密级标签。
|
||||
- 中优先级待复核:<tenant_link_count> 个公司内知道链接可访问 / 可编辑;<wide_share_count> 个协作者管理范围较宽。
|
||||
- 待策略确认:<security_count> 个复制 / 下载 / 打印范围待复核;<comment_count> 个评论范围待复核。
|
||||
- 无法判断:<unsupported_or_unverified_summary>。
|
||||
|
||||
分级含义:
|
||||
- 互联网公开链接:获得链接的任何人可能访问,最高优先级。
|
||||
- 允许对外分享:外部分享能力已开启,需 owner 复核;不等于已经存在外部协作者。
|
||||
- 公司内链接可访问:不是对外公开,但组织内扩散范围较宽。
|
||||
- 复制 / 下载 / 打印 / 评论:是否需要收紧取决于业务 policy 和文档密级。
|
||||
|
||||
高优先级待复核清单:
|
||||
> 按 `摘要清单展开规则` 展示。每个对象必须包含 `risk_id` 和 URL;缺少 URL 时展示 token / node_token 和原因。若没有高优先级对象,只展示中优先级或待策略确认分组摘要。
|
||||
|
||||
- <risk_id> <path-or-title> (<type>)
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
Owner: <owner-or-unknown>
|
||||
密级:<sec_label_name-or-missing-or-unknown>
|
||||
待复核原因:<why high priority>
|
||||
证据:<short user-language evidence, e.g. 对外分享=已开启;链接分享=未开启互联网公开链接>
|
||||
建议动作:<recommended action>
|
||||
|
||||
未完全展开:
|
||||
- 完整风险清单包含 <risk_manifest_count> 条;本摘要已展示 <shown_count> 条,未展示 <hidden_count> 条。
|
||||
- 未展示分组:<risk_group=count summary or none>
|
||||
|
||||
建议下一步:
|
||||
- 生成完整风险清单 artifact,包含 `risk_id`、URL、owner、密级、证据字段、建议动作和 `selected` 列。
|
||||
- 基于 risk_id、风险分组、owner、路径、URL 或 artifact 中 `selected=true` 的行生成只读整改 dry-run。
|
||||
- 只针对最高优先级目标进入写入确认流程,例如关闭互联网公开链接或收紧对外分享;写入前仍需二次确认。
|
||||
- 按 owner / 密级生成复核清单。
|
||||
- 继续读取访问记录,判断低活跃高暴露。
|
||||
|
||||
剩余限制:
|
||||
- <do not claim collaborator-list verification if unsupported>
|
||||
- <external_access_entity=open or external_access=true only means sharing outside is allowed, not that external collaborators exist>
|
||||
- <missing view_records / DLP / AI index status / audit log limitations>
|
||||
```
|
||||
|
||||
## 可操作风险清单
|
||||
|
||||
完整风险清单用于让用户选择后续治理范围。Markdown / CSV / 飞书文档报告都必须包含以下字段;如果某种格式无法完整展示嵌套证据,使用短文本摘要,保留 `risk_id` 和 URL。
|
||||
|
||||
```text
|
||||
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
|
||||
生成时间:<timestamp>
|
||||
用途:用户可按 risk_id、priority、risk_group、owner、path、URL 或 selected=true 选择治理对象。
|
||||
|
||||
| risk_id | priority | Path | URL | Type | Owner | sec_label | risk_group | evidence | recommended_action | current_setting | target_setting | selected | decision | status | skip_reason |
|
||||
|---------|----------|------|-----|------|-------|-----------|------------|----------|--------------------|-----------------|----------------|----------|----------|--------|-------------|
|
||||
| PG-001 | P1 | <path> | <url-or-token> | <type> | <owner-or-unknown> | <sec-label-or-missing> | <risk_group> | <short evidence> | <recommended-action> | <field=value> | <field=value-or-owner-review> | false | undecided | pending | <none-or-reason> |
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `risk_id` 按 priority、risk_group、normalized path、URL、canonical token / node_token 稳定排序生成;URL 缺失时必须使用 token / node_token 作为 tie-breaker。同名、同路径、shortcut 或多个 Wiki 入口不能只靠 path 生成编号;同一次诊断中不得重复。
|
||||
- `priority` 使用 `P0`、`P1`、`P2`、`PolicyReview`、`Unknown`;面向用户展示时可译为“最高优先级 / 高优先级待复核 / 中优先级待复核 / 待策略确认 / 无法判断”。
|
||||
- `selected` 默认 `false`;用户可在 CSV / 飞书文档表格中改为 `true`,或在聊天中直接说 “处理 PG-001、PG-003”。
|
||||
- `decision` 表示用户决策:`undecided`、`keep`、`dry_run`、`confirm_write`、`skip`。
|
||||
- `status` 表示执行状态:`pending`、`dry_run_ready`、`confirmed`、`executed`、`verified`、`failed`、`skipped`。
|
||||
- `target_setting` 是建议目标状态,不代表已执行;没有明确 policy 时只能写 owner review / policy review。
|
||||
|
||||
## 治理选择交互
|
||||
|
||||
用户基于完整风险清单继续治理时,Agent 必须先解析选择范围,再生成只读 dry-run:
|
||||
|
||||
```text
|
||||
可接受的用户选择:
|
||||
- 处理 PG-001、PG-003、PG-008,把互联网公开链接关闭。
|
||||
- 先处理所有 risk_group=internet_public_link,不处理 external_access_only。
|
||||
- 把 CSV / 飞书文档里 selected=true 的行生成整改 dry-run。
|
||||
- PG-003 先跳过,只处理 PG-001。
|
||||
|
||||
Agent 必须回复:
|
||||
- 已选择对象数:<count>
|
||||
- 选择来源:<risk_id list / risk_group / selected=true / URL / path>
|
||||
- 将执行的下一步:生成 dry-run;不执行写入
|
||||
- 需要跳过或重新确认的对象:<missing risk_id / unsupported / changed_since_report / no manage_public>
|
||||
```
|
||||
|
||||
如果用户选择来自旧报告或外部 artifact,生成 dry-run 前必须对所选目标重新读取当前权限。当前设置和报告快照不一致时,标记为 `changed_since_report`,不要直接沿用旧字段执行。
|
||||
|
||||
## 权限设置清单
|
||||
|
||||
```text
|
||||
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
|
||||
|
||||
| Path | URL | Type | link_share_entity | external_access_entity / external_access | invite_external | share_entity | manage_collaborator_entity | copy_entity | security_entity | comment_entity | lock_switch | sec_label_name | 建议动作 | 限制 |
|
||||
|------|-----|------|-------------------|------------------------------------------|-----------------|--------------|----------------------------|-------------|-----------------|----------------|-------------|----------------|----------|------|
|
||||
| <path> | <url-or-token> | <type> | <value> | <value> | <value-or-unknown> | <value> | <value-or-unknown> | <value-or-unknown> | <value> | <value> | <value-or-unknown> | <value-or-missing> | <recommended-action> | <unsupported-or-none> |
|
||||
```
|
||||
|
||||
## 访问复核清单
|
||||
|
||||
```text
|
||||
范围:<wiki_space / wiki_node / drive_folder / explicit_list> <name-or-id>
|
||||
复核对象数:<count>
|
||||
|
||||
| Owner | Path | URL | Type | 密级 | 风险标签 | 当前权限摘要 | 最近访问证据 | 建议动作 |
|
||||
|-------|------|-----|------|------|----------|--------------|--------------|----------|
|
||||
| <owner-or-unknown> | <path> | <url-or-token> | <type> | <sec-label-or-missing> | <labels> | <link/external/share/security/comment> | <uv/pv/last_view_or_unknown> | <keep / tighten / owner review / unsupported> |
|
||||
|
||||
限制:<unsupported_checks / discovery_blockers / none>
|
||||
```
|
||||
|
||||
## 整改 dry-run
|
||||
|
||||
```text
|
||||
将生成整改计划,不执行写入:
|
||||
- 范围:<scope>
|
||||
- 选择来源:<risk_id list / risk_group / selected=true artifact / URL list>
|
||||
- 候选目标数:<count>
|
||||
- 计划执行命令:<command family>
|
||||
- 重新读取:已对所选目标重新读取当前权限;changed_since_report=<count>
|
||||
- 字段变更:
|
||||
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
|
||||
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
|
||||
|
||||
请确认是否进入写入确认。
|
||||
```
|
||||
|
||||
## 批量权限申请确认
|
||||
|
||||
```text
|
||||
将逐个发起 <view / edit> 权限申请:
|
||||
- 候选目标数:<count>
|
||||
- 命令类型:drive +apply-permission
|
||||
- 风险:write;每个请求都会通知 owner
|
||||
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
|
||||
|
||||
候选示例:
|
||||
- <risk_id> <title> (<type>, <url-or-token>):<reason>
|
||||
|
||||
请确认是否对上述候选目标发起权限申请。
|
||||
```
|
||||
|
||||
## owner 转移确认
|
||||
|
||||
```text
|
||||
将逐个转移 owner:
|
||||
- 候选目标数:<count>
|
||||
- 命令类型:drive permission.members transfer_owner
|
||||
- 风险:high-risk-write;会改变文档 owner,可能影响原 owner 权限和文档所在位置
|
||||
- 新 owner 映射:<same_new_owner / per_target_new_owner>
|
||||
- 全局新 owner:<member_id> (<member_type>);仅当所有候选目标的新 owner 相同时展示,否则省略
|
||||
- 通知新 owner:<need_notification>
|
||||
- 原 owner 权限:<remove_old_owner=true / old_owner_perm>
|
||||
- 个人空间位置:<stay_put>
|
||||
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
|
||||
- 验证方式:执行后重新读取 metadata owner;metadata 不支持的类型标记为 partial
|
||||
- 回滚边界:不做自动回滚;如需恢复 owner,必须另起一次反向 owner 转移确认
|
||||
|
||||
候选示例:
|
||||
- <risk_id> <title> (<type>, <url-or-token>):当前 owner=<owner-or-unknown> -> 新 owner=<member_id> (<member_type>)
|
||||
|
||||
请确认是否对上述候选目标转移 owner。
|
||||
```
|
||||
|
||||
## 确认请求
|
||||
|
||||
```text
|
||||
将执行 <operation>:
|
||||
- 目标:<risk_id> <title> (<type>, <url-or-token>)
|
||||
- 命令类型:<command family>
|
||||
- 风险:<risk_level>
|
||||
- 字段变更:
|
||||
- <field>: <old> -> <new>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
|
||||
请确认是否执行。
|
||||
```
|
||||
|
||||
## 最终摘要
|
||||
|
||||
```text
|
||||
已完成:<read checks / writes>
|
||||
验证:<fresh read result or async permission-request approval note>
|
||||
清单状态:<risk_id status updates / not applicable>
|
||||
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
剩余限制:<unsupported_checks / partial facts / approvals>
|
||||
```
|
||||
@@ -1,207 +0,0 @@
|
||||
# lark-drive 权限治理 Workflow
|
||||
|
||||
Workflow id: `permission_governance`
|
||||
|
||||
Risk / Structure: `R2` / `S2`
|
||||
|
||||
本文实现已注册的权限治理 workflow。执行前必须先读取 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
|
||||
|
||||
## 适用范围
|
||||
|
||||
当用户要求检查或治理 Drive / Docs / Wiki 资产访问权限时,使用本 workflow。典型意图包括:
|
||||
|
||||
- 单资源公开性、外部访问、公司内链接、分享 / 复制 / 下载 / 评论设置检查。
|
||||
- 多资源、Wiki space / node、Drive folder 或个人文档库的权限风险诊断和权限设置清单。
|
||||
- 访问复核、低活跃高暴露、权限申请、owner 转移、密级标签调整、AI Agent / RAG 前置权限治理。
|
||||
- 只读整改 dry-run,或经确认后的权限收紧 / 权限申请 / owner 转移 / 密级标签更新。
|
||||
|
||||
目标可以是明确 URL / token、小规模明确列表、Wiki space / Wiki node 或 Drive folder。容器范围必须先只读 `DISCOVER_TARGETS` 并产出覆盖摘要;这里的"所有文档"只表示当前身份在确认范围内可枚举到的文档。任何写入都必须再次确认。
|
||||
|
||||
单目标轻量路径:用户只问“是否对外公开 / 外部可访问 / 公司内链接可见”且目标是单个明确 URL / token 时,设置 `intent=public_exposure_check`、`target_scope=single_resource`,走 `PARSE_INTENT -> TARGET_INSPECT -> FACT_READ -> RISK_ASSESS -> DONE`。该路径是 `target_count=1` 的轻量输出模式,不是独立判断逻辑;不执行 `DISCOVER_TARGETS`、不生成 `risk_manifest` / `risk_id`,只输出结论、权限含义、检查边界和必要下一步。
|
||||
|
||||
## Target Set Evaluation
|
||||
|
||||
本 workflow 不按“单篇 / 多篇 / 容器”复制权限判断逻辑。所有范围先归一为 target set,再对每个可审计目标生成 `per_target_permission_assessment`,最后按目标数量和风险分组聚合输出。
|
||||
|
||||
| target_scope | Target Collection | Output Mode |
|
||||
|--------------|-------------------|-------------|
|
||||
| `single_resource` | 直接解析一个 URL / token | `target_count=1` 时轻量渲染;不生成 `risk_manifest` |
|
||||
| `explicit_list` | 用户给出的多个 URL / token 逐个 inspect / normalize | 逐目标渲染摘要;需要后续治理时生成稳定 `risk_id` |
|
||||
| `wiki_space` / `wiki_node` / `drive_folder` | 先只读递归发现,再归一化为 `discovered_targets` | 输出覆盖情况、风险分组、可定位待复核对象和 artifact / dry-run CTA |
|
||||
|
||||
特殊的是目标收集和输出聚合,不是权限语义。`link_access`、`external_sharing`、`copy_scope`、`security_scope`、`comment_scope`、`sec_label`、`check_scope` 等语义字段必须在单目标、多目标明确列表和容器发现目标之间复用。
|
||||
|
||||
## 非目标
|
||||
|
||||
本 workflow 不处理:
|
||||
|
||||
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
|
||||
- 内容审查、过期内容判断或知识质量评分。
|
||||
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner,不建模 backup owner 或负责人绑定关系。
|
||||
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
|
||||
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
|
||||
- 未按范围确认的批量写入。
|
||||
|
||||
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
|
||||
|
||||
## Progressive Load Map
|
||||
|
||||
本表只规定每个 state 需要加载的额外上下文;命令可用范围以 `Command Map` 为准。需要拼装具体 `lark-cli` 命令时,再按需读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。
|
||||
|
||||
| State | Required Reference |
|
||||
|-------|--------------------|
|
||||
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
|
||||
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
|
||||
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
|
||||
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
|
||||
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
|
||||
| `VERIFY` | 复用 `FACT_READ` 阶段使用的 read schemas |
|
||||
|
||||
## Runtime State Extension
|
||||
|
||||
本 workflow 在共享 `Artifact Contract` 基础上扩展以下字段组:
|
||||
|
||||
| Group | Fields | Meaning |
|
||||
|-------|--------|---------|
|
||||
| Scope | `intent`, `target_scope`, `targets`, `discovered_targets`, `coverage_summary`, `discovery_blockers` | 记录用户意图、确认范围、直接目标、容器发现目标和未覆盖范围 |
|
||||
| Facts | `metadata_facts`, `public_permission_facts`, `activity_facts`, `manage_public_auth` | 记录 metadata、公共访问与协作权限、访问证据,以及写前 `manage_public` 校验 |
|
||||
| Assessment | `per_target_permission_assessments`, `risk_findings`, `unsupported_checks` | 记录逐目标语义判断、带 `risk_id` / URL / owner / sec_label / evidence / action 的风险发现,以及无法执行的检查 |
|
||||
| Governance | `risk_manifest`, `selected_risk_items`, `access_review_items`, `permission_request_candidates`, `owner_transfer_candidates` | 支持用户按 `risk_id`、风险分组、owner、路径、URL 或 artifact `selected=true` 选择治理范围,并记录 owner 转移候选 |
|
||||
| Execution | `remediation_plan`, `owner_transfer_plan`, `public_permission_snapshots` | 记录 dry-run / 已确认整改计划、owner 转移计划、字段 diff、验证方式和 public-permission 有限回滚快照 |
|
||||
|
||||
## Execution State Machine
|
||||
|
||||
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|---------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy,以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check`、`target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action,或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token,除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy,则对照 policy;`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`;owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`;治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA;单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
|
||||
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
|
||||
| `VERIFY` | `verify` | 重新执行支持的读取,并与目标状态对比 | 验证表和剩余缺口 | `false` | `DONE` |
|
||||
| `DONE` | `done` | 停止 | 最终回复,包含完成事项、验证结果和剩余风险 | `false` | End |
|
||||
|
||||
## Command Map
|
||||
|
||||
本 workflow 只能使用以下 command families:
|
||||
|
||||
| State | Allowed Command Families | Purpose |
|
||||
|-------|--------------------------|---------|
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
|
||||
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
|
||||
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
|
||||
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
|
||||
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
|
||||
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
|
||||
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
|
||||
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
|
||||
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request;只允许单目标、小列表或已明确确认的候选列表逐个执行 |
|
||||
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata,包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
|
||||
## Command Patterns
|
||||
|
||||
本入口不内联命令样例。需要拼装具体 `lark-cli` 命令时,按当前 state 读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。命令是否允许执行仍以 `Command Map` 和写入规则为准。
|
||||
|
||||
## Discovery Rules
|
||||
|
||||
容器范围只能先做只读发现和覆盖摘要,不能在发现阶段执行权限申请、权限 patch 或密级更新。
|
||||
|
||||
通用规则:
|
||||
|
||||
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers` 或 `unsupported_checks`。
|
||||
2. 发现阶段必须生成稳定 `path`。不要只保存 title;同名文档必须能通过 path 或 token 区分。
|
||||
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。
|
||||
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`,URL、owner、密级等 metadata 可能进入 `unsupported_checks`。
|
||||
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。
|
||||
|
||||
Wiki space / node 发现:
|
||||
|
||||
1. `/wiki/space/<space_id>` 直接解析为 `target_scope=wiki_space`。不要因为 `drive +inspect` 对该 URL 返回 not found 就停止。
|
||||
2. 用 `wiki +node-list --space-id <space_id>` 读取根节点;当节点 `has_child=true` 时,用该节点的 `node_token` 继续递归读取子节点。
|
||||
3. Wiki 节点必须同时保留 `node_token`、`obj_token` 和 `obj_type`。权限读取优先用 `type=wiki` + `node_token` 表达 Wiki 节点权限;元数据补充可使用 `obj_type` + `obj_token`。
|
||||
4. 如果节点只有 `obj_token` / `obj_type`,但无法确认 Wiki 节点权限 token,保留该目标为 partial,并在 `unsupported_checks` 中说明只能读取底层对象或无法完整判断 Wiki 节点权限。
|
||||
|
||||
Drive folder 发现:
|
||||
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
|
||||
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files`、`has_more` 和 `next_page_token`。不要把第一页数量当作完整范围。
|
||||
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`。
|
||||
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
|
||||
|
||||
## Fact Read Rules
|
||||
|
||||
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets` 或 `discovered_targets` 超过 200 个时,必须分批读取并合并结果。
|
||||
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`;metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`。
|
||||
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
|
||||
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑;差异只体现在目标来源、coverage summary 和输出聚合。
|
||||
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
7. `drive file.statistics get` 和 `drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
|
||||
8. 访问统计 / 访问记录当前只对 `doc`、`docx`、`sheet`、`bitable`、`mindnote`、`wiki`、`file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
|
||||
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
|
||||
|
||||
## Risk Classification
|
||||
|
||||
风险标签只能作为 evidence labels。除非用户提供明确 policy,否则不要表述为绝对违规、已泄露或已外部访问。
|
||||
|
||||
默认优先级面向用户决策,而不是制造告警感:
|
||||
|
||||
- `P0`:`link_share_entity=anyone_readable/anyone_editable`,互联网公开链接候选风险。
|
||||
- `P1`:`external_access_entity=open` / `external_access=true`、关联组织访问、公司内链接可编辑,或外部分享且缺少 / 低于 policy 密级标签。
|
||||
- `P2`:公司内知道链接可读、协作者管理范围较宽。
|
||||
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
|
||||
- `Unknown`:读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
|
||||
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
|
||||
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
|
||||
|
||||
## 写入规则
|
||||
|
||||
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
|
||||
- `drive permission.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
|
||||
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
|
||||
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
|
||||
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
|
||||
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由,就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
|
||||
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`。
|
||||
- 用户显式确认批量权限申请后,也必须逐个目标顺序调用 `drive +apply-permission`,并在结果中区分已发起申请、失败、无法构造申请请求和未发现目标。
|
||||
- `drive permission.members transfer_owner` 属于 owner 转移高风险写入。必须先确认目标、当前 owner、新 owner 的 `member_id` / `member_type`、`need_notification`、`remove_old_owner`、`old_owner_perm`、`stay_put`、执行顺序和验证方式;不能只凭姓名猜测新 owner。
|
||||
- owner 转移没有 `permission.members auth` 的等价 precheck。执行前只能用 schema 和当前 metadata 做计划,执行后必须用 `drive metas batch_query` fresh read 验证 owner;metadata 不支持的类型必须把验证标记为 partial。
|
||||
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true` 或 `old_owner_perm` 降权必须单独在确认中高亮。
|
||||
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`,不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
|
||||
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
|
||||
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
|
||||
|
||||
## 未来扩展边界
|
||||
|
||||
以下能力已有部分 CLI surface 或用户价值,但不要在当前 workflow 中作为可执行分支直接调用:
|
||||
|
||||
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
|
||||
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。
|
||||
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
|
||||
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
|
||||
## 输出策略
|
||||
|
||||
- 默认 summary-first:单目标输出简短审计摘要;多目标明确列表输出逐目标摘要;容器目标输出安全诊断报告摘要,不堆叠字段计数。
|
||||
- 单目标 `public_exposure_check` 按 outputs 的 `Semantic Rendering` 渲染 `per_target_permission_assessment`,输出用户语言结论和检查边界;默认不展示底层字段名、风险清单或整改 CTA。
|
||||
- 容器安全诊断必须包含一句话结论、覆盖情况、风险分级、可定位待复核对象、建议下一步和剩余限制。
|
||||
- 待复核对象必须包含稳定 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、证据和建议动作;缺少 URL 时展示 token / node_token 和原因。
|
||||
- 容器摘要按规模渐进披露,不能固定 Top N;未完全展开时必须说明完整清单总数,并给出生成 artifact / dry-run / owner 复核清单等 CTA。
|
||||
- 面向用户优先使用业务语言和“候选风险 / 待复核 / 待策略确认”;底层字段只作为证据。完整模板按需读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md)。
|
||||
- 不要默认创建文件、飞书文档或长表格;最终回复必须包含已完成事项、验证结果和剩余限制。异步权限申请审批只能表述为“已发起申请”。
|
||||
@@ -1,130 +0,0 @@
|
||||
# lark-drive Workflow 总框架
|
||||
|
||||
本文是 `lark-drive` workflow 总框架的运行协议和注册表。它面向 AI Agent 执行,只负责路由已纳入本总框架的 workflow。
|
||||
|
||||
`Workflow Registry` 是本总框架的唯一注册来源。未命中 registry 的请求必须按“未注册 workflow 处理”执行,不要按已有 workflow 类推扩展。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本总框架内的 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
下游 reference 只能按需逐步加载。不要因为命中本总框架,就预加载所有 workflow 文件或相关 skill。
|
||||
|
||||
## 能力边界
|
||||
|
||||
`lark-drive` workflow 总框架以 `lark-drive` 作为 Drive / Docs / Wiki 资产编排的总入口。其他领域 skill 只有在已纳入本总框架的 workflow 明确需要时,才作为辅助能力加载。
|
||||
|
||||
| Layer | Owns | Must Not Own |
|
||||
|-------|------|--------------|
|
||||
| `lark-drive/SKILL.md` | 用户意图到具体 workflow entry 的短路由 | 长流程逻辑、未注册场景 |
|
||||
| `lark-drive-workflow.md` | 共享运行协议、Artifact Contract、Workflow Registry、加载规则 | 非运行时背景说明、宽泛路线图、场景专项执行细节 |
|
||||
| Registered workflow file | 场景范围、状态机、Command Map、确认门槛、验证规则 | 其他场景、隐藏写入、未被 CLI/API 支持的能力声明 |
|
||||
|
||||
## 执行协议
|
||||
|
||||
每个已纳入本总框架的 workflow 必须遵循同一条执行骨架:
|
||||
|
||||
```text
|
||||
route -> scope -> read -> assess/plan -> confirm -> execute -> verify -> done
|
||||
```
|
||||
|
||||
运行规则:
|
||||
|
||||
1. 在读取或写入资产前,先把用户意图解析到唯一一个已纳入本总框架的 workflow。
|
||||
2. 在昂贵读取或写入规划前,先解析并确认 `target_scope`。
|
||||
3. 事实必须来自可执行 CLI 命令或被引用 skill;不要只凭目录结构推断治理结论。
|
||||
4. 无法执行的检查必须记录到 `unsupported_checks`,不能静默省略。
|
||||
5. 写入前必须产出计划。每一次写入都需要用户对准确范围和 command family 显式确认。
|
||||
6. CLI/API 支持验证时,写入后必须用 fresh read 验证。
|
||||
7. 结束时进入 `done`,返回已完成事项、验证结果和剩余限制。不要把尚未完成的外部审批描述成已完成。
|
||||
|
||||
## Artifact Contract
|
||||
|
||||
每个已纳入本总框架的 workflow 必须维护以下内部字段:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `workflow_id` | 本总框架注册的 workflow 名称,例如 `permission_governance` |
|
||||
| `current_state` | 当前 workflow 状态 |
|
||||
| `target_scope` | 已确认的目标范围和用户原始输入 |
|
||||
| `identity` | 当前身份和执行视角,通常为 `user` |
|
||||
| `facts` | 从 CLI 读取或引用 skill 获取的证据 |
|
||||
| `plan_items` | 候选动作;每项包含 command family、target、risk、verification method |
|
||||
| `unsupported_checks` | 因 CLI/API 覆盖、目标类型、认证或范围限制而无法执行的检查 |
|
||||
| `partial` | 结果是否不完整,以及不完整原因 |
|
||||
| `execution_results` | 已确认写入的执行结果 |
|
||||
| `verification_results` | fresh read 验证结果,或明确的异步审批限制 |
|
||||
|
||||
用户可见输出默认使用简洁 chat summary。只有在用户要求、结果过大不适合聊天展示,或当前 workflow 明确要求共享产物时,才创建本地文件或飞书文档。
|
||||
|
||||
## Workflow Entry Contract
|
||||
|
||||
每个已纳入本总框架的 workflow entry file 必须让 Agent 能直接判断和执行:
|
||||
|
||||
- 何时进入该 workflow,以及哪些需求不属于该 workflow;
|
||||
- 如何映射到共享执行骨架的 state machine;
|
||||
- 当前 state 需要按需加载哪些 reference;
|
||||
- 哪些 command family 可用,以及读写风险边界;
|
||||
- 写入前如何确认,写入后如何验证;
|
||||
- 最终回复必须包含哪些字段,或使用哪些 output templates。
|
||||
|
||||
每个纳入本总框架的 workflow 默认从一个独立 reference 文件开始。只有当写入、回滚或验证流程复杂到影响可读性时,才继续拆 phase 文件。
|
||||
|
||||
## Risk / Structure Gate
|
||||
|
||||
每个纳入本总框架的 workflow 都必须同时声明 `Risk Level` 和 `Structure Level`。风险等级决定安全门槛;结构等级决定文件拆分。高风险写入不等于必须拆 phase。
|
||||
|
||||
Risk Level:
|
||||
|
||||
| Level | Meaning | Runtime Requirement |
|
||||
|-------|---------|---------------------|
|
||||
| `R0` | read-only:只读发现、分析、报告 | 记录事实来源、`unsupported_checks` 和 `partial` 原因 |
|
||||
| `R1` | low-risk write:创建草稿、生成临时产物等低风险写入 | 写前说明范围,写后返回结果链接或标识 |
|
||||
| `R2` | high-risk write:权限变更、批量移动、标签修改等高风险写入 | 写前计划、准确 diff、用户显式确认、fresh read 验证 |
|
||||
| `R3` | destructive / recovery-sensitive write:删除、自动归档、双向同步、rollback cleanup | 恢复边界、执行日志、分批策略、失败停止条件和单独确认 |
|
||||
|
||||
Structure Level:
|
||||
|
||||
| Level | File Shape | When To Use |
|
||||
|-------|------------|-------------|
|
||||
| `S1` | compact entry only | 只读、轻量审计、简单计划,无复杂写入 |
|
||||
| `S2` | entry + optional `commands` / `outputs` / `artifacts` references | 有命令样例、输出模板、少量高风险写入,但状态链可集中表达 |
|
||||
| `S3` | entry + phase files + optional shared references | 多阶段写入、复杂验证、恢复 / rollback、长任务或分批执行 |
|
||||
|
||||
升级规则:
|
||||
|
||||
1. 新 workflow 默认从 `S1` 开始。
|
||||
2. Entry file 超过约 300 行时,优先拆 `commands`、`outputs` 或 `artifacts` reference。
|
||||
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
|
||||
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template,不默认新增独立 workflow。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;已发布的独立 `knowledge_organize` 是 `R2-R3/S3`,当前不作为本总框架 registry entry。
|
||||
|
||||
## 加载与拆分边界
|
||||
|
||||
- 每个纳入本总框架的场景默认只保留一个紧凑 workflow entry file。
|
||||
- 不为未注册或未来场景创建占位 reference / registry entry。
|
||||
- 只有 workflow 已经具备可执行规则时,才允许作为本总框架 workflow 出现在 `SKILL.md` 并加入 `Workflow Registry`。
|
||||
- 多文件 phase 拆分只用于执行、回滚或验证流程复杂到影响可读性的 `S3` 场景。
|
||||
|
||||
## Workflow Registry
|
||||
|
||||
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|
||||
|----------|--------|------|-----------|------------|---------|
|
||||
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
|
||||
|
||||
## Workflow Loading
|
||||
|
||||
当用户意图匹配到本总框架已注册 workflow 时:
|
||||
|
||||
1. 先读取本总框架文件。
|
||||
2. 只读取 `Workflow Registry` 中命中的 entry file。
|
||||
3. 按该 workflow 的 progressive load map 继续加载额外 reference。
|
||||
4. 除非用户改变意图,或当前 workflow 明确路由到其他 workflow,否则不要读取其他 workflow 文件。
|
||||
|
||||
## 未注册 workflow 处理
|
||||
|
||||
`Workflow Registry` 是本总框架的唯一注册来源。用户请求未列入 registry 的 workflow 或组合型治理场景时:
|
||||
|
||||
1. 明确说明该需求暂无纳入本总框架的 `lark-drive` workflow。
|
||||
2. 只在不新增本总框架 workflow 行为的前提下,将请求收窄为现有 skill / CLI 可执行的原子操作。
|
||||
3. 不要类比本总框架任何已注册 workflow 新增 state machine、artifact shape、风险分类、写入行为或验证结论。
|
||||
@@ -53,7 +53,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as ``; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
|
||||
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
|
||||
|
||||
Two ways to get the binaries:
|
||||
- **In one pass:** add `--download-resources` to this command — every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) is downloaded into `./lark-im-resources/` and a `resources` block (`{message_id, key, type, local_path, size_bytes}`) is attached to each message. See [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in).
|
||||
@@ -61,7 +61,7 @@ Two ways to get the binaries:
|
||||
|
||||
| Resource Type | Marker in Content | Behavior |
|
||||
|---------|-------------|------|
|
||||
| Image | `` | `--download-resources`, or manually `im +messages-resources-download --type image` |
|
||||
| Image | `[Image: img_xxx]` | `--download-resources`, or manually `im +messages-resources-download --type image` |
|
||||
| File | `<file key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
|
||||
| Audio | `<audio key="file_xxx" duration="Xs"/>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
|
||||
| Video | `<video key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
|
||||
|
||||
@@ -32,7 +32,7 @@ When enabled:
|
||||
- Output paths are confined to `./lark-im-resources/` by the same guards as [`+messages-resources-download`](lark-im-messages-resources-download.md) (abnormal `file_key` with path separators / `..` / absolute paths is rejected).
|
||||
- **Scope**: the download uses `GET /open-apis/im/v1/messages/:message_id/resources/:file_key`, which requires `im:message:readonly` — already declared in each listing command's `Scopes`, so `--download-resources` needs **no extra scope** beyond what's required to read the messages (user identity also needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user`; bot identity needs `im:message.group_msg` / `im:message.p2p_msg:readonly`, all already declared). Works under both user and bot identity. If a bot was registered before `im:message:readonly` was granted, a single resource will fail-silently (`error: true` + stderr warning) rather than aborting the pull.
|
||||
|
||||
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. ``, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
|
||||
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `[Image: img_xxx]`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
|
||||
|
||||
## Scope requirement
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ lark-cli im +messages-mget --message-ids "om_aaa,om_bbb"
|
||||
|
||||
1. **Use JSON for full content:** table output truncates content. Use `--format json` when the full body matters.
|
||||
2. **Sender names are already enriched:** the command resolves sender names automatically, so no extra lookup is required.
|
||||
3. **Images are rendered as placeholders:** image messages appear as placeholders such as ``. Use `+messages-resources-download` when you need the binary resource.
|
||||
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `[Image: img_xxx]`. Use `+messages-resources-download` when you need the binary resource.
|
||||
4. **Batching is more efficient:** fetching multiple IDs in one request is better than calling the API repeatedly.
|
||||
|
||||
## References
|
||||
|
||||
@@ -152,7 +152,7 @@ lark-cli im +threads-messages-list --thread <thread_id>
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as ``; resource binaries are **not** downloaded automatically.
|
||||
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `[Image: img_xxx]`; resource binaries are **not** downloaded automatically.
|
||||
|
||||
Use `im +messages-resources-download` if you need to fetch the underlying image or file bytes from a specific message.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as ``; by default resource binaries are **not** downloaded.
|
||||
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; by default resource binaries are **not** downloaded.
|
||||
|
||||
Pass `--download-resources` to download every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` in one pass and attach a `resources` block to each reply (see [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in)). Otherwise download individual resources manually through `im +messages-resources-download` (see [lark-im-messages-resources-download](lark-im-messages-resources-download.md)).
|
||||
|
||||
|
||||
@@ -18,20 +18,16 @@ metadata:
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------------------------------------------------------------|--------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
|
||||
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
|
||||
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
|
||||
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
|
||||
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
|
||||
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
|
||||
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
|
||||
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
|
||||
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
|
||||
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值 |
|
||||
| Shortcut | 说明 |
|
||||
|--------------------------------------------------------------|--------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
|
||||
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
|
||||
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
|
||||
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
|
||||
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
|
||||
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
|
||||
|
||||
## 格式说明
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# okr +batch-create
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量创建 OKR 目标(Objective)和关键结果(Key Result)。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 批量创建 2 个 Objective,各带 2 个 KR。
|
||||
lark-cli okr +batch-create \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--input '[
|
||||
{
|
||||
"text": "提升产品用户体验",
|
||||
"mention": ["ou_xxxxxxxx"],
|
||||
"krs": [
|
||||
{"text": "页面加载速度提升 50%", "mention": ["ou_yyyyyyyy"]},
|
||||
{"text": "用户满意度达到 4.8 分"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "拓展新市场份额",
|
||||
"krs": [
|
||||
{"text": "新增 10 个城市覆盖"},
|
||||
{"text": "市场份额提升至 25%"}
|
||||
]
|
||||
}
|
||||
]' \
|
||||
--as user
|
||||
|
||||
# 从文件读取输入
|
||||
lark-cli okr +batch-create \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--input @okr_batch.json \
|
||||
--as user
|
||||
|
||||
# 预览 API 调用(Dry-run)
|
||||
lark-cli okr +batch-create \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--input @okr_batch.json \
|
||||
--dry-run \
|
||||
--as user
|
||||
```
|
||||
- mention 是可选参数,不需要使用“@”提及其他用户时不传入。
|
||||
- 传入的 mention 参数会以 @对应用户的形式,添加在文本后。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-----------|------------------------------------------------------------|
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型) |
|
||||
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `@-` 从 stdin 读取。 |
|
||||
| `--user-id-type` | 否 | `open_id` | mention 中使用的用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
|
||||
| `--format` | 否 | `json` | 输出格式 |
|
||||
|
||||
## 输入格式
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"text": "Objective 内容",
|
||||
"mention": ["ou_xxxxxxxx", "ou_yyyyyyyy"],
|
||||
"krs": [
|
||||
{
|
||||
"text": "KR 内容",
|
||||
"mention": ["ou_zzzzzzzz"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 获取可用的 OKR 周期 ID
|
||||
2. 构造 `--input` JSON 数组,包含要创建的 Objective 和 KR
|
||||
3. 执行 `lark-cli okr +batch-create --cycle-id <id> --input '...'`
|
||||
|
||||
## 输出
|
||||
|
||||
成功返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"created": [
|
||||
{
|
||||
"objective_id": "7000000000000000002",
|
||||
"krs": ["7000000000000000003", "7000000000000000004"]
|
||||
},
|
||||
{
|
||||
"objective_id": "7000000000000000005",
|
||||
"krs": ["7000000000000000006"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,80 +0,0 @@
|
||||
# okr +indicator-update
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
直接更新目标(Objective)或关键结果(Key Result)的指标当前值,无需手动查询指标 ID。
|
||||
|
||||
> **查询指标:** 如需查看指标详情,请使用原生 API:
|
||||
> - 目标指标:`lark-cli okr objective.indicators list --objective-id <id>`
|
||||
> - KR 指标:`lark-cli okr key_result.indicators list --key-result-id <id>`
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 更新 Objective 的指标值
|
||||
lark-cli okr +indicator-update \
|
||||
--level objective \
|
||||
--id 7000000000000000001 \
|
||||
--value 75.5 \
|
||||
--as user
|
||||
|
||||
# 更新 Key Result 的指标值
|
||||
lark-cli okr +indicator-update \
|
||||
--level key-result \
|
||||
--id 7000000000000000002 \
|
||||
--value 100 \
|
||||
--as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------|----|--------|--------------------------------------------------------------------|
|
||||
| `--level` | 是 | — | 操作层级:`objective`(更新目标指标)\| `key-result`(更新 KR 指标) |
|
||||
| `--id` | 是 | — | 目标 ID 或 KR ID(int64 类型) |
|
||||
| `--value` | 是 | — | 新的指标当前值(数字,范围:-99999999999 到 99999999999) |
|
||||
| `--dry-run`| 否 | — | 预览 API 调用而不实际执行 |
|
||||
| `--format` | 否 | `json` | 输出格式 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标 ID 或 KR ID。
|
||||
2. 如需查看当前指标值,使用 `objective.indicators list` 或 `key_result.indicators list` 查询。
|
||||
3. 执行 `+indicator-update` 指定层级、ID 和新值。
|
||||
4. 命令自动查询指标 ID 并更新当前值。
|
||||
|
||||
## 输出
|
||||
|
||||
### JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"indicator_id": "7000000000000000003",
|
||||
"current_value": 75.5,
|
||||
"level": "objective",
|
||||
"target_id": "7000000000000000001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------------|--------|------------------------|
|
||||
| `indicator_id` | string | 被更新的指标 ID |
|
||||
| `current_value`| number | 更新后的指标当前值 |
|
||||
| `level` | string | 操作层级:`objective` / `key-result` |
|
||||
| `target_id` | string | 目标或 KR 的 ID |
|
||||
|
||||
## 注意事项
|
||||
- 仅更新 `current_value` 字段,`unit`、`start_value`、`target_value` 等其他字段保持不变
|
||||
- 若需要这些字段进行修改,使用原生接口 indicators.patch
|
||||
- 指标的 `current_value_calculate_type` 必须为「手动更新」才能通过此命令修改。
|
||||
|
||||
## 参考
|
||||
|
||||
- [OKR 指标更新 API](https://open.feishu.cn/api-explorer?from=op_doc_tab&apiName=patch&project=okr&resource=okr.indicator&version=v2)
|
||||
- [`lark-okr-progress-create.md`](./lark-okr-progress-create.md) — 创建进度记录
|
||||
- [`lark-okr-cycle-detail.md`](./lark-okr-cycle-detail.md) — 查询周期详情获取 ID
|
||||
@@ -1,81 +0,0 @@
|
||||
# okr +reorder
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
调整 OKR 周期下目标(Objective)或目标下关键结果(Key Result)的顺序。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 调整 Objective 顺位
|
||||
lark-cli okr +reorder \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--level objective \
|
||||
--ops '[
|
||||
{"id": "7000000000000000002", "position": 2},
|
||||
{"id": "7000000000000000003", "position": 1}
|
||||
]' \
|
||||
--as user
|
||||
|
||||
# 调整 KR 顺位(需指定 --objective-id)
|
||||
lark-cli okr +reorder \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--level key-result \
|
||||
--objective-id 7000000000000000002 \
|
||||
--ops '[
|
||||
{"id": "7000000000000000004", "position": 1},
|
||||
{"id": "7000000000000000005", "position": 2}
|
||||
]' \
|
||||
--as user
|
||||
|
||||
# 从文件读取 ops
|
||||
lark-cli okr +reorder \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--level objective \
|
||||
--ops @reorder_ops.json \
|
||||
--as user
|
||||
```
|
||||
|
||||
- 不允许将多个 objective/key-result 放在同一个位置下
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|--------|---------------------------------------------------------|
|
||||
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标顺序)\| `key-result`(调整目标下 KR 顺序) |
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。 |
|
||||
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
|
||||
| `--ops` | 是 | — | JSON 数组格式的顺位调整操作。支持 `@文件路径` 或 `@-` 从 stdin 读取。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
|
||||
| `--format` | 否 | `json` | 输出格式 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取周期 ID、目标 ID 和 KR ID。
|
||||
2. 构造 `--ops` JSON 数组,指定要调整的 ID 和新 position,执行命令。
|
||||
3. 返回调整后的完整顺序。
|
||||
|
||||
## 输出
|
||||
|
||||
成功返回 JSON(以调整 Objective 位置为例):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"level": "objective",
|
||||
"cycle_id": "7000000000000000001",
|
||||
"total": 3,
|
||||
"ordered": [
|
||||
"7000000000000000003",
|
||||
"7000000000000000002",
|
||||
"7000000000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,96 +0,0 @@
|
||||
# okr +weight
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
调整 OKR 周期下目标(Objective)或目标下关键结果(Key Result)的权重。支持部分指定权重,未指定的按原权重比例自动分配。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 调整 Objective 权重(部分指定,剩余自动分配)
|
||||
lark-cli okr +weight \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--level objective \
|
||||
--weights '[
|
||||
{"id": "7000000000000000002", "weight": 0.6},
|
||||
{"id": "7000000000000000003", "weight": 0.3}
|
||||
]' \
|
||||
--as user
|
||||
|
||||
# 调整 KR 权重(全部指定,和为 1)
|
||||
lark-cli okr +weight \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--level key-result \
|
||||
--objective-id 7000000000000000002 \
|
||||
--weights '[
|
||||
{"id": "7000000000000000004", "weight": 0.6},
|
||||
{"id": "7000000000000000005", "weight": 0.4}
|
||||
]' \
|
||||
--as user
|
||||
|
||||
# 从文件读取 weights
|
||||
lark-cli okr +weight \
|
||||
--cycle-id 7000000000000000001 \
|
||||
--level objective \
|
||||
--weights @weights.json \
|
||||
--as user
|
||||
```
|
||||
|
||||
参数限制: 请求中的权重保留三位小数,分配的所有权重和不能大于 1 (小于等于 1 是允许的)。
|
||||
|
||||
### 权重归一化
|
||||
|
||||
- 在 OKR 中,一个周期下所有 Objective 和 一个 Objective 下所有 Key Result 的权重和固定为 1.
|
||||
- 在使用 +weight shortcut 分配 OKR 权重时,已分配的总权重不得超过 1。
|
||||
- 若已分配的权重 < 1,剩余的权重会按照原始权重的比例均分到未指定的 Objective/Key Result 下。
|
||||
- 若所有 Objective/Key Result 均分配了权重但和 < 1,剩余的权重会计算在最后一个 Objective/Key Result 下。
|
||||
|
||||
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|--------|---------------------------------------------------------------------|
|
||||
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标权重)\| `key-result`(调整目标下 KR 权重) |
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型) |
|
||||
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
|
||||
| `--weights` | 是 | — | JSON 数组格式的权重分配。支持 `@文件路径` 或 `@-` 从 stdin 读取。权重保留三位小数,分配的所有权重和不能大于 1 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
|
||||
| `--format` | 否 | `json` | 输出格式 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取周期 ID、目标 ID、KR ID 和当前权重。
|
||||
2. 构造 `--weights` JSON 数组,指定要调整的 ID 和权重,执行命令。
|
||||
3. 返回调整后的完整权重列表。
|
||||
|
||||
## 输出
|
||||
|
||||
成功返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"level": "objective",
|
||||
"cycle_id": "7000000000000000001",
|
||||
"total": 3,
|
||||
"weights": [
|
||||
{"id": "7000000000000000002", "weight": 0.6},
|
||||
{"id": "7000000000000000003", "weight": 0.3},
|
||||
{"id": "7000000000000000004", "weight": 0.1}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关于 1001001 错误
|
||||
|
||||
有时,即使输入的参数完全正确, +weight 也会返回 1001001 错误。这是因为你的租户设置中,不一定开启了目标或关键结果的设置权重功能。
|
||||
若你确认输入的参数无误(cycle-id/objective-id 正确,weights 中的 id 均是同一个周期下的目标或同一个目标下的关键结果,weights 中的权重和 <1),
|
||||
不必进一步尝试,你需要向用户确认 OKR 应用目前是否开启了目标或关键结果的设置权重功能。
|
||||
|
||||
## 参考
|
||||
|
||||
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -18,7 +18,6 @@ metadata:
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
@@ -83,7 +82,6 @@ lark-cli auth login --domain slides
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# slides +screenshot
|
||||
|
||||
## 用途
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation '<xml_presentation_id 或 slides/wiki URL>' \
|
||||
--slide-number 1
|
||||
```
|
||||
|
||||
渲染本地 XML 内容:
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--content @slide.xml
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入 |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 |
|
||||
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
|
||||
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
|
||||
| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
|
||||
|
||||
## 示例
|
||||
|
||||
### 单页截图
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-number 1
|
||||
```
|
||||
|
||||
### 多页截图
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-number 1 \
|
||||
--slide-number 2 \
|
||||
--output-dir .lark-slides/screenshots/demo
|
||||
```
|
||||
|
||||
### 渲染 XML 预览
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--content @.lark-slides/out/demo/slide.xml \
|
||||
--output-name preview
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
返回 JSON 不包含 Base64 图片内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"output_dir": ".lark-slides/screenshots",
|
||||
"screenshots": [
|
||||
{
|
||||
"slide_id": "slide_example_id",
|
||||
"slide_number": 1,
|
||||
"format": "png",
|
||||
"path": "/abs/path/.lark-slides/screenshots/slides_example_presentation_id_p001_slide_example_id.png",
|
||||
"size": 12345
|
||||
}
|
||||
]
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 优先使用 `slides +screenshot` 保存本地图片,不要把图片 Base64 打到 stdout。
|
||||
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。
|
||||
3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。
|
||||
4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。
|
||||
5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
@@ -1,456 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// --- Dry-run E2E tests for +batch-create, +reorder, +weight ---
|
||||
|
||||
// TestOKR_BatchCreateDryRun validates +batch-create dry-run output contains expected API paths.
|
||||
func TestOKR_BatchCreateDryRun(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+batch-create",
|
||||
"--cycle-id", "123456",
|
||||
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/"), "dry-run should contain KR API path prefix, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_BatchCreateDryRun_WithUserIDType validates +batch-create dry-run with --user-id-type.
|
||||
func TestOKR_BatchCreateDryRun_WithUserIDType(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+batch-create",
|
||||
"--cycle-id", "123456",
|
||||
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
|
||||
"--user-id-type", "user_id",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "user_id"), "dry-run should contain user-id-type, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_ReorderDryRun validates +reorder dry-run output contains expected API paths.
|
||||
func TestOKR_ReorderDryRun(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+reorder",
|
||||
"--cycle-id", "123456",
|
||||
"--level", "objective",
|
||||
"--ops", `[{"id":"obj_1","position":2},{"id":"obj_2","position":1}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_ReorderDryRun_KR validates +reorder dry-run with --level=key-result.
|
||||
func TestOKR_ReorderDryRun_KR(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+reorder",
|
||||
"--cycle-id", "123456",
|
||||
"--objective-id", "789",
|
||||
"--level", "key-result",
|
||||
"--ops", `[{"id":"1001","position":2},{"id":"1002","position":1}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_WeightDryRun validates +weight dry-run output contains expected API paths.
|
||||
func TestOKR_WeightDryRun(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+weight",
|
||||
"--cycle-id", "123456",
|
||||
"--level", "objective",
|
||||
"--weights", `[{"id":"obj_1","weight":0.6},{"id":"obj_2","weight":0.4}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_WeightDryRun_KR validates +weight dry-run with --level=key-result.
|
||||
func TestOKR_WeightDryRun_KR(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+weight",
|
||||
"--cycle-id", "123456",
|
||||
"--objective-id", "789",
|
||||
"--level", "key-result",
|
||||
"--weights", `[{"id":"1001","weight":0.5},{"id":"1002","weight":0.5}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
|
||||
}
|
||||
|
||||
// --- Live E2E tests (require user token, skip otherwise) ---
|
||||
|
||||
// getTestCycleID returns the test cycle ID from env var, or skips the test.
|
||||
func getTestCycleID(t *testing.T) string {
|
||||
t.Helper()
|
||||
cycleID := os.Getenv("OKR_TEST_CYCLE_ID")
|
||||
if cycleID == "" {
|
||||
t.Skip("OKR_TEST_CYCLE_ID not set; set to a valid cycle ID for live E2E tests")
|
||||
}
|
||||
return cycleID
|
||||
}
|
||||
|
||||
// liveTestCreated tracks resources created during a live test for cleanup.
|
||||
type liveTestCreated struct {
|
||||
ObjectiveID string
|
||||
KRIDs []string
|
||||
}
|
||||
|
||||
// createTestObjectives creates test objectives using +batch-create and returns the created IDs.
|
||||
func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suffix string) []liveTestCreated {
|
||||
t.Helper()
|
||||
|
||||
input := []map[string]interface{}{
|
||||
{
|
||||
"text": fmt.Sprintf("E2E Test Objective A %s", suffix),
|
||||
"krs": []map[string]interface{}{
|
||||
{"text": fmt.Sprintf("E2E Test KR A1 %s", suffix)},
|
||||
{"text": fmt.Sprintf("E2E Test KR A2 %s", suffix)},
|
||||
},
|
||||
},
|
||||
{
|
||||
"text": fmt.Sprintf("E2E Test Objective B %s", suffix),
|
||||
"krs": []map[string]interface{}{
|
||||
{"text": fmt.Sprintf("E2E Test KR B1 %s", suffix)},
|
||||
},
|
||||
},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+batch-create",
|
||||
"--cycle-id", cycleID,
|
||||
"--input", string(inputJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "failed to create test objectives")
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
var created []liveTestCreated
|
||||
createdArr := gjson.Get(result.Stdout, "data.created").Array()
|
||||
for _, obj := range createdArr {
|
||||
objectiveID := obj.Get("objective_id").String()
|
||||
var krIDs []string
|
||||
for _, kr := range obj.Get("krs").Array() {
|
||||
krIDs = append(krIDs, kr.String())
|
||||
}
|
||||
created = append(created, liveTestCreated{
|
||||
ObjectiveID: objectiveID,
|
||||
KRIDs: krIDs,
|
||||
})
|
||||
}
|
||||
|
||||
require.Len(t, created, 2, "expected 2 objectives created")
|
||||
require.Len(t, created[0].KRIDs, 2, "expected 2 KRs for first objective")
|
||||
require.Len(t, created[1].KRIDs, 1, "expected 1 KR for second objective")
|
||||
require.NotEmpty(t, created[0].ObjectiveID, "objective_id should not be empty")
|
||||
require.NotEmpty(t, created[0].KRIDs[0], "kr_id should not be empty")
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
// cleanupLiveTest deletes KRs first, then objectives, using the raw API service commands.
|
||||
func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
|
||||
t.Helper()
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
// Delete in reverse order: KRs first, then objectives
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
obj := created[i]
|
||||
// Delete KRs first (reverse order)
|
||||
for j := len(obj.KRIDs) - 1; j >= 0; j-- {
|
||||
krID := obj.KRIDs[j]
|
||||
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "v2/key_results", "delete",
|
||||
"--key-result-id", krID,
|
||||
"--yes",
|
||||
},
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
|
||||
select {
|
||||
case <-cleanupCtx.Done():
|
||||
return
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
// Then delete the objective
|
||||
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "v2/objectives", "delete",
|
||||
"--objective-id", obj.ObjectiveID,
|
||||
"--yes",
|
||||
},
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-cleanupCtx.Done():
|
||||
return
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOKR_BatchCreateLive validates +batch-create with real API calls: create, verify, cleanup.
|
||||
func TestOKR_BatchCreateLive(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
cycleID := getTestCycleID(t)
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Create test objectives
|
||||
created := createTestObjectives(t, ctx, cycleID, suffix)
|
||||
|
||||
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
|
||||
t.Cleanup(func() {
|
||||
cleanupLiveTest(t, created)
|
||||
})
|
||||
|
||||
// Verify: call +cycle-detail to confirm objectives exist
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
|
||||
foundCount := 0
|
||||
for _, obj := range objectives {
|
||||
objID := obj.Get("id").String()
|
||||
for _, c := range created {
|
||||
if objID == c.ObjectiveID {
|
||||
foundCount++
|
||||
// Verify KRs exist under this objective
|
||||
krs := obj.Get("key_results").Array()
|
||||
krIDs := make(map[string]bool)
|
||||
for _, kr := range krs {
|
||||
krIDs[kr.Get("id").String()] = true
|
||||
}
|
||||
for _, expectedKR := range c.KRIDs {
|
||||
assert.True(t, krIDs[expectedKR], "expected KR %s to exist under objective %s", expectedKR, objID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, len(created), foundCount, "all created objectives should be found in cycle detail")
|
||||
}
|
||||
|
||||
// TestOKR_ReorderLive validates +reorder with real API calls: create, reorder, verify, cleanup.
|
||||
func TestOKR_ReorderLive(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
cycleID := getTestCycleID(t)
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Create test objectives (A, then B)
|
||||
created := createTestObjectives(t, ctx, cycleID, suffix)
|
||||
|
||||
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
|
||||
t.Cleanup(func() {
|
||||
cleanupLiveTest(t, created)
|
||||
})
|
||||
|
||||
objA := created[0].ObjectiveID
|
||||
objB := created[1].ObjectiveID
|
||||
|
||||
// Reorder: swap positions (B at position 1, A at position 2)
|
||||
ops := []map[string]interface{}{
|
||||
{"id": objB, "position": 1},
|
||||
{"id": objA, "position": 2},
|
||||
}
|
||||
opsJSON, _ := json.Marshal(ops)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+reorder",
|
||||
"--cycle-id", cycleID,
|
||||
"--level", "objective",
|
||||
"--ops", string(opsJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
// Verify order via +cycle-detail
|
||||
result, err = clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
|
||||
var foundIDs []string
|
||||
for _, obj := range objectives {
|
||||
objID := obj.Get("id").String()
|
||||
if objID == objA || objID == objB {
|
||||
foundIDs = append(foundIDs, objID)
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, foundIDs, 2, "should find both test objectives")
|
||||
assert.Equal(t, objB, foundIDs[0], "after reorder, objective B should be first")
|
||||
assert.Equal(t, objA, foundIDs[1], "after reorder, objective A should be second")
|
||||
}
|
||||
|
||||
// TestOKR_WeightLive validates +weight with real API calls: create, set weights, verify sum=1.0, cleanup.
|
||||
func TestOKR_WeightLive(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
cycleID := getTestCycleID(t)
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Create test objectives
|
||||
created := createTestObjectives(t, ctx, cycleID, suffix)
|
||||
|
||||
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
|
||||
t.Cleanup(func() {
|
||||
cleanupLiveTest(t, created)
|
||||
})
|
||||
|
||||
objA := created[0].ObjectiveID
|
||||
objB := created[1].ObjectiveID
|
||||
|
||||
// Set weights: A=0.6, B=0.4 (sum=1.0)
|
||||
weights := []map[string]interface{}{
|
||||
{"id": objA, "weight": 0.6},
|
||||
{"id": objB, "weight": 0.4},
|
||||
}
|
||||
weightsJSON, _ := json.Marshal(weights)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+weight",
|
||||
"--cycle-id", cycleID,
|
||||
"--level", "objective",
|
||||
"--weights", string(weightsJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
// Verify weights via +cycle-detail
|
||||
result, err = clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
|
||||
var weightA, weightB float64
|
||||
for _, obj := range objectives {
|
||||
objID := obj.Get("id").String()
|
||||
if objID == objA {
|
||||
weightA = obj.Get("weight").Float()
|
||||
} else if objID == objB {
|
||||
weightB = obj.Get("weight").Float()
|
||||
}
|
||||
}
|
||||
|
||||
// Verify weights are set correctly (allowing for floating point tolerance)
|
||||
assert.InDelta(t, 0.6, weightA, 0.001, "objective A weight should be 0.6")
|
||||
assert.InDelta(t, 0.4, weightB, 0.001, "objective B weight should be 0.4")
|
||||
|
||||
// Verify sum = 1.0
|
||||
sumWeights := weightA + weightB
|
||||
assert.InDelta(t, 1.0, sumWeights, 0.001, "sum of weights should be 1.0, got %.6f", sumWeights)
|
||||
}
|
||||
Reference in New Issue
Block a user