mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
9 Commits
v1.0.73
...
feat/get-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d7cb03eb | ||
|
|
ca7135f582 | ||
|
|
7b58ba1b1d | ||
|
|
765b097d44 | ||
|
|
4a5e2c519a | ||
|
|
67fc870582 | ||
|
|
af8e027269 | ||
|
|
2efadec335 | ||
|
|
5fb70d326a |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,33 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: detect visual elements outside canvas
|
||||
- reduce public content credential fixture false positives
|
||||
- standardize CLI shortcut text in English (#1942)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: reduce filter and update retry loops (#1879)
|
||||
- **vc**: default transcript routing to smart notes over minutes (#1961)
|
||||
- clarify local trigger automation (#1958)
|
||||
|
||||
### Tests
|
||||
|
||||
- synchronize temporary Git maintenance (#1946)
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
|
||||
- [codex] support bot menu events (#1765)
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
@@ -1579,7 +1552,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
|
||||
type BotMenuOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
|
||||
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
|
||||
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
|
||||
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
|
||||
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
|
||||
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
|
||||
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
|
||||
}
|
||||
|
||||
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
AppID string `json:"app_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Timestamp json.RawMessage `json:"timestamp"`
|
||||
Operator struct {
|
||||
OperatorID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
} `json:"operator"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = menuTimestamp
|
||||
}
|
||||
operatorID := envelope.Event.Operator.OperatorID.OpenID
|
||||
|
||||
out := &BotMenuOutput{
|
||||
Type: eventTypeBotMenuV6,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
AppID: envelope.Header.AppID,
|
||||
TenantKey: envelope.Header.TenantKey,
|
||||
EventKey: envelope.Event.EventKey,
|
||||
MenuTimestamp: menuTimestamp,
|
||||
OperatorID: operatorID,
|
||||
OperatorOpenID: operatorID,
|
||||
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
|
||||
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
|
||||
OperatorName: envelope.Event.Operator.OperatorName,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func rawScalarString(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func timestampMillisString(raw json.RawMessage) string {
|
||||
s := rawScalarString(raw)
|
||||
if len(s) == 10 && allDigits(s) {
|
||||
return s + "000"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestKeysBotMenuMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeBotMenuV6 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.EventType != eventTypeBotMenuV6 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.SubscriptionType != "" {
|
||||
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Fatal("Schema.Custom is nil")
|
||||
}
|
||||
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
|
||||
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("Schema.Native must be nil for processed output")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process is nil")
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotMenuRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeBotMenuV6
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenu(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_001",
|
||||
"event_type": "application.bot.menu_v6",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test",
|
||||
"tenant_key": "tenant_test"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1776409469000,
|
||||
"operator": {
|
||||
"operator_id": {
|
||||
"open_id": "ou_operator",
|
||||
"union_id": "on_operator",
|
||||
"user_id": "user_operator"
|
||||
},
|
||||
"operator_name": "Test User"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
if out.EventID != "ev_menu_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.EventKey != "start_eval" {
|
||||
t.Errorf("EventKey = %q", out.EventKey)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469000" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
|
||||
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
|
||||
}
|
||||
if out.OperatorUnionID != "on_operator" {
|
||||
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
|
||||
}
|
||||
if out.OperatorUserID != "user_operator" {
|
||||
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
|
||||
}
|
||||
if out.OperatorName != "Test User" {
|
||||
t.Errorf("OperatorName = %q", out.OperatorName)
|
||||
}
|
||||
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
|
||||
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_002",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": "1776409469001",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1776409469001" {
|
||||
t.Errorf("Timestamp fallback = %q", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469001" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_seconds",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1694592375,
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1694592375000" {
|
||||
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1694592375000" {
|
||||
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_003",
|
||||
"event_type": "unexpected.event_type",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuMalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("processBotMenu: %v", err)
|
||||
}
|
||||
var out BotMenuOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\n%s", err, got)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application registers Application-domain EventKeys.
|
||||
package application
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeBotMenuV6 = "application.bot.menu_v6"
|
||||
|
||||
// Keys returns all Application-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeBotMenuV6,
|
||||
DisplayName: "Bot menu",
|
||||
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
|
||||
},
|
||||
Process: processBotMenu,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/application"
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
application.Keys(),
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
@@ -14,12 +18,75 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// "drive.files.delete"). When the original invocation can be re-run safely,
|
||||
// the hint carries the complete retry command with --yes appended — eval
|
||||
// traces show agents always self-heal by appending --yes, so handing them
|
||||
// the exact line saves the reconstruction step. The retry line is omitted
|
||||
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
|
||||
// as its own token or bundled onto a flag as --flag=-, whose piped data a
|
||||
// bare re-run would not reproduce) or when the rendered command would be
|
||||
// unreasonably long to echo back.
|
||||
func RequireConfirmation(action string) error {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action)
|
||||
if retry := retryCommandWithYes(os.Args); retry != "" {
|
||||
return err.WithHint("add --yes to confirm; re-run: %s", retry)
|
||||
}
|
||||
return err.WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
|
||||
// full invocation back (e.g. a +batch-update with a large inline JSON)
|
||||
// costs more context than it saves.
|
||||
const retryCommandMaxLen = 300
|
||||
|
||||
// retryCommandWithYes renders args as a shell-safe command line with --yes
|
||||
// appended, or "" when a safe rendering isn't possible (see
|
||||
// RequireConfirmation).
|
||||
func retryCommandWithYes(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(args)+1)
|
||||
parts = append(parts, filepath.Base(args[0]))
|
||||
for _, a := range args[1:] {
|
||||
if argReadsStdin(a) {
|
||||
return ""
|
||||
}
|
||||
parts = append(parts, shellQuoteArg(a))
|
||||
}
|
||||
parts = append(parts, "--yes")
|
||||
line := strings.Join(parts, " ")
|
||||
if len(line) > retryCommandMaxLen {
|
||||
return ""
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// argReadsStdin reports whether an argument makes a flag read from stdin — the
|
||||
// portable bare "-" value, whether passed as its own token (--flag -) or
|
||||
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
|
||||
// re-run cannot reproduce, so any such argument suppresses the retry line.
|
||||
func argReadsStdin(a string) bool {
|
||||
if a == "-" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shellQuoteArg single-quotes an argument when it contains any character a
|
||||
// POSIX shell could interpret, so the retry line is copy-paste safe.
|
||||
func shellQuoteArg(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
|
||||
}
|
||||
if cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
// The hint may additionally carry a re-run line composed from the live
|
||||
// os.Args (environment-dependent under `go test`), but the add-yes
|
||||
// contract always leads.
|
||||
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
|
||||
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
// No fix_command field leaks into the envelope: the retry line lives in
|
||||
// the free-text hint only; the typed protocol stays action-only.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
|
||||
// basename argv[0], and the two omission guards (stdin args, oversized
|
||||
// commands).
|
||||
func TestRetryCommandWithYes(t *testing.T) {
|
||||
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{
|
||||
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
|
||||
"--url", "https://x.feishu.cn/sheets/tok",
|
||||
"--range", "A1:B2", "--sheet-name", "第 1 班",
|
||||
})
|
||||
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single quotes inside args survive", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
|
||||
if !strings.Contains(got, `'it'\''s'`) {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin arg omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
|
||||
t.Errorf("stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
|
||||
// --flag=- reads stdin the same as --flag -; both must suppress the line.
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
|
||||
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized command omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
|
||||
t.Errorf("oversized invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// ResolveInput resolves special input conventions for a raw flag value:
|
||||
@@ -77,7 +80,25 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
// An absolute path under the system temp dir is read directly instead:
|
||||
// agents stage generated payloads (@/tmp/ops.json) there as a matter of
|
||||
// course, and the strict relative-to-cwd policy — load-bearing for uploads
|
||||
// and drive sync — only cost @file callers a python/stdin detour.
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
resolved, terr := validate.SafeTempAbsInputPath(path)
|
||||
if terr == nil {
|
||||
data, err := os.ReadFile(resolved) //nolint:forbidigo // resolved is confined to the system temp dir by SafeTempAbsInputPath
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
// Absolute but outside the temp dir: surface the prescriptive error
|
||||
// (relative path / temp-dir path / stdin) instead of the generic
|
||||
// relative-only message the strict validator below would produce.
|
||||
return nil, fmt.Errorf("invalid file path %q: %w", path, terr)
|
||||
}
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
}
|
||||
|
||||
@@ -19,18 +19,12 @@ import (
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
Path string `json:"path"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
type commentContent struct {
|
||||
Body string
|
||||
Path string
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
@@ -40,11 +34,12 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
diags, err := auditEvent(*eventPath, *kind)
|
||||
body, err := commentBody(*eventPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
@@ -52,44 +47,32 @@ func main() {
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
|
||||
content, err := commentBody(eventPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCommentContent(kind, content), nil
|
||||
}
|
||||
|
||||
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
|
||||
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (commentContent, error) {
|
||||
func commentBody(path string) (string, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return commentContent{}, err
|
||||
return "", err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return commentContent{}, err
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
|
||||
return payload.Comment.Body, nil
|
||||
case payload.Review != nil:
|
||||
return commentContent{Body: payload.Review.Body}, nil
|
||||
return payload.Review.Body, nil
|
||||
default:
|
||||
return commentContent{}, nil
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
@@ -34,92 +32,11 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "clean comment" || got.Path != "" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `CLIENT_SECRET=$(security find-generic-password -w)`
|
||||
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
diags, err := auditEvent("event.json", "pull_request_review_comment")
|
||||
if err != nil {
|
||||
t.Fatalf("auditEvent() error = %v", err)
|
||||
}
|
||||
for _, diag := range diags {
|
||||
if diag.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
|
||||
}
|
||||
}
|
||||
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
|
||||
for _, finding := range pathless {
|
||||
if finding.Rule == "public_content_generic_credential" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
|
||||
}
|
||||
|
||||
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
|
||||
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
|
||||
content := commentContent{
|
||||
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
|
||||
Path: "cmd/agent/list_test.go",
|
||||
}
|
||||
|
||||
diags := scanCommentContent("pull_request_review_comment", content)
|
||||
for _, diag := range diags {
|
||||
if diag.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
if diag.File != content.Path {
|
||||
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing provider credential diagnostic: %#v", diags)
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
|
||||
@@ -6,11 +6,10 @@ package diff
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
|
||||
@@ -123,7 +122,8 @@ func writeFile(t *testing.T, repo, rel, content string) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
@@ -131,7 +131,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func gitOutput(t *testing.T, repo string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v", args, err)
|
||||
|
||||
@@ -6,11 +6,10 @@ package publiccontent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
@@ -24,10 +23,9 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "`+providerValue+`"
|
||||
api_`+`key = "example-public-key"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
@@ -201,14 +199,13 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"` + providerValue + `"}`,
|
||||
`{"client_` + `secret": "` + providerValue + `"}`,
|
||||
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
|
||||
`{"github` + `Token":"` + providerValue + `"}`,
|
||||
`{"vendorApi` + `Key":"` + providerValue + `"}`,
|
||||
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
@@ -218,7 +215,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -302,8 +306,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,12 +338,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -354,11 +358,15 @@ func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
@@ -366,7 +374,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -383,7 +391,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, accessKey) {
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
@@ -424,7 +432,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -440,11 +448,15 @@ func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
@@ -477,13 +489,12 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
@@ -495,7 +506,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -604,8 +621,7 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
@@ -669,11 +685,10 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
@@ -840,7 +855,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
if len(args) > 0 && args[0] == "commit" {
|
||||
args = append([]string{"commit", "--no-verify"}, args[1:]...)
|
||||
}
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
@@ -849,7 +865,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
|
||||
@@ -4,15 +4,8 @@
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
return ScanCommentAtPath(kind, "", body)
|
||||
}
|
||||
|
||||
func ScanCommentAtPath(kind, path, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
if path == "" {
|
||||
path = kind
|
||||
}
|
||||
return scanText(path, "comment", body, isDetectorRuleFile(path))
|
||||
return scanText(kind, "comment", body, false)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
@@ -20,60 +17,3 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" participant Client",
|
||||
" participant AccessTokenHashTransport",
|
||||
" participant SecurityPolicyTransport",
|
||||
" Client->>AccessTokenHashTransport: Send request with bearer token",
|
||||
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
|
||||
" Client -> ClientSecret: Resolve configured credential",
|
||||
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
credentialAssignment := "password=" + providerValue
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" Client->>Server: Send " + credentialAssignment,
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
|
||||
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential in review comment should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func credentialValueHasStrongEvidence(key, value string) bool {
|
||||
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
|
||||
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
|
||||
for _, candidate := range candidates {
|
||||
if providerCredentialIdentifier(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if isCredentialMetadataField(key) {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
|
||||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
|
||||
}
|
||||
|
||||
func credentialEvidenceCandidates(value string) []string {
|
||||
candidates := []string{value}
|
||||
for range 3 {
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || decoded == value {
|
||||
break
|
||||
}
|
||||
candidates = append(candidates, decoded)
|
||||
value = decoded
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func isCredentialMetadataField(key string) bool {
|
||||
if isBenignTokenField(key) {
|
||||
return true
|
||||
}
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch parts[len(parts)-1] {
|
||||
case "hash", "id", "kind", "marker", "prefix", "transport":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func base64PaddedCredentialValue(value string) bool {
|
||||
if len(value) < 16 || !strings.HasSuffix(value, "=") {
|
||||
return false
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
|
||||
return false
|
||||
}
|
||||
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
|
||||
}
|
||||
|
||||
func percentEncodedCredentialValue(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var escapes int
|
||||
for i := 0; i+2 < len(value); i++ {
|
||||
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
|
||||
escapes++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return escapes >= 2
|
||||
}
|
||||
|
||||
func isHexByte(value byte) bool {
|
||||
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
@@ -383,63 +383,33 @@ func anglePlaceholderIdentifier(value string) bool {
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
return providerCredentialIdentifier(value)
|
||||
}
|
||||
|
||||
func providerCredentialIdentifier(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
switch {
|
||||
case providerTokenWithBody(value, "sk_live_", 16, ""),
|
||||
providerTokenWithBody(value, "sk_test_", 16, ""),
|
||||
providerTokenWithBody(value, "ghp_", 16, ""),
|
||||
providerTokenWithBody(value, "gho_", 16, ""),
|
||||
providerTokenWithBody(value, "ghu_", 16, ""),
|
||||
providerTokenWithBody(value, "github_pat_", 16, "_"),
|
||||
providerTokenWithBody(value, "xoxb_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxb-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa-", 16, "-"),
|
||||
awsAccessKeyIdentifier(value):
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
|
||||
body, ok := strings.CutPrefix(value, prefix)
|
||||
if !ok || len(body) < minBodyLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range body {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func awsAccessKeyIdentifier(value string) bool {
|
||||
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[4:] {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
|
||||
@@ -47,30 +47,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
|
||||
rawMatch := line[location[0]:location[1]]
|
||||
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
|
||||
continue
|
||||
}
|
||||
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
|
||||
if !isCredentialAssignmentMatch(rawMatch) {
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
|
||||
evidenceValue := value
|
||||
if sourceCodeFile(file) {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
|
||||
evidenceValue = rhs
|
||||
}
|
||||
}
|
||||
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
|
||||
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
|
||||
continue
|
||||
}
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
@@ -79,7 +64,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
@@ -138,43 +123,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
func validCredentialAssignmentStart(line string, start int, match string) bool {
|
||||
if start <= 0 || credentialAssignmentOperator(match) != ":" {
|
||||
return true
|
||||
}
|
||||
prefix := strings.TrimSpace(line[:start])
|
||||
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
|
||||
if strings.HasSuffix(prefix, arrow) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialAssignmentOperator(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(match[len(key):])
|
||||
if strings.HasPrefix(rest, ":=") {
|
||||
return ":="
|
||||
}
|
||||
if strings.HasPrefix(rest, ":") {
|
||||
return ":"
|
||||
}
|
||||
if strings.HasPrefix(rest, "=") {
|
||||
return "="
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, _, ok := normalizedCredentialAssignment(match)
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
@@ -325,7 +288,7 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
@@ -360,8 +323,52 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
@@ -481,20 +488,17 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) {
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
@@ -514,16 +518,17 @@ func isBenignCodeCredentialExpression(file, line string, matchStart int, match,
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
|
||||
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[matchStart+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
@@ -531,12 +536,7 @@ func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (st
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
|
||||
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
|
||||
if parsed == nil {
|
||||
return rhs, true
|
||||
}
|
||||
return credentialAssignmentValue(parsed), true
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -593,7 +593,6 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeStringExpressionLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
@@ -686,18 +685,6 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeStringExpressionLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" ||
|
||||
credentialShapedIdentifier(normalized) ||
|
||||
highEntropyCredentialValue(strings.ToLower(normalized)) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalized, "${") ||
|
||||
strings.Contains(normalized, "$(") ||
|
||||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
@@ -766,7 +753,7 @@ func codeIdentifier(value string) bool {
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[", `\`:
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -993,7 +980,6 @@ func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"p%40ss",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
|
||||
@@ -251,22 +251,26 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
|
||||
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"API_KEY=notredactedreal",
|
||||
"API_KEY=notplaceholdersecret",
|
||||
"API_KEY=abcxxxxreal",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable credential words should not be findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
|
||||
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
|
||||
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
|
||||
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
|
||||
paddedToken := base64PaddedFixture(paddedTokenPrefix)
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
@@ -290,25 +294,17 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
|
||||
if findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
jsonToken := providerValue
|
||||
jsonSecret := providerValue
|
||||
jsonKey := providerValue
|
||||
jsonTenantToken := providerValue
|
||||
jsonAppSecret := providerValue
|
||||
jsonPrefixedKey := providerValue
|
||||
jsonTenantCamelToken := providerValue
|
||||
jsonGithubToken := providerValue
|
||||
jsonVendorKey := providerValue
|
||||
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
|
||||
jsonToken := "real-json-token"
|
||||
jsonSecret := "real " + "secret value"
|
||||
jsonKey := "real-json-key"
|
||||
jsonTenantToken := "real-tenant-json-token"
|
||||
jsonAppSecret := "real-app-secret"
|
||||
jsonPrefixedKey := "real-prefixed-key"
|
||||
jsonTenantCamelToken := "real-tenant-camel-token"
|
||||
jsonGithubToken := "real-github-token"
|
||||
jsonVendorKey := "real-vendor-key"
|
||||
jsonSlackBotToken := "xoxb-real-token"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_` + `token":"` + jsonToken + `"}`,
|
||||
`{"client_` + `secret": "` + jsonSecret + `"}`,
|
||||
@@ -338,13 +334,12 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -352,7 +347,13 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -363,77 +364,85 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
|
||||
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
|
||||
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
|
||||
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
|
||||
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
|
||||
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
|
||||
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
|
||||
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
|
||||
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
|
||||
})
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -584,18 +593,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
|
||||
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY=${{" + stripeLike + "}}",
|
||||
"TOKEN=${{real-secret-token-value}}",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
if count != 2 {
|
||||
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +648,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`proxy := "http://user:p%40ss@proxy:8080/path"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
@@ -813,151 +821,35 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("token field names alone should not produce findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
|
||||
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
|
||||
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
|
||||
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
|
||||
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
|
||||
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
|
||||
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
|
||||
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
|
||||
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "k1",`,
|
||||
`"secret_id": "s1",`,
|
||||
`"token_id": "t1",`,
|
||||
`"private_key_id": "pk1",`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "` + stripeLike + `",`,
|
||||
`"token_id": "` + githubToken + `",`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
if !credentialShapedValue(` "` + providerValue + `" `) {
|
||||
t.Fatal("space-padded quoted provider credential should be recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
}{
|
||||
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
|
||||
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
|
||||
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
|
||||
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
|
||||
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
|
||||
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
|
||||
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []string{
|
||||
"access_token: ghp%" + "5F" + providerBody,
|
||||
"access_token_hash: ghp%" + "255F" + providerBody,
|
||||
}
|
||||
for _, text := range tests {
|
||||
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"token_type: asian",
|
||||
"token_prefix: ASIA",
|
||||
"token_prefix: ghp_",
|
||||
"api_key: sk_live_example",
|
||||
"token_prefix: asianmarketsegment01",
|
||||
"token_prefix: ghp_placeholder_value",
|
||||
}, "\n")+"\n"))
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
|
||||
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
|
||||
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
|
||||
}, "\n")+"\n"))
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -1035,22 +927,6 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`const localOfficeTokenPrefix = "local_office_"`,
|
||||
`const imageLiveSecretMarker = "img_live_secret"`,
|
||||
`const imageProdKeyMarker = "img_prod_key"`,
|
||||
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
|
||||
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
@@ -1065,18 +941,22 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("partially masked values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
@@ -1092,7 +972,6 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
@@ -1101,47 +980,32 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"`,
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "typescript terminated secret",
|
||||
name: "typescript numeric password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `";`,
|
||||
},
|
||||
{
|
||||
name: "typescript secret with trailing comment",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"; // production`,
|
||||
},
|
||||
{
|
||||
name: "typescript asserted secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `" as const;`,
|
||||
},
|
||||
{
|
||||
name: "typescript provider password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "` + providerValue + `"`,
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "` + providerValue + `"`,
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "` + providerValue + `"`,
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -1154,154 +1018,24 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
|
||||
|
||||
got := ScanFile("fixtures/source_secret.ts", []byte(
|
||||
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
|
||||
))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
|
||||
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
|
||||
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
|
||||
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
|
||||
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
|
||||
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
|
||||
providerSecret := "sk_" + "live_1234567890abcdef"
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`clientSecret := "` + providerSecret + `"`,
|
||||
`accessToken := "` + providerToken + `"`,
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericCredentialDecisionMatrix(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
|
||||
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
comment bool
|
||||
want bool
|
||||
}{
|
||||
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
|
||||
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
|
||||
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
|
||||
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
|
||||
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
|
||||
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
|
||||
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
|
||||
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
|
||||
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
|
||||
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
|
||||
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
|
||||
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
|
||||
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
|
||||
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
|
||||
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
|
||||
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
|
||||
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
|
||||
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
|
||||
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
|
||||
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got []Finding
|
||||
if tt.comment {
|
||||
got = ScanComment("issue_comment", tt.text)
|
||||
} else {
|
||||
got = ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
}
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "human readable access token", value: "user-access-token", want: false},
|
||||
{name: "delimited secret value", value: "secret-value", want: false},
|
||||
{name: "underscored secret fixture", value: "plain_secret", want: false},
|
||||
{name: "short delimited fixture", value: "t-abc", want: false},
|
||||
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
|
||||
{name: "real labeled fixture", value: "real-token", want: false},
|
||||
{name: "ambiguous concrete word", value: "supersecret", want: false},
|
||||
{name: "provider token", value: providerToken, want: true},
|
||||
{name: "high entropy secret", value: highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
|
||||
`secret := "secret-value"`,
|
||||
`samplePassword := "sample-password"`,
|
||||
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1382,10 +1116,9 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"` + githubToken + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1419,10 +1152,9 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "` + githubToken + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1636,43 +1368,39 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: " + stripeLike + "_HERE",
|
||||
"api_key: YOUR_" + stripeLike,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
|
||||
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
|
||||
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"CLIENT_SECRET=%" + stripeLike + "%",
|
||||
"GITHUB_TOKEN=%" + patLike + "%",
|
||||
"TOKEN=%real-secret-token-value%",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
|
||||
t.Helper()
|
||||
got := ScanFile(file, []byte(text+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
|
||||
if count != 3 {
|
||||
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -203,8 +203,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -600,8 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
|
||||
cmd := gitcmd.Command(repo, commandArgs...)
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package gitcmd provides Git process helpers for tests that use temporary
|
||||
// repositories.
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
maintenanceAutoDetach = "maintenance.autoDetach"
|
||||
gcAutoDetach = "gc.autoDetach"
|
||||
)
|
||||
|
||||
// Command creates a Git command whose automatic maintenance stays in the
|
||||
// command lifecycle, so temporary repository cleanup cannot race a detached
|
||||
// maintenance process.
|
||||
func Command(dir string, args ...string) *exec.Cmd {
|
||||
commandArgs := make([]string, 0, len(args)+4)
|
||||
commandArgs = append(commandArgs,
|
||||
"-c", maintenanceAutoDetach+"=false",
|
||||
"-c", gcAutoDetach+"=false",
|
||||
)
|
||||
commandArgs = append(commandArgs, args...)
|
||||
cmd := exec.Command("git", commandArgs...)
|
||||
cmd.Dir = dir
|
||||
return cmd
|
||||
}
|
||||
|
||||
// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
|
||||
// Git process started by the current test, including processes created through
|
||||
// production command runners. Tests using it must not run in parallel.
|
||||
func SetSynchronousMaintenanceEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
count := 0
|
||||
if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 0 {
|
||||
t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
|
||||
}
|
||||
count = parsed
|
||||
}
|
||||
for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
|
||||
index := strconv.Itoa(count)
|
||||
t.Setenv("GIT_CONFIG_KEY_"+index, key)
|
||||
t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
|
||||
count++
|
||||
}
|
||||
t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandDisablesDetachedMaintenance(t *testing.T) {
|
||||
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
|
||||
cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config %s: %v\n%s", key, err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != "false" {
|
||||
t.Fatalf("%s = %q, want false", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSynchronousMaintenanceEnv(t *testing.T) {
|
||||
t.Setenv("GIT_CONFIG_COUNT", "1")
|
||||
t.Setenv("GIT_CONFIG_KEY_0", "user.name")
|
||||
t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
|
||||
SetSynchronousMaintenanceEnv(t)
|
||||
for key, want := range map[string]string{
|
||||
"user.name": "Existing Test User",
|
||||
maintenanceAutoDetach: "false",
|
||||
gcAutoDetach: "false",
|
||||
} {
|
||||
cmd := exec.Command("git", "config", "--get", "--type=bool", key)
|
||||
if key == "user.name" {
|
||||
cmd = exec.Command("git", "config", "--get", key)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config %s: %v\n%s", key, err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != want {
|
||||
t.Fatalf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// SafeTempAbsInputPath accepts an absolute read path only when it resolves
|
||||
// under the system temp dir. Delegates to localfileio.SafeTempAbsInputPath.
|
||||
func SafeTempAbsInputPath(path string) (string, error) {
|
||||
return localfileio.SafeTempAbsInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,10 +19,35 @@ func SafeOutputPath(path string) (string, error) {
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
|
||||
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
|
||||
// load-bearing invariant. The one deliberate exception is the @file payload
|
||||
// expansion, which layers SafeTempAbsInputPath on top (see cmdutil).
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// SafeTempAbsInputPath accepts an absolute READ path only when it resolves
|
||||
// under the canonical system temp dir. Agents stage generated payloads
|
||||
// (batch operations JSON, CSV) in /tmp as a matter of course, and rejecting
|
||||
// @/tmp/ops.json only pushed them through an extra python/stdin round trip
|
||||
// (recurring friction cluster in eval traces). Reads under os.TempDir()
|
||||
// carry no write risk and no project-escape risk. Errors for anything else
|
||||
// (relative paths included) — callers fall back to SafeInputPath semantics.
|
||||
func SafeTempAbsInputPath(path string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(path, "--file"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isAbsolutePath(path) {
|
||||
return "", fmt.Errorf("--file %q is not an absolute path", path)
|
||||
}
|
||||
resolved, ok := absPathUnderTempDir(path)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("--file must be a relative path within the current directory, or an absolute path under the system temp dir (%s), got %q (hint: use ./filename or a %s path; flags that support stdin can read any file via '-' instead)", os.TempDir(), path, os.TempDir())
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
@@ -96,6 +122,26 @@ func safePath(raw, flagName string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// absPathUnderTempDir accepts an absolute path only when, after cleaning and
|
||||
// resolving symlinks (through the nearest existing ancestor for
|
||||
// not-yet-created files), it still lives under the canonical system temp dir.
|
||||
// A symlink inside the temp dir pointing outside it resolves outside and is
|
||||
// rejected.
|
||||
func absPathUnderTempDir(raw string) (string, bool) {
|
||||
canonicalTmp, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
resolved, err := resolveNearestAncestor(filepath.Clean(raw))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !isUnderDir(resolved, canonicalTmp) || resolved == canonicalTmp {
|
||||
return "", false
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
@@ -185,15 +185,67 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
// WHEN: SafeInputPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
// THEN: the strict validator rejects it — uploads / drive sync rely on
|
||||
// relative-only; temp-dir reads go through SafeTempAbsInputPath instead
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeTempAbsInputPath(t *testing.T) {
|
||||
t.Run("accepts a file under the temp dir", func(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "payload-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
resolved, err := SafeTempAbsInputPath(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected temp path accepted, got %v", err)
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks: %v", err)
|
||||
}
|
||||
if resolved != canonical {
|
||||
t.Fatalf("resolved = %q, want %q", resolved, canonical)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects relative paths", func(t *testing.T) {
|
||||
if _, err := SafeTempAbsInputPath("./ops.json"); err == nil {
|
||||
t.Fatal("expected error for relative path, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects absolute paths outside the temp dir", func(t *testing.T) {
|
||||
if _, err := SafeTempAbsInputPath("/etc/passwd"); err == nil {
|
||||
t.Fatal("expected error for non-temp absolute path, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a temp-dir symlink escaping outside", func(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "escape-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
link := filepath.Join(dir, "escape.json")
|
||||
if err := os.Symlink("/etc/passwd", link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
if _, err := SafeTempAbsInputPath(link); err == nil {
|
||||
t.Fatal("expected error for symlink escaping the temp dir, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
|
||||
@@ -34,12 +34,7 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
|
||||
|
||||
func runGit(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
commandArgs := []string{
|
||||
"-c", "maintenance.autoDetach=false",
|
||||
"-c", "gc.autoDetach=false",
|
||||
}
|
||||
commandArgs = append(commandArgs, args...)
|
||||
cmd := exec.Command("git", commandArgs...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -48,14 +43,6 @@ func runGit(t *testing.T, root string, args ...string) string {
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func TestRunGitDisablesDetachedMaintenance(t *testing.T) {
|
||||
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
|
||||
if got := runGit(t, t.TempDir(), "config", "--get", "--type=bool", key); got != "false" {
|
||||
t.Fatalf("%s = %q, want false", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
|
||||
root := writeFixture(t, fixtureRepo{
|
||||
"errs/subtypes.go": `package errs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.73",
|
||||
"version": "1.0.72",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const automationSkillDoc = "../../skills/lark-apps/references/lark-apps-automation.md"
|
||||
const localDevSkillDoc = "../../skills/lark-apps/references/lark-apps-local-dev.md"
|
||||
const larkAppsSkillDoc = "../../skills/lark-apps/SKILL.md"
|
||||
const releaseGetSkillDoc = "../../skills/lark-apps/references/lark-apps-release-get.md"
|
||||
|
||||
func readAutomationSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, automationSkillDoc)
|
||||
}
|
||||
|
||||
func readLocalDevSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, localDevSkillDoc)
|
||||
}
|
||||
|
||||
func readReleaseGetSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, releaseGetSkillDoc)
|
||||
}
|
||||
|
||||
func readAppsSkillDoc(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read skill doc %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func skillSection(t *testing.T, doc, heading string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(doc, heading)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing skill section %q", heading)
|
||||
}
|
||||
rest := doc[start+len(heading):]
|
||||
if next := strings.Index(rest, "\n## "); next >= 0 {
|
||||
return rest[:next]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
func skillSubsection(t *testing.T, doc, heading string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(doc, heading)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing skill subsection %q", heading)
|
||||
}
|
||||
rest := doc[start+len(heading):]
|
||||
end := len(rest)
|
||||
for _, marker := range []string{"\n### ", "\n## "} {
|
||||
if next := strings.Index(rest, marker); next >= 0 && next < end {
|
||||
end = next
|
||||
}
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
func requireInOrder(t *testing.T, text string, tokens ...string) {
|
||||
t.Helper()
|
||||
offset := 0
|
||||
for _, token := range tokens {
|
||||
idx := strings.Index(text[offset:], token)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing %q after %q", token, text[:offset])
|
||||
}
|
||||
offset += idx + len(token)
|
||||
}
|
||||
}
|
||||
|
||||
func requireFirstOccurrencesInOrder(t *testing.T, text string, tokens ...string) {
|
||||
t.Helper()
|
||||
previous := -1
|
||||
for _, token := range tokens {
|
||||
idx := strings.Index(text, token)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing %q", token)
|
||||
}
|
||||
if idx <= previous {
|
||||
t.Fatalf("first %q at %d must follow the previous contract token at %d", token, idx, previous)
|
||||
}
|
||||
previous = idx
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_ChangedHandlerStartWaitsForThisRelease(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 实现或更新 handler 后发布并启动/测试")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler",
|
||||
"+automation-get",
|
||||
"记录发布前状态",
|
||||
"--name",
|
||||
"项目 guide",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证。",
|
||||
"在 Git 已确认/预授权时 commit,然后执行",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"确认 disabled",
|
||||
"+release-create --branch sprint/default",
|
||||
"data.release_id",
|
||||
"+release-get",
|
||||
"data.status=finished",
|
||||
"仅启动",
|
||||
"+automation-enable",
|
||||
"+automation-get",
|
||||
"不制造 runtime probe",
|
||||
"测试",
|
||||
"运行时验证的操作级授权",
|
||||
"完成全部 preflight",
|
||||
"才执行 `+automation-enable`",
|
||||
"真实 runtime",
|
||||
"仅要求测试",
|
||||
"恢复到发布前状态",
|
||||
)
|
||||
requireFirstOccurrencesInOrder(t, section,
|
||||
"+automation-get",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"+release-create --branch sprint/default",
|
||||
"data.status=finished",
|
||||
"仅启动",
|
||||
)
|
||||
for _, boundary := range []string{
|
||||
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler,且用户要求把这次代码发布后启动或测试时,才使用此路径。",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证。",
|
||||
"在 Git 已确认/预授权时 commit,然后执行 `git push origin sprint/default`。",
|
||||
"若该命令本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled 后停止;若因超时等导致结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
|
||||
"只有 `data.status=finished` 才能继续;`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟。",
|
||||
"确认 `failed` 时报告发布失败,原本 enabled 的 trigger 仅在确认新代码未上线后恢复 enabled,原本 disabled 的保持 disabled。",
|
||||
"发布状态仍不确定时不得进入 enable、probe 或状态恢复分支。",
|
||||
"**仅启动**:取得持续启动授权后执行 `+automation-enable`,并用 `+automation-get` 确认 enabled;到此结束,不制造 runtime probe。",
|
||||
"**测试(含“启动并测试”)**:先按下节“运行时验证的操作级授权”完成全部 preflight",
|
||||
"若用户仅要求测试而不是持续启动,只在本轮 release 已 `finished` 且 probe 成功后恢复到发布前状态",
|
||||
"无论用户是仅测试还是启动并测试,probe 失败、结果不确定或 enable 后提前结束时,一律 `+automation-disable` 并回读 disabled",
|
||||
"不得把“发布前 enabled”当作失败后的恢复依据",
|
||||
"没有通用的 `automation-debug` 或 trigger 日志 shortcut。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("complete-start section must explain %q boundary", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_BindsTheExactNameAsUser(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
for _, boundary := range []string{
|
||||
"全部操作需 `--as user`(AuthType: user)。",
|
||||
"当用户希望触发器实际执行业务代码时,先确认当前工作区是已初始化的应用项目,并读取其中与触发器任务匹配的 guide。",
|
||||
"`--name` 是应用内唯一的 trigger 定位键;代码侧绑定名称必须与它逐字相同。不得用 trigger ID 或方法名代替它。具体 handler 语法和接入方式以项目 guide 为准。",
|
||||
} {
|
||||
if !strings.Contains(doc, boundary) {
|
||||
t.Errorf("automation skill must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_RoutesAndDiagnosesUnfiredTriggers(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
|
||||
errorSection := skillSection(t, doc, "## 常见错误与决策场景")
|
||||
|
||||
if !strings.Contains(routeSection, "「触发器没反应 / enable 了不触发 / 为什么没执行 / 验证一下触发器」→ 先按「未触发时的诊断顺序」诊断;对 UPSERT 和 feishu-approval 仅验证配置边界,不承诺 handler 或 live 验证。") {
|
||||
t.Error("routing anchors must direct unfired triggers to the bounded diagnostic flow")
|
||||
}
|
||||
if !strings.Contains(errorSection, "已证实的 cron、webhook、record-change(INSERT/UPDATE/DELETE)按「未触发时的诊断顺序」排查;UPSERT 和 feishu-approval 仅核对配置边界,不承诺 handler 或 live 验证。") {
|
||||
t.Error("error table must preserve the bounded unfired-trigger diagnostic flow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_ConfigurationStopsDisabled(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅创建/配置触发器")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"用 `+automation-create` 创建,并省略 `--status` 或显式传 `disabled`,然后报告 name 和 disabled 状态。",
|
||||
"不要传 `--status enabled`,也不要写 handler、commit/push、release 或 enable;更不能把创建 API 成功称为“可运行”。",
|
||||
"默认 disabled 是这个意图的终点,不是稍后自动 enable 的待办。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("configuration-only section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_EnableExistingTriggerDoesNotPublish(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
section := skillSubsection(t, doc, "### 仅启用已有 disabled trigger")
|
||||
routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"用户只要求启用已存在且 disabled 的 trigger",
|
||||
"+automation-get",
|
||||
"+release-list --status finished --page-size 1",
|
||||
"已完成线上 release",
|
||||
"当前线上应用",
|
||||
"不能证明该 trigger name 已绑定 handler",
|
||||
"+automation-enable",
|
||||
"+automation-get",
|
||||
"不得修改 handler、commit/push 或 release",
|
||||
"对 UPSERT 或 feishu-approval 只改变配置状态",
|
||||
)
|
||||
if !strings.Contains(section, "未发布时不得自动创建 release,也不得声称 trigger 已开始实际运行") {
|
||||
t.Error("enable-only flow must distinguish configuration enablement from a published runtime")
|
||||
}
|
||||
if !strings.Contains(section, "即使存在 finished release,也只能把 enable 报告为配置激活") {
|
||||
t.Error("enable-only flow must not infer handler provenance from app release history")
|
||||
}
|
||||
if strings.Contains(section, "apps +get") || strings.Contains(section, "`is_published`") {
|
||||
t.Error("enable-only flow must use finished release history instead of an optional app detail field")
|
||||
}
|
||||
for _, forbidden := range []string{"git push", "+release-create"} {
|
||||
if strings.Contains(section, forbidden) {
|
||||
t.Errorf("enable-only flow must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(routeSection, "「启用 / 启动已有 trigger」→ 先核对现有状态;只启用时不要修改源码或发布应用。") {
|
||||
t.Error("routing anchors must keep existing-trigger enablement separate from code release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_TestExistingTriggerDoesNotPublish(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 测试已有线上 trigger(不改代码)")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"用户要求测试已经发布的 trigger",
|
||||
"+automation-get",
|
||||
"+release-list --status finished --page-size 1",
|
||||
"当前线上代码",
|
||||
"不得为测试自动修改源码、commit/push 或 release",
|
||||
"在任何临时 enable 之前完成",
|
||||
"测试请求已明确包含临时 enable,或另行取得 enable 授权",
|
||||
"运行时验证的操作级授权",
|
||||
"无论 probe 成功、失败、结果不确定,还是临时 enable 后提前结束或中断,最终都必须 `+automation-disable` 并回读 disabled",
|
||||
)
|
||||
for _, forbidden := range []string{"git push", "+release-create"} {
|
||||
if strings.Contains(section, forbidden) {
|
||||
t.Errorf("existing-trigger test flow must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_HandlerOnlyStopsBeforeRelease(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"创建或定位已明确 name 的 disabled trigger,读取项目 guide,按其要求实现同名业务 handler,完成本地验证。",
|
||||
"只在既有 Git 确认或预授权下 commit/push;停止在 `+release-create` 和 `+automation-enable` 之前。",
|
||||
"用户没有明确“发布好”时,先问,不能默认把完整应用上线。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("handler-only section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_HandlerOnlyExcludesUnverifiedRuntimeTypes(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
|
||||
|
||||
if !strings.Contains(section, "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。") {
|
||||
t.Error("handler-only flow must exclude UPSERT and feishu-approval without a verified runtime contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_PublishedHandlerStaysDisabled(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 把 handler 发布好,但先不要启动")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。",
|
||||
"先用 `+automation-get` 定位;不存在时用 `+automation-create` 创建同名 disabled trigger,再次回读确认。",
|
||||
"已存在时记录它是否 enabled。",
|
||||
"若 trigger 已 enabled,先说明发布前必须临时停用以及可能造成的运行中断,并取得这次临时停用授权;未获授权时停止在发布前。",
|
||||
"取得授权后,在发布前执行 `+automation-disable`,并再次用 `+automation-get` 确认 disabled。",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证后,commit、`git push origin sprint/default`。",
|
||||
"随后发布完整应用:",
|
||||
"若 `+release-create` 本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled,然后停止;若因超时等导致创建结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
|
||||
"取得 `data.release_id` 后,对**这一轮** ID 调用 `+release-get`:`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时且状态仍不确定时报告 `release_id` 和当前 status,并保持 disabled;只有 `data.status=finished` 才算完成。",
|
||||
"确认 `failed` 且新代码未上线时,原本 enabled 的 trigger 恢复 enabled 并回读,原本 disabled 的保持 disabled。",
|
||||
"release 是整个应用上线,可能影响既有线上功能;未获得启动或测试授权时,finished 后始终保持 disabled,不执行 `+automation-enable`。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("publish-without-start section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
requireFirstOccurrencesInOrder(t, section,
|
||||
"+automation-get",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"+release-create",
|
||||
)
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_UPSERTAndApprovalStayConfigurationOnly(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### UPSERT 与飞书审批边界")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"record-change 的 UPSERT 可创建 disabled 配置,但当前没有已证实的运行时代码契约;不得静默按 UPDATE 处理,也不得承诺 handler 或 live 验证。",
|
||||
"feishu-approval 可创建 disabled 配置,并读取或更新 `event_type`、对应 status 和可选 `approval_code`。",
|
||||
"当前没有已证实的运行时 handler 契约或实际投递验证;不要把 enable 或审批 API 成功称为业务代码已执行。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("UPSERT/approval boundary section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_RuntimeProbeRequiresOperationScope(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 运行时验证的操作级授权")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"启用 trigger 的授权不等于制造 runtime 事件的授权,测试授权也不等于任意数据库写入授权。",
|
||||
"record-change 在执行任何 DML 前,必须明确并取得覆盖以下作用域的授权",
|
||||
"环境、表、操作、精确测试记录或筛选条件、payload、预期结果和清理方式",
|
||||
"优先使用专用测试记录",
|
||||
"`DELETE`",
|
||||
"[lark-apps-db-execute.md](lark-apps-db-execute.md)",
|
||||
"先 `SELECT count(*)`、执行 `--dry-run`",
|
||||
"取得针对该删除目标的明确授权",
|
||||
"+automation-list --trigger-type record-change --all",
|
||||
"同一环境、表和操作可能命中的其他 enabled trigger",
|
||||
"聚合业务影响",
|
||||
"恢复 UPDATE 或清理 INSERT 也可能再次触发自动化",
|
||||
"缺少安全、已授权且可清理的事件入口时,记录 blocked",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("runtime probe section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_UsesResolvableSharedSkillLink(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
|
||||
if strings.Contains(doc, "](../lark-shared/SKILL.md)") {
|
||||
t.Error("automation reference must not resolve lark-shared inside the lark-apps directory")
|
||||
}
|
||||
if !strings.Contains(doc, "](../../lark-shared/SKILL.md)") {
|
||||
t.Error("automation reference must link to the sibling lark-shared skill")
|
||||
}
|
||||
sharedSkillDoc := filepath.Clean(filepath.Join(filepath.Dir(automationSkillDoc), "../../lark-shared/SKILL.md"))
|
||||
if _, err := os.Stat(sharedSkillDoc); err != nil {
|
||||
t.Fatalf("automation reference target %s must exist: %v", sharedSkillDoc, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSkillContract_AllSharedSkillLinksResolve(t *testing.T) {
|
||||
docs := []string{larkAppsSkillDoc}
|
||||
references, err := filepath.Glob("../../skills/lark-apps/references/*.md")
|
||||
if err != nil {
|
||||
t.Fatalf("glob lark-apps references: %v", err)
|
||||
}
|
||||
docs = append(docs, references...)
|
||||
sharedLink := regexp.MustCompile(`\]\(([^)]+lark-shared/SKILL\.md)\)`)
|
||||
|
||||
for _, docPath := range docs {
|
||||
doc := readAppsSkillDoc(t, docPath)
|
||||
for _, match := range sharedLink.FindAllStringSubmatch(doc, -1) {
|
||||
target := filepath.Clean(filepath.Join(filepath.Dir(docPath), match[1]))
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Errorf("%s shared-skill link %q resolves to missing target %s: %v", docPath, match[1], target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_UsesProjectGuideWithoutSyncInternals(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## Trigger guide 的项目边界")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"先查看工作区 `.agents/skills/`,读取与自动化任务匹配的 `trigger-guide`。",
|
||||
"文件缺失或不能覆盖当前任务时,报告项目缺少可用的领域 guide;不要在本 lark-cli reference 中猜测安装命令、版本或包内目录。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("trigger-guide boundary section must explain %q", boundary)
|
||||
}
|
||||
}
|
||||
for _, implementationShape := range []string{
|
||||
"npx ", "skills sync", "data.", "skills_", "_CACHE_DIR", "nestjs-",
|
||||
"@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
|
||||
} {
|
||||
if strings.Contains(section, implementationShape) {
|
||||
t.Errorf("local-dev skill must not expose project-sync implementation shape %q", implementationShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSkillContract_DoesNotExposeSteeringImplementation(t *testing.T) {
|
||||
for name, doc := range map[string]string{
|
||||
"automation": readAutomationSkillDoc(t),
|
||||
"local-dev": readLocalDevSkillDoc(t),
|
||||
} {
|
||||
for _, implementationShape := range []string{
|
||||
"npx ", "skills sync", "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
|
||||
} {
|
||||
if strings.Contains(doc, implementationShape) {
|
||||
t.Errorf("%s skill must not expose project-sync implementation shape %q", name, implementationShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_UsesEnvironmentAndDefersEnableToAutomationSOP(t *testing.T) {
|
||||
doc := readLocalDevSkillDoc(t)
|
||||
releaseSection := skillSection(t, doc, "## 改完代码后部署上线")
|
||||
for _, legacy := range []string{"--env dev", "--env online"} {
|
||||
if strings.Contains(doc, legacy) {
|
||||
t.Errorf("local-dev skill must not recommend legacy %q", legacy)
|
||||
}
|
||||
}
|
||||
for _, boundary := range []string{
|
||||
"`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时仍未完成时停止本轮轮询、报告 `release_id` 和当前 status。",
|
||||
"若本次改动包含自动化 handler,在执行本节通用 commit/push/release 序列前就转到 [automation SOP](lark-apps-automation.md) 的匹配路径,由该 SOP 负责完整的状态门禁、commit/push、release 和可选 enable/test;不要先按本节发布再补 trigger 状态检查。",
|
||||
"用户只要求启用已有 trigger 时,转到 [automation SOP 的「仅启用已有 disabled trigger」路径](lark-apps-automation.md#仅启用已有-disabled-trigger);不得因 enable 反向修改 handler、commit/push 或 release。",
|
||||
"使用 `--environment dev|online`,不要使用旧的 `--env`。只有确认应用已开启多环境时才引导 `--environment dev`;单环境应用省略 `--environment`(服务端选 online)或显式传 `--environment online`。",
|
||||
} {
|
||||
if !strings.Contains(doc, boundary) {
|
||||
t.Errorf("local-dev skill must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
routeIndex := strings.Index(releaseSection, "若本次改动包含自动化 handler")
|
||||
releaseIndex := strings.Index(releaseSection, "+release-create")
|
||||
if routeIndex < 0 || releaseIndex < 0 || routeIndex >= releaseIndex {
|
||||
t.Error("automation routing must appear before the generic release sequence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_DoesNotRequireOnlineURL(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
|
||||
|
||||
if strings.Contains(section, "`finished` 成功时该命令输出已含 `online_url`") {
|
||||
t.Error("release guidance must not claim every finished release includes online_url")
|
||||
}
|
||||
if !strings.Contains(section, "若返回 `online_url`,可直接使用;未返回时不要编造链接。") {
|
||||
t.Error("release guidance must explain that online_url is optional")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_TreatsErrorLogsAsOptional(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
|
||||
|
||||
if !strings.Contains(section, "`failed` 时若返回非空 `error_logs`,据此给出失败原因;否则只报告 `release_id` 和当前 status,不要编造原因") {
|
||||
t.Error("release guidance must not promise error_logs on every failed release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSkillContract_TreatsOptionalOutputAsOptional(t *testing.T) {
|
||||
releaseGet := readReleaseGetSkillDoc(t)
|
||||
for _, boundary := range []string{
|
||||
"`finished` 后才可能有 `online_url`。",
|
||||
"若输出含 `online_url`,直接读取它作为本轮发布的线上访问链接;未返回时只报告发布完成,不要编造链接。",
|
||||
"若输出含 `error_logs`(`step`/`error_log`),据此向用户转述关键失败步骤和可行动修复;未返回时不要编造失败原因。",
|
||||
} {
|
||||
if !strings.Contains(releaseGet, boundary) {
|
||||
t.Errorf("release-get skill must preserve optional-output boundary %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
)
|
||||
|
||||
func appsValidationError(format string, args ...any) *errs.ValidationError {
|
||||
@@ -73,3 +74,32 @@ func appsInputPathEntryError(path string, err error) error {
|
||||
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
|
||||
// endpoint: refines endpoint-scoped business codes, prefixes the message with
|
||||
// command context, and attaches endpoint-specific recovery hints. A
|
||||
// still-untyped error is lifted at the SDK boundary instead.
|
||||
func enrichHTMLPublishAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return client.WrapDoAPIError(err)
|
||||
}
|
||||
// The HTML publish business codes (90001/90002) are scoped to this
|
||||
// endpoint, not service-global, so their subtype classification lives
|
||||
// here instead of the global errclass code table. Only an
|
||||
// otherwise-unclassified API error is refined; a stronger upstream
|
||||
// classification is never overridden.
|
||||
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
}
|
||||
if p.Message != "" {
|
||||
p.Message = "html-publish failed: " + p.Message
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,3 +57,57 @@ func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
|
||||
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
|
||||
WithCode(errCodeBuildFailed).
|
||||
WithLogID("logid-build-failed")
|
||||
|
||||
got := enrichHTMLPublishAPIError(err)
|
||||
if got != err {
|
||||
t.Fatalf("typed error should be enriched in place")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, got)
|
||||
if problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.LogID != "logid-build-failed" {
|
||||
t.Fatalf("log_id = %q, want preserved", problem.LogID)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "html-publish failed") {
|
||||
t.Fatalf("message = %q, want html-publish context", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected known-code recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeRateLimit {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,10 @@ import (
|
||||
var AppsGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+get",
|
||||
Description: "Get a single app's detail by app ID or meta token (returns app_type, name, description, publish status, etc.)",
|
||||
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +get --app-id <app_id>",
|
||||
"Example: lark-cli apps +get --app-id <meta_token>",
|
||||
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
|
||||
"Tip: extract app type with --jq '.data.app.app_type'",
|
||||
},
|
||||
@@ -29,7 +28,7 @@ var AppsGet = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID or meta token", Required: true},
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -41,7 +40,7 @@ var AppsGet = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Get app detail (returns app_id, meta_token, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
@@ -55,9 +54,6 @@ var AppsGet = common.Shortcut{
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
|
||||
if mt, ok := app["meta_token"].(string); ok && mt != "" {
|
||||
fmt.Fprintf(w, "meta_token: %s\n", mt)
|
||||
}
|
||||
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
|
||||
fmt.Fprintf(w, "name: %v\n", app["name"])
|
||||
if desc, ok := app["description"].(string); ok && desc != "" {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -37,13 +38,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
if path == "" {
|
||||
return appsValidationParamError("--path", "--path is required")
|
||||
@@ -76,11 +73,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Pack tar.gz → GET pre_release for TOS upload URL → PUT tar.gz to TOS → POST release-create with tos_path; returns release_id")
|
||||
dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
PUT("<presigned_upload_url> (from pre_release response)").
|
||||
POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
|
||||
Body(map[string]string{"tos_path": "<from pre_release response>"})
|
||||
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
|
||||
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Set("content_type", "multipart/form-data")
|
||||
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
@@ -128,7 +123,16 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
|
||||
out, err := runHTMLPublishTOS(ctx, rctx, spec)
|
||||
appType := queryAppType(ctx, rctx, spec.AppID)
|
||||
|
||||
var out map[string]interface{}
|
||||
var err error
|
||||
if appType == "modern_html" {
|
||||
out, err = runHTMLPublishTOS(ctx, rctx, spec)
|
||||
} else {
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -260,7 +264,25 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
|
||||
return tarball, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the publish path: validate → tar.gz →
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
if resp.URL != "" {
|
||||
out["url"] = resp.URL
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
|
||||
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
|
||||
// tos_path for +release-create --tos-path.
|
||||
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -22,6 +23,20 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
resp *htmlPublishResponse
|
||||
err error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
f.calls = append(f.calls, appID)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
func writeAppsSampleSite(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -31,19 +46,71 @@ func writeAppsSampleSite(t *testing.T) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
func TestRunHTMLPublish_HappyPath(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out["url"] != "https://miaoda/app_x" {
|
||||
t.Fatalf("url=%v", out["url"])
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
|
||||
t.Fatalf("calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||||
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
|
||||
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
|
||||
// envelope 只含 url,未来若有人加 status / release_id 字段会被这个测试拦截。
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
|
||||
}
|
||||
if _, ok := out["url"]; !ok {
|
||||
t.Fatalf("envelope missing 'url': %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
wantErr := errors.New("server timeout")
|
||||
fake := &fakeAppsHTMLPublishClient{err: wantErr}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when path invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
|
||||
// 目录形态:缺 index.html 应该被拦
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
@@ -54,9 +121,13 @@ func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
// 目录含 index.html 应该正常走完
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
@@ -64,49 +135,57 @@ func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called when index.html present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
// 单文件形态:文件名不是 index.html 也要拦
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "foo.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
|
||||
if err == nil {
|
||||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||||
}
|
||||
requireAppsValidationProblem(t, err)
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
// 单文件形态:文件名恰好就是 index.html → 放行
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "index.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||||
if err != nil {
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called for single index.html")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
// 把上限调到 100 字节验证拦截,defer 恢复原值避免污染其它测试。
|
||||
orig := maxHTMLPublishTarballBytes
|
||||
maxHTMLPublishTarballBytes = 100
|
||||
defer func() { maxHTMLPublishTarballBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
// 写 index.html(满足新加的 index 校验)+ 大文件超 100 字节上限。
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
@@ -115,7 +194,8 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
@@ -126,6 +206,9 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when tarball oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
|
||||
@@ -181,17 +264,8 @@ func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
|
||||
t.Fatalf("dry-run missing pre_release endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "presigned_upload_url") {
|
||||
t.Fatalf("dry-run missing TOS PUT step: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
|
||||
t.Fatalf("dry-run missing release-create endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "tos_path") {
|
||||
t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "index.html") {
|
||||
t.Fatalf("dry-run missing file list: %s", got)
|
||||
@@ -426,7 +500,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
|
||||
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
@@ -434,6 +510,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
@@ -476,7 +555,8 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
@@ -487,9 +567,13 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
@@ -501,12 +585,12 @@ func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,18 +74,15 @@ type appTypePolicy struct {
|
||||
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
|
||||
// the non-empty (`app sync`) scaffold path.
|
||||
skipSkillsSync bool
|
||||
// skipAppSync skips `npx ... app sync` on the non-empty repo path.
|
||||
skipAppSync bool
|
||||
}
|
||||
|
||||
// appTypePolicies maps an app_type to its +init control strategy. Types absent
|
||||
// from the map get the zero-value policy (install runs, env is pulled, skills
|
||||
// are synced).
|
||||
var appTypePolicies = map[string]appTypePolicy{
|
||||
// modern_html / html are static HTML sites: no dependencies to install,
|
||||
// no startup env vars to pull, no steering skills to sync, and no app sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
// modern_html is a static HTML site: no dependencies to install, no startup
|
||||
// env vars to pull, and no steering skills to sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
@@ -125,13 +122,9 @@ var AppsInit = common.Shortcut{
|
||||
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
|
||||
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
|
||||
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
|
||||
@@ -341,19 +334,11 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
// each is not already resolvable from local/global/system config, so a
|
||||
// developer's existing identity is never overwritten. Each key is handled
|
||||
// independently (a machine with only user.name set still gets a default email).
|
||||
func ensureGitIdentity(ctx context.Context, dir, authorName, authorEmail string) error {
|
||||
name := strings.TrimSpace(authorName)
|
||||
if name == "" {
|
||||
name = defaultGitUserName
|
||||
}
|
||||
email := strings.TrimSpace(authorEmail)
|
||||
if email == "" {
|
||||
email = defaultGitUserEmail
|
||||
}
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", name); err != nil {
|
||||
func ensureGitIdentity(ctx context.Context, dir string) error {
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", email)
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
|
||||
}
|
||||
|
||||
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
|
||||
@@ -415,16 +400,13 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
policy := policyForAppType(appType)
|
||||
if !policy.skipAppSync {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !policy.skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
@@ -454,38 +436,26 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
return base
|
||||
}
|
||||
|
||||
// credentialInitResult holds the fields parsed from +git-credential-init output.
|
||||
type credentialInitResult struct {
|
||||
RepositoryURL string
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
}
|
||||
|
||||
// parseCredentialInitEnvelope extracts fields from a +git-credential-init JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"...","commit_author_name":"...","commit_author_email":"..."}}).
|
||||
func parseCredentialInitEnvelope(stdout string) (credentialInitResult, error) {
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
CommitAuthorName string `json:"commit_author_name"`
|
||||
CommitAuthorEmail string `json:"commit_author_email"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
}
|
||||
return credentialInitResult{
|
||||
RepositoryURL: env.Data.RepositoryURL,
|
||||
CommitAuthorName: env.Data.CommitAuthorName,
|
||||
CommitAuthorEmail: env.Data.CommitAuthorEmail,
|
||||
}, nil
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
|
||||
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
|
||||
@@ -557,10 +527,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType, err := queryAppType(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
policy := policyForAppType(appType)
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
@@ -628,16 +595,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
initLogf(rctx, "Issuing repository credentials for %s...", appID)
|
||||
cred, err := issueCredentials(ctx, rctx, appID)
|
||||
repoURL, err := issueCredentials(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
|
||||
if err := validateRepoURLScheme(repoURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
@@ -645,10 +612,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
// Ensure a committer identity exists before the scaffold commit. Uses the
|
||||
// author name/email from +git-credential-init when available; falls back
|
||||
// to lark-cli-bot defaults when the server does not provide them.
|
||||
if err := ensureGitIdentity(ctx, dir, cred.CommitAuthorName, cred.CommitAuthorEmail); err != nil {
|
||||
// Ensure a committer identity exists before the scaffold commit; only sets
|
||||
// repo-local defaults when none is configured (existing identity is kept).
|
||||
if err := ensureGitIdentity(ctx, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -677,7 +643,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(cred.RepositoryURL),
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
"branch": defaultInitBranch,
|
||||
"clone_path": dir,
|
||||
"scaffold": scaffold,
|
||||
@@ -755,10 +721,10 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
|
||||
|
||||
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
|
||||
// and returns the repo_url it reports. Forwards --as when set.
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return credentialInitResult{}, errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
}
|
||||
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
@@ -766,11 +732,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return credentialInitResult{}, appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
WithHint("ensure apps +git-credential-init is available and you are logged in").
|
||||
WithCause(err)
|
||||
}
|
||||
return parseCredentialInitEnvelope(stdout)
|
||||
return parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
|
||||
// commitAndPushIfDirty commits and pushes only when the working tree has
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -111,24 +110,18 @@ func TestDefaultCloneDir(t *testing.T) {
|
||||
// --- pure-function tests ---
|
||||
|
||||
func TestParseRepoURL(t *testing.T) {
|
||||
result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
|
||||
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RepositoryURL != "http://u:t@h/app_x.git" {
|
||||
t.Errorf("RepositoryURL got %q", result.RepositoryURL)
|
||||
}
|
||||
if result.CommitAuthorName != "Alice" {
|
||||
t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
|
||||
}
|
||||
if result.CommitAuthorEmail != "alice@example.com" {
|
||||
t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
|
||||
if url != "http://u:t@h/app_x.git" {
|
||||
t.Errorf("got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoURL_Errors(t *testing.T) {
|
||||
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
|
||||
if _, err := parseCredentialInitEnvelope(in); err == nil {
|
||||
if _, err := parseRepoURLFromEnvelope(in); err == nil {
|
||||
t.Errorf("expected error for %q", in)
|
||||
}
|
||||
}
|
||||
@@ -156,22 +149,6 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
|
||||
t.Cleanup(func() { initRunner = orig })
|
||||
}
|
||||
|
||||
func stubAppType(reg *httpmock.Registry, appID, appType string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/" + appID,
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"app_type": appType,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func credInitOK(repoURL string) fakeCallResult {
|
||||
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
|
||||
}
|
||||
@@ -336,8 +313,7 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||||
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -378,8 +354,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
@@ -448,8 +423,7 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
"git status": {}, // clean tree after scaffold -> no commit/push
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -498,8 +472,7 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -569,8 +542,7 @@ func TestAppsInit_CloneFailure(t *testing.T) {
|
||||
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -644,8 +616,7 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
|
||||
@@ -751,7 +722,7 @@ func TestIsEmptyRepo(t *testing.T) {
|
||||
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
|
||||
// the stderr buffer, so tests can assert on the +init progress log lines that
|
||||
// initLogf writes to IO().ErrOut.
|
||||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -761,12 +732,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr, reg
|
||||
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr
|
||||
}
|
||||
|
||||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
factory, stdout, _, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -795,8 +766,7 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
|
||||
stubAppType(reg2, "app_x", "FULL_STACK")
|
||||
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("run err=%v", err)
|
||||
@@ -859,8 +829,7 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
|
||||
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -901,8 +870,7 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A src/app.ts\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -922,8 +890,7 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -943,8 +910,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -963,7 +929,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||||
// gitMust runs a git command in dir with a real binary, failing the test on error.
|
||||
func gitMust(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(dir, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
|
||||
@@ -979,7 +946,6 @@ func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
gitcmd.SetSynchronousMaintenanceEnv(t)
|
||||
// Bare remote so `git push origin sprint/default` succeeds.
|
||||
remote := t.TempDir()
|
||||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||||
@@ -1101,7 +1067,6 @@ func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
gitcmd.SetSynchronousMaintenanceEnv(t)
|
||||
remote := t.TempDir()
|
||||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||||
|
||||
@@ -1324,8 +1289,7 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
|
||||
"env-pull": envPullOK("/abs/app_x/.env.local"),
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -1363,8 +1327,7 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||||
@@ -1403,8 +1366,7 @@ func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
|
||||
envFile := filepath.Join(abs, ".env.local")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -1451,8 +1413,7 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||||
}
|
||||
@@ -1744,15 +1705,13 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPolicyForAppType(t *testing.T) {
|
||||
// modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
|
||||
for _, at := range []string{"modern_html", "html"} {
|
||||
if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
|
||||
t.Errorf("%s policy = %+v, want all skip flags set", at, p)
|
||||
}
|
||||
// modern_html decouples all control points: skip install, env-pull, skills sync.
|
||||
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
|
||||
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
|
||||
}
|
||||
// Unlisted types (including "") get the zero-value policy: everything runs.
|
||||
for _, at := range []string{"full_stack", "", "backend"} {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
|
||||
t.Errorf("policy for %q = %+v, want zero value", at, p)
|
||||
}
|
||||
}
|
||||
@@ -1798,7 +1757,7 @@ func configSetValue(calls [][]string, key string) (string, bool) {
|
||||
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
|
||||
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
|
||||
@@ -1815,7 +1774,7 @@ func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
|
||||
"git config": {stdout: "Existing Dev\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := configSetValue(f.calls, "user.name"); ok {
|
||||
@@ -1831,7 +1790,7 @@ func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
|
||||
"git config": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
|
||||
t.Error("expected error when git config set fails")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,25 +13,22 @@ import (
|
||||
)
|
||||
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
|
||||
// an app_id or a meta_token — the server resolves both. The server returns
|
||||
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
|
||||
// this function normalizes to lowercase. Returns an error when the API
|
||||
// is unavailable or the response is malformed — callers must not proceed
|
||||
// with a fallback type to avoid creating the wrong project scaffold.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, identifier string) (string, error) {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(identifier))
|
||||
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
|
||||
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
|
||||
// to lowercase. Returns "" when the API is unavailable or returns an
|
||||
// error — callers fall back to legacy behavior.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app object")
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
|
||||
return ""
|
||||
}
|
||||
appType, _ := appRaw["app_type"].(string)
|
||||
if strings.TrimSpace(appType) == "" {
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
|
||||
}
|
||||
return strings.ToLower(appType), nil
|
||||
return strings.ToLower(appType)
|
||||
}
|
||||
|
||||
@@ -43,10 +43,7 @@ func TestQueryAppType_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_test")
|
||||
if result != "modern_html" {
|
||||
t.Errorf("queryAppType = %q, want modern_html", result)
|
||||
}
|
||||
@@ -68,10 +65,7 @@ func TestQueryAppType_FullStack(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_fs")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_fs")
|
||||
if result != "full_stack" {
|
||||
t.Errorf("queryAppType = %q, want full_stack", result)
|
||||
}
|
||||
@@ -93,10 +87,7 @@ func TestQueryAppType_Html(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_html")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_html")
|
||||
if result != "html" {
|
||||
t.Errorf("queryAppType = %q, want html", result)
|
||||
}
|
||||
@@ -111,9 +102,9 @@ func TestQueryAppType_APIError(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_bad")
|
||||
if err == nil {
|
||||
t.Error("expected error on API failure")
|
||||
result := queryAppType(context.Background(), rt, "app_bad")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty on error", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +119,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_no")
|
||||
if err == nil {
|
||||
t.Error("expected error when app object missing")
|
||||
result := queryAppType(context.Background(), rt, "app_no")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app object missing", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +141,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_empty")
|
||||
if err == nil {
|
||||
t.Error("expected error when app_type is empty")
|
||||
result := queryAppType(context.Background(), rt, "app_empty")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,9 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -30,13 +30,9 @@ var AppsReleaseGet = common.Shortcut{
|
||||
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return appsValidationParamError("--release-id", "--release-id is required")
|
||||
}
|
||||
|
||||
@@ -41,21 +41,6 @@ func withAppsHint(err error, hint string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
|
||||
// meta_token values are rejected with a hint to resolve via +get first.
|
||||
func validateRealAppID(appID string) error {
|
||||
if !strings.HasPrefix(appID, "app_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
`--app-id must be an app_id starting with "app_".`,
|
||||
).WithParam("--app-id").WithHint(
|
||||
`If you have a meta_token or a /page/<token>/ link, first resolve it:
|
||||
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
|
||||
Then retry this command with the returned app_id.`,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||||
// --output path. The authoritative guard is the local FileIO layer
|
||||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||||
|
||||
@@ -75,7 +75,6 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
"return commit_author_name and commit_author_email for repo-local git identity",
|
||||
}).
|
||||
Params(gitCredentialIssueParams(appID))
|
||||
},
|
||||
@@ -91,12 +90,6 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
"repository_url": result.GitHTTPURL,
|
||||
"status": initStatus(result),
|
||||
}
|
||||
if result.CommitAuthorName != "" {
|
||||
payload["commit_author_name"] = result.CommitAuthorName
|
||||
}
|
||||
if result.CommitAuthorEmail != "" {
|
||||
payload["commit_author_email"] = result.CommitAuthorEmail
|
||||
}
|
||||
if result.ConfigWarning != "" {
|
||||
payload["git_config_warning"] = result.ConfigWarning
|
||||
}
|
||||
@@ -468,13 +461,11 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
}
|
||||
}
|
||||
issued := &gitcred.IssuedCredential{
|
||||
AppID: firstString(source, "app_id", appID),
|
||||
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
|
||||
Username: firstString(source, "username"),
|
||||
PAT: firstString(source, "token", "Token", "pat", "password"),
|
||||
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
|
||||
CommitAuthorName: firstString(source, "commit_author_name"),
|
||||
CommitAuthorEmail: firstString(source, "commit_author_email"),
|
||||
AppID: firstString(source, "app_id", appID),
|
||||
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
|
||||
Username: firstString(source, "username"),
|
||||
PAT: firstString(source, "token", "Token", "pat", "password"),
|
||||
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
|
||||
}
|
||||
if issued.AppID == "" {
|
||||
issued.AppID = appID
|
||||
|
||||
@@ -87,7 +87,6 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
"return commit_author_name and commit_author_email for repo-local git identity",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -129,13 +129,7 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
|
||||
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
|
||||
_ = m.Secrets.Remove(previous.PATRef)
|
||||
}
|
||||
result := &InitResult{
|
||||
AppID: appID,
|
||||
GitHTTPURL: url,
|
||||
Refreshed: previous != nil,
|
||||
CommitAuthorName: issued.CommitAuthorName,
|
||||
CommitAuthorEmail: issued.CommitAuthorEmail,
|
||||
}
|
||||
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
|
||||
@@ -51,22 +51,18 @@ type CredentialRecord struct {
|
||||
}
|
||||
|
||||
type IssuedCredential struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
type InitResult struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
}
|
||||
|
||||
type RemoveResult struct {
|
||||
|
||||
73
shortcuts/apps/html_publish_client.go
Normal file
73
shortcuts/apps/html_publish_client.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type htmlPublishResponse struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type appsHTMLPublishClient interface {
|
||||
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
|
||||
}
|
||||
|
||||
type appsHTMLPublishAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddFile("file", bytes.NewReader(tarball.Body))
|
||||
|
||||
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
data, err := api.runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return nil, enrichHTMLPublishAPIError(err)
|
||||
}
|
||||
url, _ := data["url"].(string)
|
||||
if url == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"html-publish response is missing the published app url")
|
||||
}
|
||||
return &htmlPublishResponse{URL: url}, nil
|
||||
}
|
||||
|
||||
// OAPI business error codes returned by the
|
||||
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
|
||||
// service; update when new codes are documented in the OAPI spec.
|
||||
const (
|
||||
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
|
||||
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
|
||||
)
|
||||
|
||||
func buildHTMLPublishFailureHint(code int) string {
|
||||
switch code {
|
||||
case errCodeBuildFailed:
|
||||
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
|
||||
case errCodeAppNotFound:
|
||||
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
197
shortcuts/apps/html_publish_client_test.go
Normal file
197
shortcuts/apps/html_publish_client_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"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/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
|
||||
return rctx, reg
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"url": "https://miaoda.feishu.cn/app/app_x",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
|
||||
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
|
||||
t.Fatalf("url=%q", resp.URL)
|
||||
}
|
||||
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mt, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mt != "multipart/form-data" {
|
||||
t.Fatalf("content type %q wrong", ct)
|
||||
}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
saw := false
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if p.FormName() == "file" {
|
||||
saw = true
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatalf("multipart missing 'file' part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 90001,
|
||||
"msg": "build failed: dependency conflict",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint on code 90001")
|
||||
}
|
||||
if !strings.Contains(problem.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeAppNotFound,
|
||||
"msg": "app not found",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected app-not-found recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
|
||||
// 默认分支:未识别的 code 返回空 hint,让 Agent 用 message 兜底。
|
||||
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
|
||||
t.Fatalf("unknown code should return empty hint, got %q", hint)
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(0); hint != "" {
|
||||
t.Fatalf("zero code should return empty hint, got %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
|
||||
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
|
||||
t.Fatalf("code 90001 should return non-empty hint")
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
|
||||
hint := buildHTMLPublishFailureHint(90002)
|
||||
if hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
if strings.Contains(hint, "+list") {
|
||||
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "app_id") {
|
||||
t.Fatalf("hint should reference app_id, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
RawBody: []byte("{not json"),
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new base." {
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -469,6 +469,9 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
@@ -574,9 +577,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -585,9 +587,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -596,7 +597,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
d.
|
||||
POST("/open-apis/base/v3/bases").
|
||||
|
||||
@@ -1,790 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +update room-availability pre-check helpers.
|
||||
//
|
||||
// Uses /open-apis/calendar/v4/freebusy/room_availability_check to warn the
|
||||
// caller before an update either adds a new room attendee or shifts the time
|
||||
// of a slot that already has a room reservation. --skip-room-check bypasses
|
||||
// the check for callers that want to move fast.
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
flagSkipRoomCheck = "skip-room-check"
|
||||
roomCheckPath = "/open-apis/calendar/v4/freebusy/room_availability_check"
|
||||
)
|
||||
|
||||
// roomAvailability mirrors a single room result from the API.
|
||||
type roomAvailability struct {
|
||||
RoomID string `json:"room_id,omitempty"`
|
||||
RoomName string `json:"room_name,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
UnavailableReasonType string `json:"unavailable_reason_type,omitempty"`
|
||||
Strategy *roomStrategy `json:"room_strategy,omitempty"`
|
||||
Requisition *roomRequisition `json:"room_requisition,omitempty"`
|
||||
ApprovalInfo *roomApprovalInfo `json:"room_approval_info,omitempty"`
|
||||
}
|
||||
|
||||
// roomStrategy mirrors the room_strategy block returned by the API on
|
||||
// unavailable rooms. Every field is optional: the server only fills in the
|
||||
// entries relevant to the current unavailable_reason_type.
|
||||
type roomStrategy struct {
|
||||
SingleMaxDuration string `json:"single_max_duration,omitempty"`
|
||||
MaxAdvanceBookingTime string `json:"max_advance_booking_time,omitempty"`
|
||||
DailyStartTime string `json:"daily_start_time,omitempty"`
|
||||
DailyEndTime string `json:"daily_end_time,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
DailyAdvanceWindowReleaseTime string `json:"daily_advance_window_release_time,omitempty"`
|
||||
}
|
||||
|
||||
// roomRequisition mirrors room_requisition, returned by the API only when
|
||||
// unavailable_reason_type == "during_requisition". Both fields are RFC3339
|
||||
// strings and either may be empty if the server has no exact bound.
|
||||
type roomRequisition struct {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
// roomApprovalInfo mirrors room_approval_info, returned when the room requires
|
||||
// (or may require) an approval submission before it can be booked.
|
||||
//
|
||||
// - ApprovalMode: "none" (no approval), "over_duration" (only when the
|
||||
// booking exceeds the threshold), or "all" (every booking needs approval).
|
||||
// - ApprovalDurationThreshold: seconds; only meaningful when
|
||||
// ApprovalMode == "over_duration". The server returns it as a numeric
|
||||
// string, matching the shape of the other duration fields.
|
||||
//
|
||||
// When the pre-check returns status == "need_approval" the caller renders a
|
||||
// friendly reminder derived from these two fields plus the current event
|
||||
// duration, so the agent knows whether to switch rooms/times or route the
|
||||
// user through an approval flow.
|
||||
type roomApprovalInfo struct {
|
||||
ApprovalMode string `json:"approval_mode,omitempty"`
|
||||
ApprovalDurationThreshold string `json:"approval_duration_threshold,omitempty"`
|
||||
}
|
||||
|
||||
// eventSnapshot carries only the fields room-check needs from the current
|
||||
// event: existing room IDs, current start/end (unix seconds string), timezone,
|
||||
// and rrule.
|
||||
type eventSnapshot struct {
|
||||
RoomIDs []string
|
||||
StartTs string
|
||||
EndTs string
|
||||
Timezone string
|
||||
Recurrent string
|
||||
}
|
||||
|
||||
// unavailableReasonHint maps API-declared unavailable reasons to a short
|
||||
// English phrase suitable for embedding in the block message. Unknown or
|
||||
// future reasons fall back to a single stable phrase so the CLI's blocked
|
||||
// message stays predictable for agents that parse it.
|
||||
func unavailableReasonHint(reason string) string {
|
||||
switch reason {
|
||||
case "reserved_by_other_event":
|
||||
return "already reserved by another event"
|
||||
case "past_time":
|
||||
return "cannot book a room in the past"
|
||||
case "beyond_advance_booking_window":
|
||||
return "beyond the room's advance-booking window"
|
||||
case "over_max_duration":
|
||||
return "exceeds the room's max single-booking duration"
|
||||
case "not_in_usable_time":
|
||||
return "outside the room's daily bookable window"
|
||||
case "during_requisition":
|
||||
return "the room is disabled during this time and cannot be booked"
|
||||
case "before_daily_advance_window_release":
|
||||
return "the target date is outside the room's currently unlocked advance-booking window; the window extends by one calendar day at the daily release time"
|
||||
case "recurring_exceed_approval_limit":
|
||||
return "recurring event duration exceeds the limit for booking this approval-required room — shorten the duration or pick a different room"
|
||||
default:
|
||||
return "currently unbookable"
|
||||
}
|
||||
}
|
||||
|
||||
// strategyDetail renders the human-readable suffix appended to the reason
|
||||
// phrase for a given (reason, strategy) pair. It returns an empty string when
|
||||
// no strategy data is available or when the fields relevant to this reason
|
||||
// are missing / invalid, so callers can safely concatenate the result.
|
||||
func strategyDetail(reason string, s *roomStrategy) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
switch reason {
|
||||
case "over_max_duration":
|
||||
if d := formatDurationSeconds(s.SingleMaxDuration); d != "" {
|
||||
return "the max single-booking duration is " + d
|
||||
}
|
||||
case "beyond_advance_booking_window":
|
||||
// The API returns max_advance_booking_time as RFC3339 already;
|
||||
// surface it verbatim so agents don't lose the exact instant.
|
||||
if t := strings.TrimSpace(s.MaxAdvanceBookingTime); t != "" {
|
||||
return "the latest bookable end time is " + t
|
||||
}
|
||||
case "not_in_usable_time":
|
||||
start := formatDaySeconds(s.DailyStartTime)
|
||||
end := formatDaySeconds(s.DailyEndTime)
|
||||
zone := roomZoneLabel(s.Timezone)
|
||||
switch {
|
||||
case start != "" && end != "":
|
||||
return fmt.Sprintf("the daily bookable window is %s - %s (%s)", start, end, zone)
|
||||
case start != "":
|
||||
return fmt.Sprintf("the daily bookable window starts at %s (%s)", start, zone)
|
||||
case end != "":
|
||||
return fmt.Sprintf("the daily bookable window ends at %s (%s)", end, zone)
|
||||
}
|
||||
case "before_daily_advance_window_release":
|
||||
if t := formatDaySeconds(s.DailyAdvanceWindowReleaseTime); t != "" {
|
||||
return fmt.Sprintf("the next unlock happens today at %s (%s), which advances the window by one day", t, roomZoneLabel(s.Timezone))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requisitionDetail renders the suffix describing the room's scheduled
|
||||
// disable window for a `during_requisition` block. The API sends both bounds
|
||||
// as RFC3339 already, so we surface them verbatim to keep the exact instant.
|
||||
// Returns "" when both bounds are missing so the caller falls back to the
|
||||
// generic "pick a different time or a different room" recovery hint.
|
||||
func requisitionDetail(reason string, r *roomRequisition) string {
|
||||
if reason != "during_requisition" || r == nil {
|
||||
return ""
|
||||
}
|
||||
start := strings.TrimSpace(r.StartTime)
|
||||
end := strings.TrimSpace(r.EndTime)
|
||||
switch {
|
||||
case start != "" && end != "":
|
||||
return fmt.Sprintf("the disabled period is %s to %s", start, end)
|
||||
case start != "":
|
||||
return "the disabled period starts at " + start
|
||||
case end != "":
|
||||
return "the disabled period ends at " + end
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatDurationSeconds renders a whole-second string like "10800" as a
|
||||
// compact "H hours [M minutes]" phrase. Returns "" when the value is
|
||||
// missing, non-numeric, or non-positive.
|
||||
func formatDurationSeconds(raw string) string {
|
||||
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || sec <= 0 {
|
||||
return ""
|
||||
}
|
||||
d := time.Duration(sec) * time.Second
|
||||
h := int(d / time.Hour)
|
||||
m := int((d % time.Hour) / time.Minute)
|
||||
switch {
|
||||
case h > 0 && m > 0:
|
||||
return fmt.Sprintf("%d hours %d minutes", h, m)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d hours", h)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%d minutes", m)
|
||||
default:
|
||||
return fmt.Sprintf("%d seconds", sec)
|
||||
}
|
||||
}
|
||||
|
||||
// formatDaySeconds renders a "seconds since midnight" string as "HH:MM".
|
||||
// Returns "" when raw is missing, non-numeric, or outside [0, 24h). Seconds
|
||||
// are truncated because the API only guarantees minute-level meaning for
|
||||
// daily windows and release times.
|
||||
func formatDaySeconds(raw string) string {
|
||||
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || sec < 0 || sec >= 24*3600 {
|
||||
return ""
|
||||
}
|
||||
h := sec / 3600
|
||||
m := (sec % 3600) / 60
|
||||
return fmt.Sprintf("%02d:%02d", h, m)
|
||||
}
|
||||
|
||||
// roomZoneLabel renders the room's timezone as either a "GMT±X" string
|
||||
// anchored to today (so DST is respected) when the IANA name resolves, or
|
||||
// the IANA name itself as a fallback so agents always see the source of
|
||||
// truth. Returns the local device timezone's label when raw is empty.
|
||||
func roomZoneLabel(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return gmtOffsetLabel(time.Now())
|
||||
}
|
||||
loc, err := time.LoadLocation(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return gmtOffsetLabel(time.Now().In(loc))
|
||||
}
|
||||
|
||||
// gmtOffsetLabel formats t's zone offset as "GMT+8" / "GMT-5:30" / "GMT".
|
||||
// Minute-precision is included only when the offset has a non-zero minute
|
||||
// component so the common whole-hour case stays terse.
|
||||
func gmtOffsetLabel(t time.Time) string {
|
||||
_, offsetSec := t.Zone()
|
||||
if offsetSec == 0 {
|
||||
return "GMT"
|
||||
}
|
||||
sign := "+"
|
||||
if offsetSec < 0 {
|
||||
sign = "-"
|
||||
offsetSec = -offsetSec
|
||||
}
|
||||
h := offsetSec / 3600
|
||||
m := (offsetSec % 3600) / 60
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("GMT%s%d", sign, h)
|
||||
}
|
||||
return fmt.Sprintf("GMT%s%d:%02d", sign, h, m)
|
||||
}
|
||||
|
||||
// collectAttendeeRoomIDs extracts omm_ prefixed IDs from a comma-separated
|
||||
// flag value. Empty / whitespace input returns nil.
|
||||
func collectAttendeeRoomIDs(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var rooms []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id := strings.TrimSpace(part)
|
||||
if !strings.HasPrefix(id, "omm_") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
rooms = append(rooms, id)
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
|
||||
// fetchEventSnapshot GETs the event with attendees so we can read the current
|
||||
// start / end / recurrence and the room IDs already booked on the event. It is
|
||||
// best-effort: any error bubbles up so the caller can降级放行 by warning.
|
||||
//
|
||||
// One retry is baked in: a `{uid}_{original_time}` event_id refers to a
|
||||
// specific instance of a recurring series, but until that instance is edited
|
||||
// and materialised as an exception, the server only knows the master
|
||||
// (`{uid}_0`) and answers 193001 (event not found). We detect that shape and
|
||||
// re-issue the GET against the master so the room-check pipeline still has a
|
||||
// snapshot to work with.
|
||||
func fetchEventSnapshot(_ context.Context, runtime *common.RuntimeContext, calendarID, eventID string) (*eventSnapshot, error) {
|
||||
data, err := callEventGet(runtime, calendarID, eventID)
|
||||
if err != nil {
|
||||
if masterID, ok := recurringMasterEventID(eventID); ok && isEventNotFound(err) {
|
||||
data, err = callEventGet(runtime, calendarID, masterID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
event, _ := data["event"].(map[string]interface{})
|
||||
if event == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||
}
|
||||
snap := &eventSnapshot{}
|
||||
if start, _ := event["start_time"].(map[string]interface{}); start != nil {
|
||||
if ts, _ := start["timestamp"].(string); ts != "" {
|
||||
snap.StartTs = ts
|
||||
}
|
||||
if tz, _ := start["timezone"].(string); tz != "" {
|
||||
snap.Timezone = tz
|
||||
}
|
||||
}
|
||||
if end, _ := event["end_time"].(map[string]interface{}); end != nil {
|
||||
if ts, _ := end["timestamp"].(string); ts != "" {
|
||||
snap.EndTs = ts
|
||||
}
|
||||
if snap.Timezone == "" {
|
||||
if tz, _ := end["timezone"].(string); tz != "" {
|
||||
snap.Timezone = tz
|
||||
}
|
||||
}
|
||||
}
|
||||
if r, _ := event["recurrence"].(string); r != "" {
|
||||
snap.Recurrent = r
|
||||
}
|
||||
attendees, _ := event["attendees"].([]interface{})
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range attendees {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t, _ := m["type"].(string); t != "resource" {
|
||||
continue
|
||||
}
|
||||
id, _ := m["room_id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if status, _ := m["rsvp_status"].(string); status == "removed" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
snap.RoomIDs = append(snap.RoomIDs, id)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// callEventGet issues the calendar event GET used by fetchEventSnapshot. It
|
||||
// is factored out so the 193001 fallback can re-issue the request against
|
||||
// the master event without duplicating the params / path plumbing.
|
||||
func callEventGet(runtime *common.RuntimeContext, calendarID, eventID string) (map[string]interface{}, error) {
|
||||
path := fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||
validate.EncodePathSegment(calendarID), validate.EncodePathSegment(eventID))
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"need_attendee": true,
|
||||
"max_attendee_num": 20,
|
||||
}
|
||||
return runtime.CallAPITyped("GET", path, params, nil)
|
||||
}
|
||||
|
||||
// recurringMasterEventID inspects a calendar event_id shaped like
|
||||
// `{uid}_{original_time}` and returns `{uid}_0` when original_time is a
|
||||
// positive integer, plus true so callers know a fallback is worth trying.
|
||||
// Any other shape (missing underscore, non-numeric suffix, already `_0`, or
|
||||
// suffix `0` / negative) returns "", false so we don't retry pointlessly.
|
||||
func recurringMasterEventID(eventID string) (string, bool) {
|
||||
idx := strings.LastIndex(eventID, "_")
|
||||
if idx <= 0 || idx == len(eventID)-1 {
|
||||
return "", false
|
||||
}
|
||||
uid := eventID[:idx]
|
||||
suffix := eventID[idx+1:]
|
||||
n, err := strconv.ParseInt(suffix, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return uid + "_0", true
|
||||
}
|
||||
|
||||
// isEventNotFound returns true when err is a calendar 193001 (event not
|
||||
// found) API error. Kept in this file rather than shared with
|
||||
// unwrapCalendarAPIError because that helper returns a user-facing hint —
|
||||
// here we only need the classification, not the copy.
|
||||
func isEventNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
return false
|
||||
}
|
||||
return ae.Code == 193001
|
||||
}
|
||||
|
||||
// roomCheckPlan bundles the resolved inputs for the pre-check API call.
|
||||
type roomCheckPlan struct {
|
||||
RoomIDs []string
|
||||
StartTs string
|
||||
EndTs string
|
||||
StartTimezone string
|
||||
Rrule string
|
||||
}
|
||||
|
||||
// resolveRoomCheckPlan works out which rooms to check and the target time
|
||||
// window. It applies the降级放行 policy: if the event snapshot fails to load
|
||||
// but we can proceed with only user-provided inputs (i.e., time changed and a
|
||||
// new room is added), the pre-check still runs against those. Otherwise it
|
||||
// warns and returns (nil, nil) so the caller skips the check.
|
||||
//
|
||||
// Returns (nil, nil) when no check is warranted.
|
||||
func resolveRoomCheckPlan(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, newStartTs, newEndTs string, timeChanged, rruleChanged bool) (*roomCheckPlan, error) {
|
||||
newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
|
||||
removeSet := map[string]struct{}{}
|
||||
for _, id := range collectAttendeeRoomIDs(runtime.Str("remove-attendee-ids")) {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Fast path: only trigger the check when it can find something to look at.
|
||||
// - New room attendees → always check.
|
||||
// - Time or rrule change → check existing rooms if any.
|
||||
if len(newRooms) == 0 && !timeChanged && !rruleChanged {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newRrule := strings.TrimSpace(runtime.Str("rrule"))
|
||||
|
||||
// If we don't need existing rooms and have both start/end, skip the GET.
|
||||
needSnapshot := timeChanged || rruleChanged || !timeChanged && len(newRooms) > 0
|
||||
|
||||
var snap *eventSnapshot
|
||||
if needSnapshot {
|
||||
var err error
|
||||
snap, err = fetchEventSnapshot(ctx, runtime, calendarID, eventID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +update] warning: failed to fetch current event for room-availability check (%v); precheck runs only against user-supplied inputs — pass --%s to silence\n",
|
||||
err, flagSkipRoomCheck)
|
||||
snap = nil
|
||||
}
|
||||
}
|
||||
|
||||
plan := &roomCheckPlan{
|
||||
StartTs: newStartTs,
|
||||
EndTs: newEndTs,
|
||||
Rrule: newRrule,
|
||||
}
|
||||
if plan.StartTs == "" && snap != nil {
|
||||
plan.StartTs = snap.StartTs
|
||||
}
|
||||
if plan.EndTs == "" && snap != nil {
|
||||
plan.EndTs = snap.EndTs
|
||||
}
|
||||
if plan.Rrule == "" && snap != nil {
|
||||
plan.Rrule = snap.Recurrent
|
||||
}
|
||||
if snap != nil {
|
||||
plan.StartTimezone = snap.Timezone
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
addRoom := func(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
plan.RoomIDs = append(plan.RoomIDs, id)
|
||||
}
|
||||
for _, id := range newRooms {
|
||||
addRoom(id)
|
||||
}
|
||||
if snap != nil && (timeChanged || rruleChanged) {
|
||||
for _, id := range snap.RoomIDs {
|
||||
addRoom(id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(plan.RoomIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Without a target window the server has no basis to check anything;
|
||||
// prefer degrading gracefully to blocking legitimate updates.
|
||||
if plan.StartTs == "" || plan.EndTs == "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +update] warning: room-availability check skipped because start/end could not be resolved; pass --%s to silence\n",
|
||||
flagSkipRoomCheck)
|
||||
return nil, nil
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// roomCheckPlanDurationSec returns the current booking duration in whole
|
||||
// seconds derived from the resolved plan's Unix-second window, or 0 when
|
||||
// either bound is missing or unparseable. Used to compare against
|
||||
// approval_duration_threshold when the API asks for approval.
|
||||
func roomCheckPlanDurationSec(plan *roomCheckPlan) int64 {
|
||||
if plan == nil {
|
||||
return 0
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimSpace(plan.StartTs), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
end, err := strconv.ParseInt(strings.TrimSpace(plan.EndTs), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if end <= start {
|
||||
return 0
|
||||
}
|
||||
return end - start
|
||||
}
|
||||
|
||||
// buildRoomCheckBody assembles the request body for room_availability_check.
|
||||
// The pre-check API expects start/end as RFC3339 timestamps; we take the
|
||||
// Unix-second strings used elsewhere in the update flow and render them in
|
||||
// the event's own timezone when available, falling back to the local device
|
||||
// timezone so agents on different machines still produce a valid request.
|
||||
// start_timezone is an IANA name (e.g. "Asia/Shanghai") copied from the event
|
||||
// snapshot; it is omitted when unknown so the server can fall back to its own
|
||||
// default.
|
||||
func buildRoomCheckBody(calendarID, eventID string, plan *roomCheckPlan) map[string]interface{} {
|
||||
loc := time.Local
|
||||
if plan.StartTimezone != "" {
|
||||
if l, err := time.LoadLocation(plan.StartTimezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
"start_time": formatRoomCheckTime(plan.StartTs, loc),
|
||||
"end_time": formatRoomCheckTime(plan.EndTs, loc),
|
||||
"room_ids": plan.RoomIDs,
|
||||
}
|
||||
if plan.StartTimezone != "" {
|
||||
body["start_timezone"] = plan.StartTimezone
|
||||
}
|
||||
if plan.Rrule != "" {
|
||||
body["event_rrule"] = plan.Rrule
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// formatRoomCheckTime renders a Unix-second string as RFC3339 in loc.
|
||||
// Non-numeric input is returned unchanged so anomalies stay visible instead
|
||||
// of being silently rewritten to the epoch.
|
||||
func formatRoomCheckTime(unixStr string, loc *time.Location) string {
|
||||
sec, err := strconv.ParseInt(strings.TrimSpace(unixStr), 10, 64)
|
||||
if err != nil {
|
||||
return unixStr
|
||||
}
|
||||
return time.Unix(sec, 0).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// callRoomAvailabilityCheck posts the availability request and returns per-room
|
||||
// results.
|
||||
func callRoomAvailabilityCheck(runtime *common.RuntimeContext, body map[string]interface{}) ([]roomAvailability, error) {
|
||||
data, err := runtime.CallAPITyped("POST", roomCheckPath, nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawList, _ := data["room_availabilitys"].([]interface{})
|
||||
out := make([]roomAvailability, 0, len(rawList))
|
||||
for _, raw := range rawList {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := roomAvailability{}
|
||||
if v, ok := m["room_id"].(string); ok {
|
||||
item.RoomID = v
|
||||
}
|
||||
if v, ok := m["room_name"].(string); ok {
|
||||
item.RoomName = v
|
||||
}
|
||||
if v, ok := m["status"].(string); ok {
|
||||
item.Status = v
|
||||
}
|
||||
if v, ok := m["unavailable_reason_type"].(string); ok {
|
||||
item.UnavailableReasonType = v
|
||||
}
|
||||
if strat, ok := m["room_strategy"].(map[string]interface{}); ok {
|
||||
item.Strategy = parseRoomStrategy(strat)
|
||||
}
|
||||
if req, ok := m["room_requisition"].(map[string]interface{}); ok {
|
||||
item.Requisition = parseRoomRequisition(req)
|
||||
}
|
||||
if info, ok := m["room_approval_info"].(map[string]interface{}); ok {
|
||||
item.ApprovalInfo = parseRoomApprovalInfo(info)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseRoomStrategy extracts the optional strategy fields from a raw API
|
||||
// map. Missing / non-string values are dropped so callers only see what the
|
||||
// server actually sent.
|
||||
func parseRoomStrategy(m map[string]interface{}) *roomStrategy {
|
||||
s := &roomStrategy{}
|
||||
if v, ok := m["single_max_duration"].(string); ok {
|
||||
s.SingleMaxDuration = v
|
||||
}
|
||||
if v, ok := m["max_advance_booking_time"].(string); ok {
|
||||
s.MaxAdvanceBookingTime = v
|
||||
}
|
||||
if v, ok := m["daily_start_time"].(string); ok {
|
||||
s.DailyStartTime = v
|
||||
}
|
||||
if v, ok := m["daily_end_time"].(string); ok {
|
||||
s.DailyEndTime = v
|
||||
}
|
||||
if v, ok := m["timezone"].(string); ok {
|
||||
s.Timezone = v
|
||||
}
|
||||
if v, ok := m["daily_advance_window_release_time"].(string); ok {
|
||||
s.DailyAdvanceWindowReleaseTime = v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseRoomRequisition extracts the optional room_requisition block from a
|
||||
// raw API map. Missing / non-string values are dropped.
|
||||
func parseRoomRequisition(m map[string]interface{}) *roomRequisition {
|
||||
r := &roomRequisition{}
|
||||
if v, ok := m["start_time"].(string); ok {
|
||||
r.StartTime = v
|
||||
}
|
||||
if v, ok := m["end_time"].(string); ok {
|
||||
r.EndTime = v
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// parseRoomApprovalInfo extracts the optional room_approval_info block from a
|
||||
// raw API map. Missing / non-string values are dropped.
|
||||
func parseRoomApprovalInfo(m map[string]interface{}) *roomApprovalInfo {
|
||||
info := &roomApprovalInfo{}
|
||||
if v, ok := m["approval_mode"].(string); ok {
|
||||
info.ApprovalMode = v
|
||||
}
|
||||
if v, ok := m["approval_duration_threshold"].(string); ok {
|
||||
info.ApprovalDurationThreshold = v
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// approvalReasonHint composes the per-line phrase for a `need_approval`
|
||||
// status. The API returns `room_approval_info` with:
|
||||
//
|
||||
// - "all" → every reservation on this room must be approved.
|
||||
// - "over_duration" → only bookings longer than approval_duration_threshold
|
||||
// need approval. The current event duration (eventDurationSec) is compared
|
||||
// against the threshold so agents can see exactly why approval is being
|
||||
// asked for — and, when the current duration is below the threshold, the
|
||||
// message points at the "shorten it" recovery path.
|
||||
// - anything else → generic reminder so unknown modes still surface.
|
||||
//
|
||||
// This function only produces the per-room fragment. The shared recovery
|
||||
// clause (attendees-create, client fallback, shorten, pick another room) is
|
||||
// appended once by blockOnUnavailableRooms into `.WithHint(...)` so a message
|
||||
// with several approval-required rooms doesn't repeat the same recovery
|
||||
// paragraph on every line.
|
||||
func approvalReasonHint(info *roomApprovalInfo, eventDurationSec int64) string {
|
||||
mode := ""
|
||||
if info != nil {
|
||||
mode = strings.TrimSpace(info.ApprovalMode)
|
||||
}
|
||||
switch mode {
|
||||
case "all":
|
||||
return "this room requires approval for every reservation"
|
||||
case "over_duration":
|
||||
threshold, _ := strconv.ParseInt(strings.TrimSpace(info.ApprovalDurationThreshold), 10, 64)
|
||||
if threshold <= 0 {
|
||||
// Server said approval-by-duration but didn't give a threshold —
|
||||
// keep the mode label so agents don't lose the classification.
|
||||
return "this room requires approval when the booking exceeds a duration threshold"
|
||||
}
|
||||
thresholdPhrase := formatDurationSeconds(info.ApprovalDurationThreshold)
|
||||
if thresholdPhrase == "" {
|
||||
thresholdPhrase = fmt.Sprintf("%d seconds", threshold)
|
||||
}
|
||||
base := fmt.Sprintf("this room requires approval when the booking exceeds %s", thresholdPhrase)
|
||||
if eventDurationSec > 0 {
|
||||
currentPhrase := formatDurationSeconds(strconv.FormatInt(eventDurationSec, 10))
|
||||
if currentPhrase == "" {
|
||||
currentPhrase = fmt.Sprintf("%d seconds", eventDurationSec)
|
||||
}
|
||||
if eventDurationSec >= threshold {
|
||||
base += fmt.Sprintf(" (current duration is %s)", currentPhrase)
|
||||
} else {
|
||||
// Server flagged approval but our duration reads as below the
|
||||
// threshold — surface both so the agent can reconcile rather
|
||||
// than guess.
|
||||
base += fmt.Sprintf(" (current duration reads as %s; server still flagged approval)", currentPhrase)
|
||||
}
|
||||
}
|
||||
return base
|
||||
default:
|
||||
return "this room requires approval before it can be booked"
|
||||
}
|
||||
}
|
||||
|
||||
// roomLabel renders the room identifier for the block message. When the API
|
||||
// returns a human-readable name it becomes `<room_id>[<room_name>]`; a blank
|
||||
// name (or an entirely blank id, defensive) degrades to whichever is present
|
||||
// so agents can still address the room. The room_id is kept as the primary
|
||||
// identifier because callers act on it programmatically. Square brackets are
|
||||
// used (rather than parentheses) so a room name that itself contains
|
||||
// parentheses — e.g. "Room A (west wing)" — doesn't produce ambiguous nesting
|
||||
// like `omm_1(Room A (west wing))`.
|
||||
func roomLabel(id, name string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
name = strings.TrimSpace(name)
|
||||
switch {
|
||||
case id != "" && name != "":
|
||||
return fmt.Sprintf("%s[%s]", id, name)
|
||||
case id != "":
|
||||
return id
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// blockOnUnavailableRooms returns a typed validation error when any room in
|
||||
// results is unavailable or requires approval, or nil when everything is
|
||||
// bookable. The error text carries per-room reasons plus the retry command
|
||||
// hint from the PRD. When the API returns a room_strategy for a blocked room,
|
||||
// the relevant limit (max duration, latest bookable time, daily window, or
|
||||
// daily release time) is appended after the reason so agents can relay it to
|
||||
// the user without making a follow-up request. For a `during_requisition`
|
||||
// block, the disabled period (from room_requisition) is appended if available;
|
||||
// a "pick a different time or a different room" recovery clause is always
|
||||
// appended so the message reads coherently whether or not exact bounds are
|
||||
// known.
|
||||
//
|
||||
// `need_approval` results are treated as blocking (the CLI cannot submit an
|
||||
// approval on the user's behalf, so silently PATCHing would surprise the
|
||||
// user). The line uses room_approval_info + eventDurationSec to explain the
|
||||
// mode ("all" / "over_duration"), the threshold, and — for over_duration —
|
||||
// how the current booking compares. The shared "how do I actually recover
|
||||
// from approval" clause is folded into the hint once (not per line), so
|
||||
// several approval-required rooms don't repeat the same paragraph.
|
||||
func blockOnUnavailableRooms(results []roomAvailability, eventDurationSec int64) error {
|
||||
var blocked []roomAvailability
|
||||
for _, r := range results {
|
||||
if r.Status != "available" {
|
||||
blocked = append(blocked, r)
|
||||
}
|
||||
}
|
||||
if len(blocked) == 0 {
|
||||
return nil
|
||||
}
|
||||
var lines []string
|
||||
hasNeedApproval := false
|
||||
for _, r := range blocked {
|
||||
var reason string
|
||||
switch r.Status {
|
||||
case "need_approval":
|
||||
hasNeedApproval = true
|
||||
reason = approvalReasonHint(r.ApprovalInfo, eventDurationSec)
|
||||
default:
|
||||
reason = unavailableReasonHint(r.UnavailableReasonType)
|
||||
}
|
||||
line := fmt.Sprintf("%s: %s", roomLabel(r.RoomID, r.RoomName), reason)
|
||||
if detail := strategyDetail(r.UnavailableReasonType, r.Strategy); detail != "" {
|
||||
line += ", " + detail
|
||||
}
|
||||
if detail := requisitionDetail(r.UnavailableReasonType, r.Requisition); detail != "" {
|
||||
line += ", " + detail
|
||||
}
|
||||
if r.UnavailableReasonType == "during_requisition" {
|
||||
line += "; pick a different time or a different room"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
msg := "meeting room booking will fail after this event change:\n " + strings.Join(lines, "\n ")
|
||||
hint := fmt.Sprintf("do NOT auto-retry: relay the room IDs and reasons above to the user and get explicit confirmation before re-running with --%s.",
|
||||
flagSkipRoomCheck)
|
||||
if hasNeedApproval {
|
||||
hint += " Rooms flagged need_approval: the CLI cannot submit approvals; DO NOT auto-run any recovery — ask the user first, then pick one: (a) newly added room → after the user confirms and provides `approval_reason`, run `lark-cli calendar event.attendees create --as user`; (b) time/rrule change re-triggers approval on an existing room → ask the user to update through the client; (c) shorten the meeting below the threshold or pick a different room."
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", msg).WithHint("%s", hint)
|
||||
}
|
||||
@@ -3368,952 +3368,3 @@ func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarUpdate room-availability precheck tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eventSnapshotStub builds a GET-event fixture with the given rooms + window
|
||||
// so room-check helpers can read a plausible snapshot.
|
||||
func eventSnapshotStub(calendarID, eventID, startTs, endTs string, roomIDs ...string) *httpmock.Stub {
|
||||
attendees := make([]interface{}, 0, len(roomIDs))
|
||||
for _, id := range roomIDs {
|
||||
attendees = append(attendees, map[string]interface{}{
|
||||
"type": "resource",
|
||||
"room_id": id,
|
||||
})
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/" + calendarID + "/events/" + eventID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": eventID,
|
||||
"summary": "Existing",
|
||||
"start_time": map[string]interface{}{"timestamp": startTs, "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": endTs, "timezone": "Asia/Shanghai"},
|
||||
"attendees": attendees,
|
||||
},
|
||||
},
|
||||
},
|
||||
Reusable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_SkipFlag_BypassesAPI(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// Register the PATCH stub but no room-check stub — the test asserts that no
|
||||
// unmatched request is made.
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc1"}},
|
||||
},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc1",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--summary", "Skip",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--skip-room-check",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patchStub.CapturedBody) == 0 {
|
||||
t.Fatalf("expected PATCH to be captured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_TitleOnly_SkipsCheck(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// Only registered PATCH; title-only changes should never trigger room-check
|
||||
// and never fetch the event snapshot.
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc2"}},
|
||||
},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc2",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--summary", "New title only",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patchStub.CapturedBody) == 0 {
|
||||
t.Fatalf("expected PATCH to be captured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_NewRoomAvailable_Allows(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// Snapshot has no existing rooms; we're adding omm_new.
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc3", "1742515200", "1742518800"))
|
||||
|
||||
checkStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{"room_id": "omm_new", "status": "available"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(checkStub)
|
||||
|
||||
addStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc3/attendees",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(addStub)
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc3",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_new",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(checkStub.CapturedBody) == 0 {
|
||||
t.Fatalf("expected room-availability-check to be called")
|
||||
}
|
||||
body := decodeCalendarCapturedBody(t, checkStub)
|
||||
rooms, _ := body["room_ids"].([]interface{})
|
||||
if len(rooms) != 1 || rooms[0] != "omm_new" {
|
||||
t.Fatalf("room_ids should be [omm_new], got %#v", rooms)
|
||||
}
|
||||
if body["calendar_id"] != "cal_rc" || body["event_id"] != "evt_rc3" {
|
||||
t.Fatalf("room-check body missing ids: %#v", body)
|
||||
}
|
||||
if body["start_timezone"] != "Asia/Shanghai" {
|
||||
t.Fatalf("start_timezone should carry snapshot value, got %#v", body["start_timezone"])
|
||||
}
|
||||
if body["start_time"] != "2025-03-21T08:00:00+08:00" {
|
||||
t.Fatalf("start_time should be RFC3339 in event tz, got %#v", body["start_time"])
|
||||
}
|
||||
if body["end_time"] != "2025-03-21T09:00:00+08:00" {
|
||||
t.Fatalf("end_time should be RFC3339 in event tz, got %#v", body["end_time"])
|
||||
}
|
||||
if len(addStub.CapturedBody) == 0 {
|
||||
t.Fatalf("expected add-attendees POST to run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_NewRoomUnavailable_Blocks(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc4", "1742515200", "1742518800"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_busy",
|
||||
"status": "unavailable",
|
||||
"unavailable_reason_type": "reserved_by_other_event",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc4",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_busy",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected block error when room is unavailable")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype=%q, want failed_precondition", ve.Subtype)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "omm_busy") {
|
||||
t.Errorf("message should list blocked room id, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "--skip-room-check") {
|
||||
t.Errorf("hint should mention --skip-room-check, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_TimeChanged_ChecksExistingRoom(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// Existing event already has omm_existing booked.
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc5", "1742515200", "1742518800", "omm_existing"))
|
||||
|
||||
checkStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{"room_id": "omm_existing", "status": "available"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(checkStub)
|
||||
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc5",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc5"}},
|
||||
},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc5",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--start", "2025-03-21T02:00:00+08:00",
|
||||
"--end", "2025-03-21T03:00:00+08:00",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(checkStub.CapturedBody) == 0 {
|
||||
t.Fatalf("expected room-check to run for existing room on time change")
|
||||
}
|
||||
body := decodeCalendarCapturedBody(t, checkStub)
|
||||
rooms, _ := body["room_ids"].([]interface{})
|
||||
if len(rooms) != 1 || rooms[0] != "omm_existing" {
|
||||
t.Fatalf("room_ids should be [omm_existing], got %#v", rooms)
|
||||
}
|
||||
if len(patchStub.CapturedBody) == 0 {
|
||||
t.Fatalf("expected PATCH to run after check passes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_APIFailure_DegradesGracefully(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc6", "1742515200", "1742518800"))
|
||||
// Simulate room-check API failure (e.g., not yet rolled out) so the CLI
|
||||
// degrades gracefully instead of blocking the update.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 190001,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
addStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc6/attendees",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(addStub)
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc6",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_new",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(addStub.CapturedBody) == 0 {
|
||||
t.Fatalf("expected add-attendees POST to run despite check failure")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "room availability check failed") {
|
||||
t.Errorf("stderr should warn about degraded check, got: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_DryRun_IncludesPrecheckStep(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc7",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_dryrun",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "room_availability_check") {
|
||||
t.Fatalf("dry-run should preview room_availability_check, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Pre-check meeting room availability") {
|
||||
t.Fatalf("dry-run should describe pre-check step, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoomCheck_DryRun_SkipFlagOmitsStep(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc8",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_dryrun2",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--skip-room-check",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "room_availability_check") {
|
||||
t.Fatalf("dry-run with --skip-room-check should not preview room_availability_check, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStrategyDetail_ByReason exercises the human-readable strategy suffix
|
||||
// appended to each blocked-room line. Timezone-anchored fields use a fixed
|
||||
// IANA name so the offset ("GMT+8") is deterministic across machines.
|
||||
func TestStrategyDetail_ByReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
strategy *roomStrategy
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "over_max_duration renders as hours",
|
||||
reason: "over_max_duration",
|
||||
strategy: &roomStrategy{SingleMaxDuration: "10800"},
|
||||
want: "the max single-booking duration is 3 hours",
|
||||
},
|
||||
{
|
||||
name: "over_max_duration mixed hours and minutes",
|
||||
reason: "over_max_duration",
|
||||
strategy: &roomStrategy{SingleMaxDuration: "5400"},
|
||||
want: "the max single-booking duration is 1 hours 30 minutes",
|
||||
},
|
||||
{
|
||||
name: "beyond_advance_booking_window surfaces rfc3339 verbatim",
|
||||
reason: "beyond_advance_booking_window",
|
||||
strategy: &roomStrategy{MaxAdvanceBookingTime: "2026-07-13T18:00:00+08:00", Timezone: "Asia/Shanghai"},
|
||||
want: "the latest bookable end time is 2026-07-13T18:00:00+08:00",
|
||||
},
|
||||
{
|
||||
name: "not_in_usable_time renders day-seconds and zone",
|
||||
reason: "not_in_usable_time",
|
||||
strategy: &roomStrategy{DailyStartTime: "36000", DailyEndTime: "72000", Timezone: "Asia/Shanghai"},
|
||||
want: "the daily bookable window is 10:00 - 20:00 (GMT+8)",
|
||||
},
|
||||
{
|
||||
name: "before_daily_advance_window_release renders unlock time and zone",
|
||||
reason: "before_daily_advance_window_release",
|
||||
strategy: &roomStrategy{DailyAdvanceWindowReleaseTime: "28800", Timezone: "Asia/Shanghai"},
|
||||
want: "the next unlock happens today at 08:00 (GMT+8), which advances the window by one day",
|
||||
},
|
||||
{
|
||||
name: "past_time has no strategy suffix",
|
||||
reason: "past_time",
|
||||
strategy: &roomStrategy{SingleMaxDuration: "10800"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil strategy returns empty",
|
||||
reason: "over_max_duration",
|
||||
strategy: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "invalid duration returns empty",
|
||||
reason: "over_max_duration",
|
||||
strategy: &roomStrategy{SingleMaxDuration: "not-a-number"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "day-seconds out of range returns empty",
|
||||
reason: "not_in_usable_time",
|
||||
strategy: &roomStrategy{DailyStartTime: "-1", DailyEndTime: "999999", Timezone: "Asia/Shanghai"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "unresolvable timezone falls back to iana name",
|
||||
reason: "before_daily_advance_window_release",
|
||||
strategy: &roomStrategy{DailyAdvanceWindowReleaseTime: "28800", Timezone: "Not/AReal_Zone"},
|
||||
want: "the next unlock happens today at 08:00 (Not/AReal_Zone), which advances the window by one day",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := strategyDetail(tt.reason, tt.strategy)
|
||||
if got != tt.want {
|
||||
t.Errorf("strategyDetail(%q) = %q, want %q", tt.reason, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_RoomCheck_StrategyDetailInMessage pins that when the API returns a
|
||||
// room_strategy alongside the unavailable_reason_type, blockOnUnavailableRooms
|
||||
// surfaces the specific limit inline so agents can relay it to the user
|
||||
// without an extra round trip.
|
||||
func TestUpdate_RoomCheck_StrategyDetailInMessage(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_strategy", "1742515200", "1742525200"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_toolong",
|
||||
"status": "unavailable",
|
||||
"unavailable_reason_type": "over_max_duration",
|
||||
"room_strategy": map[string]interface{}{
|
||||
"single_max_duration": "10800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc_strategy",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_toolong",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected block error when strategy limit is hit")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "the max single-booking duration is 3 hours") {
|
||||
t.Errorf("message should surface the max-duration limit, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "omm_toolong") {
|
||||
t.Errorf("message should still list the room id, got: %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequisitionDetail_ByBounds pins the human-readable suffix rendered for a
|
||||
// `during_requisition` block. Every variant (both bounds, start only, end
|
||||
// only, none, nil requisition, non-matching reason) must degrade coherently.
|
||||
func TestRequisitionDetail_ByBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *roomRequisition
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "both bounds surface as verbatim rfc3339 range",
|
||||
req: &roomRequisition{StartTime: "2026-07-13T09:00:00+08:00", EndTime: "2026-07-13T18:00:00+08:00"},
|
||||
want: "the disabled period is 2026-07-13T09:00:00+08:00 to 2026-07-13T18:00:00+08:00",
|
||||
},
|
||||
{
|
||||
name: "start only",
|
||||
req: &roomRequisition{StartTime: "2026-07-13T09:00:00+08:00"},
|
||||
want: "the disabled period starts at 2026-07-13T09:00:00+08:00",
|
||||
},
|
||||
{
|
||||
name: "end only",
|
||||
req: &roomRequisition{EndTime: "2026-07-13T18:00:00+08:00"},
|
||||
want: "the disabled period ends at 2026-07-13T18:00:00+08:00",
|
||||
},
|
||||
{
|
||||
name: "empty bounds return no detail",
|
||||
req: &roomRequisition{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil requisition returns empty",
|
||||
req: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := requisitionDetail("during_requisition", tt.req)
|
||||
if got != tt.want {
|
||||
t.Errorf("requisitionDetail(during_requisition) = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Non-matching reason should always short-circuit even with a full payload.
|
||||
if got := requisitionDetail("reserved_by_other_event", &roomRequisition{StartTime: "x", EndTime: "y"}); got != "" {
|
||||
t.Errorf("requisitionDetail should ignore requisition for non-during_requisition reasons, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_RoomCheck_RequisitionDetailInMessage pins that when the API
|
||||
// returns room_requisition alongside a during_requisition block, the disabled
|
||||
// period is surfaced inline and the recovery clause is always present.
|
||||
func TestUpdate_RoomCheck_RequisitionDetailInMessage(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_req", "1742515200", "1742525200"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_req",
|
||||
"room_name": "Meeting Room A",
|
||||
"status": "unavailable",
|
||||
"unavailable_reason_type": "during_requisition",
|
||||
"room_requisition": map[string]interface{}{
|
||||
"start_time": "2026-07-13T09:00:00+08:00",
|
||||
"end_time": "2026-07-13T18:00:00+08:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc_req",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_req",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected block error for during_requisition")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "the disabled period is 2026-07-13T09:00:00+08:00 to 2026-07-13T18:00:00+08:00") {
|
||||
t.Errorf("message should surface the disabled period, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "pick a different time or a different room") {
|
||||
t.Errorf("message should always include recovery hint, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "omm_req[Meeting Room A]") {
|
||||
t.Errorf("message should render room id with human-readable name, got: %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoomLabel_ByFields pins the room identifier rendering used in the block
|
||||
// message. `<room_id>(<room_name>)` when both are present; degrades to
|
||||
// whichever is non-empty when the other is missing.
|
||||
func TestRoomLabel_ByFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
room string
|
||||
want string
|
||||
}{
|
||||
{name: "both present", id: "omm_1", room: "Meeting Room A", want: "omm_1[Meeting Room A]"},
|
||||
{name: "id only", id: "omm_2", room: "", want: "omm_2"},
|
||||
{name: "id only with whitespace name", id: "omm_3", room: " ", want: "omm_3"},
|
||||
{name: "name only degrades to name", id: "", room: "Room B", want: "Room B"},
|
||||
{name: "both blank returns empty", id: "", room: "", want: ""},
|
||||
{name: "name with parens does not create ambiguous nesting", id: "omm_4", room: "Room A (west wing)", want: "omm_4[Room A (west wing)]"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := roomLabel(tt.id, tt.room); got != tt.want {
|
||||
t.Errorf("roomLabel(%q, %q) = %q, want %q", tt.id, tt.room, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecurringMasterEventID_Shapes pins the recurringMasterEventID contract:
|
||||
// only `{uid}_{positive int}` collapses to `{uid}_0`; everything else opts out.
|
||||
func TestRecurringMasterEventID_Shapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
wantID string
|
||||
wantOK bool
|
||||
scenario string
|
||||
}{
|
||||
{in: "abc_1742515200", wantID: "abc_0", wantOK: true, scenario: "positive suffix collapses to master"},
|
||||
{in: "abc_1", wantID: "abc_0", wantOK: true, scenario: "positive one collapses to master"},
|
||||
{in: "abc_0", wantID: "", wantOK: false, scenario: "already master"},
|
||||
{in: "abc", wantID: "", wantOK: false, scenario: "no underscore"},
|
||||
{in: "_1742515200", wantID: "", wantOK: false, scenario: "empty uid"},
|
||||
{in: "abc_", wantID: "", wantOK: false, scenario: "empty suffix"},
|
||||
{in: "abc_-1", wantID: "", wantOK: false, scenario: "negative suffix"},
|
||||
{in: "abc_xyz", wantID: "", wantOK: false, scenario: "non-numeric suffix"},
|
||||
{in: "abc_def_1742515200", wantID: "abc_def_0", wantOK: true, scenario: "uid may contain underscore"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.scenario, func(t *testing.T) {
|
||||
gotID, gotOK := recurringMasterEventID(tt.in)
|
||||
if gotID != tt.wantID || gotOK != tt.wantOK {
|
||||
t.Errorf("recurringMasterEventID(%q) = (%q, %v), want (%q, %v)", tt.in, gotID, gotOK, tt.wantID, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_RoomCheck_EventNotFound_FallsBackToMaster pins the 193001
|
||||
// fallback: when the event_id is `{uid}_{original_time}` and the server
|
||||
// answers "event not found", the snapshot GET retries against `{uid}_0`
|
||||
// (the recurring master), so the room-check pipeline can still proceed.
|
||||
func TestUpdate_RoomCheck_EventNotFound_FallsBackToMaster(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// First GET on the instance event: 193001.
|
||||
instanceStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_1742515200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 193001,
|
||||
"msg": "event not found",
|
||||
},
|
||||
}
|
||||
reg.Register(instanceStub)
|
||||
|
||||
// Fallback GET on the master event: 200 with an existing room attendee, so
|
||||
// the pre-check has something to reason about.
|
||||
masterStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "uid_master_0",
|
||||
"summary": "Weekly sync",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
|
||||
"attendees": []interface{}{map[string]interface{}{"type": "resource", "room_id": "omm_from_master"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(masterStub)
|
||||
|
||||
// Time change → precheck runs against existing room from the master snapshot.
|
||||
precheckStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_from_master",
|
||||
"status": "available",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(precheckStub)
|
||||
|
||||
// PATCH succeeds.
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_1742515200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "uid_master_1742515200"}},
|
||||
},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "uid_master_1742515200",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--start", "2025-03-21T08:00:00+08:00",
|
||||
"--end", "2025-03-21T09:00:00+08:00",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected update to succeed after master fallback, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalReasonHint_ByMode pins the copy for each supported approval
|
||||
// mode, including the over_duration current-vs-threshold branches. The exact
|
||||
// phrase matters because agents parse it to decide next steps (relay to user,
|
||||
// shorten the meeting, pick another room).
|
||||
func TestApprovalReasonHint_ByMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info *roomApprovalInfo
|
||||
duration int64
|
||||
mustContain []string
|
||||
mustNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "all mode always needs approval",
|
||||
info: &roomApprovalInfo{ApprovalMode: "all"},
|
||||
duration: 3600,
|
||||
mustContain: []string{
|
||||
"requires approval for every reservation",
|
||||
},
|
||||
mustNotContain: []string{
|
||||
"the CLI cannot submit approvals",
|
||||
"lark-cli calendar event.attendees create",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "over_duration with current above threshold cites both",
|
||||
info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
|
||||
duration: 7200,
|
||||
mustContain: []string{
|
||||
"exceeds 1 hours",
|
||||
"current duration is 2 hours",
|
||||
},
|
||||
mustNotContain: []string{
|
||||
"lark-cli calendar event.attendees create",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "over_duration with current exactly at threshold treated as over",
|
||||
info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
|
||||
duration: 3600,
|
||||
mustContain: []string{
|
||||
"exceeds 1 hours",
|
||||
"current duration is 1 hours",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "over_duration with current below threshold surfaces reconciliation",
|
||||
info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
|
||||
duration: 1800,
|
||||
mustContain: []string{
|
||||
"exceeds 1 hours",
|
||||
"current duration reads as 30 minutes",
|
||||
"server still flagged approval",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "over_duration without threshold keeps mode label",
|
||||
info: &roomApprovalInfo{ApprovalMode: "over_duration"},
|
||||
duration: 3600,
|
||||
mustContain: []string{
|
||||
"exceeds a duration threshold",
|
||||
},
|
||||
mustNotContain: []string{
|
||||
"the CLI cannot submit approvals",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown mode falls back to generic reminder",
|
||||
info: &roomApprovalInfo{ApprovalMode: "future_mode"},
|
||||
duration: 3600,
|
||||
mustContain: []string{
|
||||
"requires approval before it can be booked",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil approval info still yields a reminder",
|
||||
info: nil,
|
||||
duration: 3600,
|
||||
mustContain: []string{
|
||||
"requires approval before it can be booked",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := approvalReasonHint(tt.info, tt.duration)
|
||||
for _, needle := range tt.mustContain {
|
||||
if !strings.Contains(got, needle) {
|
||||
t.Errorf("approvalReasonHint(%+v, %d) missing %q, got: %q", tt.info, tt.duration, needle, got)
|
||||
}
|
||||
}
|
||||
for _, needle := range tt.mustNotContain {
|
||||
if strings.Contains(got, needle) {
|
||||
t.Errorf("approvalReasonHint(%+v, %d) should not contain %q (that clause belongs in the hint, not the per-line reason), got: %q", tt.info, tt.duration, needle, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_RoomCheck_NeedApproval_Blocks pins that a status=="need_approval"
|
||||
// result blocks the update with a friendly, structured message: mode,
|
||||
// threshold, current duration comparison, and the "CLI can't approve" clause.
|
||||
// The block error also carries the same retry hint as the unavailable branch
|
||||
// so agents don't auto-retry with --skip-room-check.
|
||||
func TestUpdate_RoomCheck_NeedApproval_Blocks(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// Snapshot window: 1742515200 -> 1742522400 (2h). Threshold is 1h, so the
|
||||
// current duration is over threshold.
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_approval", "1742515200", "1742522400"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_approval",
|
||||
"room_name": "Executive Room",
|
||||
"status": "need_approval",
|
||||
"room_approval_info": map[string]interface{}{
|
||||
"approval_mode": "over_duration",
|
||||
"approval_duration_threshold": "3600",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc_approval",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_approval",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected need_approval to block the update")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "omm_approval[Executive Room]") {
|
||||
t.Errorf("message should render room label, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "requires approval when the booking exceeds 1 hours") {
|
||||
t.Errorf("message should carry approval threshold, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "current duration is 2 hours") {
|
||||
t.Errorf("message should carry current-vs-threshold comparison, got: %q", ve.Message)
|
||||
}
|
||||
if strings.Contains(ve.Message, "the CLI cannot submit approvals inline") {
|
||||
t.Errorf("recovery clause should live in the hint (not repeated per line in the message), got message: %q", ve.Message)
|
||||
}
|
||||
if strings.Contains(ve.Message, "lark-cli calendar event.attendees create --as user") {
|
||||
t.Errorf("attendees-create recovery clause should live in the hint (not per line), got message: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "the CLI cannot submit approvals") {
|
||||
t.Errorf("hint should carry the approval recovery clause once, got: %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "DO NOT auto-run") {
|
||||
t.Errorf("hint should forbid auto-running any approval recovery path without user confirmation, got: %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "ask the user first") {
|
||||
t.Errorf("hint should require asking the user before picking a recovery path, got: %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "lark-cli calendar event.attendees create --as user") {
|
||||
t.Errorf("hint should point at the attendees-create recovery path, got: %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "update through the client") {
|
||||
t.Errorf("hint should mention the client-side fallback for re-approval on existing rooms, got: %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, flagSkipRoomCheck) {
|
||||
t.Errorf("hint should still mention --%s, got: %q", flagSkipRoomCheck, ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_RoomCheck_RequisitionMissingBoundsStillCoherent pins that when
|
||||
// the API returns during_requisition without room_requisition, the recovery
|
||||
// hint keeps the line coherent on its own.
|
||||
func TestUpdate_RoomCheck_RequisitionMissingBoundsStillCoherent(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_req2", "1742515200", "1742525200"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"room_availabilitys": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_req_nobounds",
|
||||
"status": "unavailable",
|
||||
"unavailable_reason_type": "during_requisition",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarUpdate, []string{
|
||||
"+update",
|
||||
"--event-id", "evt_rc_req2",
|
||||
"--calendar-id", "cal_rc",
|
||||
"--add-attendee-ids", "omm_req_nobounds",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected block error for during_requisition without bounds")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if strings.Contains(ve.Message, "the disabled period") {
|
||||
t.Errorf("message should not fabricate a disabled period, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "pick a different time or a different room") {
|
||||
t.Errorf("message should always include recovery hint, got: %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ var CalendarUpdate = common.Shortcut{
|
||||
{Name: "add-attendee-ids", Desc: "attendee IDs to add, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "remove-attendee-ids", Desc: "attendee IDs to remove, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "notify", Type: "bool", Default: "true", Desc: "send update notification to attendees"},
|
||||
{Name: flagSkipRoomCheck, Type: "bool", Default: "false", Hidden: true, Desc: "skip meeting-room availability precheck (default checks rooms whenever a new room is added or the time/rrule of a room-attached event changes)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateCalendarUpdate(runtime)
|
||||
@@ -220,50 +219,6 @@ func calendarUpdateAttendeesPath(calendarID, eventID string) string {
|
||||
return calendarUpdateEventPath(calendarID, eventID) + "/attendees"
|
||||
}
|
||||
|
||||
// runRoomAvailabilityPrecheck checks any room affected by this update (new
|
||||
// room attendees, or existing rooms when the time/rrule shifts) against the
|
||||
// server before the PATCH is issued. It returns nil to allow the update to
|
||||
// proceed and a typed error to block it. Called only when --skip-room-check
|
||||
// is false.
|
||||
func runRoomAvailabilityPrecheck(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, body map[string]interface{}) error {
|
||||
timeChanged := runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end")
|
||||
rruleChanged := runtime.Cmd.Flags().Changed("rrule")
|
||||
|
||||
var newStartTs, newEndTs string
|
||||
if timeChanged {
|
||||
if m, _ := body["start_time"].(map[string]string); m != nil {
|
||||
newStartTs = m["timestamp"]
|
||||
}
|
||||
if m, _ := body["end_time"].(map[string]string); m != nil {
|
||||
newEndTs = m["timestamp"]
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := resolveRoomCheckPlan(ctx, runtime, calendarID, eventID, newStartTs, newEndTs, timeChanged, rruleChanged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if plan == nil {
|
||||
return nil
|
||||
}
|
||||
results, err := callRoomAvailabilityCheck(runtime, buildRoomCheckBody(calendarID, eventID, plan))
|
||||
if err != nil {
|
||||
// Degrade gracefully: warn on stderr and let the update proceed so the
|
||||
// pre-check API doesn't gate legitimate updates when it hiccups. For
|
||||
// 190014 (invalid_parameters) surface the server-supplied field-level
|
||||
// detail so agents can see why the precheck refused.
|
||||
msg := unwrapCalendarAPIError(err)
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +update] warning: room availability check failed (%s); proceeding with update — pass --%s to silence\n",
|
||||
msg, flagSkipRoomCheck)
|
||||
return nil
|
||||
}
|
||||
return blockOnUnavailableRooms(results, roomCheckPlanDurationSec(plan))
|
||||
}
|
||||
|
||||
func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarID, eventID := calendarUpdateIDs(runtime)
|
||||
displayCalendarID := calendarID
|
||||
@@ -291,33 +246,6 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d.Desc("multi-step update: event fields, attendee removal, and attendee addition run in order when requested")
|
||||
}
|
||||
steps := 0
|
||||
|
||||
if !runtime.Bool(flagSkipRoomCheck) {
|
||||
newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
|
||||
timeChanged := runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end")
|
||||
rruleChanged := runtime.Cmd.Flags().Changed("rrule")
|
||||
if len(newRooms) > 0 || timeChanged || rruleChanged {
|
||||
steps++
|
||||
desc := fmt.Sprintf("[%d] Pre-check meeting room availability (default; pass --%s to skip)", steps, flagSkipRoomCheck)
|
||||
previewBody := map[string]interface{}{
|
||||
"calendar_id": displayCalendarID,
|
||||
"event_id": eventID,
|
||||
"room_ids": newRooms,
|
||||
"start_timezone": "<inherited from event>",
|
||||
}
|
||||
if start, _ := body["start_time"].(map[string]string); start != nil {
|
||||
previewBody["start_time"] = formatRoomCheckTime(start["timestamp"], time.Local)
|
||||
}
|
||||
if end, _ := body["end_time"].(map[string]string); end != nil {
|
||||
previewBody["end_time"] = formatRoomCheckTime(end["timestamp"], time.Local)
|
||||
}
|
||||
if rrule, _ := body["recurrence"].(string); rrule != "" {
|
||||
previewBody["event_rrule"] = rrule
|
||||
}
|
||||
d.POST(roomCheckPath).Desc(desc).Body(previewBody)
|
||||
}
|
||||
}
|
||||
|
||||
if hasEventFields {
|
||||
steps++
|
||||
d.PATCH("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||
@@ -350,7 +278,7 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return d
|
||||
}
|
||||
|
||||
func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarID, eventID := calendarUpdateIDs(runtime)
|
||||
if eventID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
@@ -361,12 +289,6 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
|
||||
return err
|
||||
}
|
||||
|
||||
if !runtime.Bool(flagSkipRoomCheck) {
|
||||
if err := runRoomAvailabilityPrecheck(ctx, runtime, calendarID, eventID, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
completed := []string{}
|
||||
event := map[string]interface{}{}
|
||||
if hasEventFields {
|
||||
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
permissionGrantPermHint = "可管理权限"
|
||||
)
|
||||
|
||||
// AutoGrantCurrentUserDrivePermission grants full_access on a newly created
|
||||
@@ -120,7 +121,7 @@ func buildPermissionGrantResult(status, userOpenID, message, reason string) map[
|
||||
}
|
||||
|
||||
func permissionGrantPermMessage() string {
|
||||
return permissionGrantPerm
|
||||
return permissionGrantPerm + " (" + permissionGrantPermHint + ")"
|
||||
}
|
||||
|
||||
func permissionGrantPermType(resourceType string) string {
|
||||
|
||||
@@ -31,14 +31,6 @@ func apiErrWithScopes(code int, msg string, subjects ...string) error {
|
||||
return errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
func TestPermissionGrantPermMessageUsesAPINameOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := permissionGrantPermMessage(); got != "full_access" {
|
||||
t.Fatalf("permissionGrantPermMessage() = %q, want %q", got, "full_access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "perm-grant-test-skip",
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new document." {
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -173,9 +173,11 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
wantMessage := "Resource was created, but granting current user full_access failed: no permission. You can retry later or continue using bot identity."
|
||||
if grant["message"] != wantMessage {
|
||||
t.Fatalf("permission_grant.message = %q, want %q", grant["message"], wantMessage)
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "auto-grant failed") {
|
||||
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
|
||||
|
||||
@@ -59,7 +59,7 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
}
|
||||
desc := "OpenAPI: create document"
|
||||
if runtime.IsBot() {
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document."
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/docs_ai/v1/documents").
|
||||
|
||||
@@ -73,10 +73,10 @@ func init() {
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("Task", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("Chat card", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("Base"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("Base"))
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
|
||||
@@ -975,8 +975,8 @@ func TestConvertToIMMarkdownDocumentExpectedTagsAndEscaping(t *testing.T) {
|
||||
"````Go\nfmt.Println(\"hi\")\n```\n````",
|
||||
"`` `edge` `` $E=mc^2$ --- ![A \\[img\\]](https://example.com/i%281%29.png)",
|
||||
"``report`v1`.pdf``",
|
||||
"`Task``Chat card`",
|
||||
"`Base``Base``OKR`",
|
||||
"`任务``群聊卡片`",
|
||||
"`多维表格``多维表格``OKR`",
|
||||
}, "\n")
|
||||
|
||||
if got := convertToIMMarkdown(input, imCtx); got != want {
|
||||
|
||||
@@ -26,7 +26,7 @@ func v2FetchFlags() []common.Flag {
|
||||
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|error"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
|
||||
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},
|
||||
|
||||
@@ -443,7 +443,7 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "bug|error",
|
||||
"keyword": "bug|缺陷",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsReferenceMapFlagDesc = "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
|
||||
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
)
|
||||
|
||||
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
wantDesc := "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
|
||||
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
@@ -36,8 +34,8 @@ func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if flag.Desc != wantDesc {
|
||||
t.Fatalf("reference-map help = %q, want English description %q", flag.Desc, wantDesc)
|
||||
if !strings.Contains(flag.Desc, "@reference-map.json") {
|
||||
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -772,7 +772,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
|
||||
var inputs []commentReplyElementInput
|
||||
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"Example text\"}]'", err).WithParam("--content")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")
|
||||
|
||||
@@ -59,7 +59,7 @@ var DriveCreateFolder = common.Shortcut{
|
||||
Desc("[1] Create folder").
|
||||
Body(spec.RequestBody())
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder.")
|
||||
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -90,7 +90,6 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
@@ -109,10 +108,6 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
|
||||
if got.API[0].Body["folder_token"] != "fld_parent" {
|
||||
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
|
||||
}
|
||||
wantDesc := "After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder."
|
||||
if got.API[0].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", got.API[0].Desc, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
@@ -183,7 +178,7 @@ func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new folder." {
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.Dr
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it.")
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -108,7 +108,6 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
@@ -118,10 +117,6 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
if len(got.API) != 4 {
|
||||
t.Fatalf("expected 4 API calls, got %d", len(got.API))
|
||||
}
|
||||
wantDesc := "After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it."
|
||||
if got.API[len(got.API)-1].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", got.API[len(got.API)-1].Desc, wantDesc)
|
||||
}
|
||||
|
||||
if got.API[0].Body != nil {
|
||||
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
|
||||
|
||||
@@ -1088,7 +1088,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
t.Fatalf("set --wiki-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveUpload.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -1100,8 +1100,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
PostUploadNote string `json:"post_upload_note"`
|
||||
API []struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
@@ -1124,10 +1123,6 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
if got.API[1].Body["with_url"] != true {
|
||||
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
|
||||
}
|
||||
wantPostUploadNote := "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file."
|
||||
if got.PostUploadNote != wantPostUploadNote {
|
||||
t.Fatalf("post_upload_note = %q, want %q", got.PostUploadNote, wantPostUploadNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new file." {
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new file." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ var DriveUpload = common.Shortcut{
|
||||
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
|
||||
"In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
|
||||
"In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
|
||||
@@ -137,7 +137,7 @@ var DriveUpload = common.Shortcut{
|
||||
"with_url": true,
|
||||
})
|
||||
if runtime.IsBot() && !isOverwrite {
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file.")
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -72,18 +71,3 @@ func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
|
||||
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadHelpTipUsesEnglishPermissionName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := "In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions."
|
||||
for _, tip := range DriveUpload.Tips {
|
||||
if strings.Contains(tip, "automatic full_access") {
|
||||
if tip != want {
|
||||
t.Fatalf("DriveUpload full_access tip = %q, want %q", tip, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("DriveUpload full_access help tip not found")
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestSheetCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_current_user" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new spreadsheet." {
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -156,26 +156,10 @@ func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
|
||||
"data": "",
|
||||
},
|
||||
nil, nil)
|
||||
rt = common.TestNewRuntimeContextWithIdentity(rt.Cmd, nil, core.AsBot)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
|
||||
t.Fatalf("DryRun should include folder_token, got: %s", got)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(dryRun.API) != 1 {
|
||||
t.Fatalf("dry-run API count = %d, want 1", len(dryRun.API))
|
||||
}
|
||||
wantDesc := "After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet."
|
||||
if dryRun.API[0].Desc != wantDesc {
|
||||
t.Fatalf("desc = %q, want %q", dryRun.API[0].Desc, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreatePreservesBackendURL(t *testing.T) {
|
||||
|
||||
@@ -115,7 +115,7 @@ var SheetCreate = common.Shortcut{
|
||||
POST("/open-apis/sheets/v3/spreadsheets").
|
||||
Body(body)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet.")
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
|
||||
284
shortcuts/sheets/batch_key_vocab_test.go
Normal file
284
shortcuts/sheets/batch_key_vocab_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// subOp builds a raw +batch-update sub-op for translateBatchOp tests.
|
||||
func subOp(shortcut string, input map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{"shortcut": shortcut, "input": input}
|
||||
}
|
||||
|
||||
// TestBatchOp_UnknownInputKeyRejected pins the key-vocabulary guard: an
|
||||
// off-vocabulary sub-op input key must error with a did-you-mean instead of
|
||||
// being silently ignored (silent ignore surfaced as misleading "missing
|
||||
// required flag" errors — the top batch error cluster in eval traces).
|
||||
func TestBatchOp_UnknownInputKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invented key errors with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"rangee": "A1:B2",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}), testToken, 0)
|
||||
ve := requireValidation(t, err, `unknown input key "rangee"`)
|
||||
if !strings.Contains(ve.Message, `did you mean "range"`) {
|
||||
t.Fatalf("message %q missing did-you-mean", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "input keys:") {
|
||||
t.Fatalf("hint %q missing key contract", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system flag is not sub-op vocabulary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"dry_run": true,
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, `unknown input key "dry_run"`)
|
||||
})
|
||||
|
||||
t.Run("reserved locator in hyphen form still rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"spreadsheet-token": "shtXXX",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "do not pass input.spreadsheet-token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOp_HabitualKeysRewritten pins the silent rewrites: camelCase onto
|
||||
// the declared flag, and the commandFlagAliases table (size → width/height on
|
||||
// the resize pair — the pre-2026-07 vocabulary and the styles-protocol
|
||||
// spelling, the single largest sub-op error cluster).
|
||||
func TestBatchOp_HabitualKeysRewritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("camelCase sheetName resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to width on +cols-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cols-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A:C",
|
||||
"type": "pixel",
|
||||
"size": float64(120),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
width, _ := input["resize_width"].(map[string]interface{})
|
||||
if width["value"] != 120 {
|
||||
t.Fatalf("resize_width = %v, want value 120", input["resize_width"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to height on +rows-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+rows-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "1:3",
|
||||
"type": "pixel",
|
||||
"size": float64(36),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single-entry ranges unwraps onto range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:B2" {
|
||||
t.Fatalf("range = %v, want A1:B2", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-entry ranges prescribes a split", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2", "C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "split them into 2 sub-ops")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOperations_AggregatesValidationErrors pins the one-pass contract:
|
||||
// several invalid ops come back in a single error (each with its own
|
||||
// operations[i] context) instead of the first only — eval traces show
|
||||
// fix-one-resend loops of up to 7 round trips under first-error-only.
|
||||
func TestBatchOperations_AggregatesValidationErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops both reported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-clear", map[string]interface{}{"range": "A1:B2"}), // missing sheet selector
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}), // missing cells
|
||||
subOp("+cells-clear", map[string]interface{}{"sheet_name": "S1", "range": "A1:B2"}), // valid
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"operations[0] (+cells-clear)", "operations[1] (+cells-set)", "--cells is required"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps the standalone-shaped error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}),
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "--cells is required")
|
||||
if strings.Contains(ve.Message, "failed validation") {
|
||||
t.Fatalf("single-error message must not use the aggregate wrapper: %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetInput_MatrixPrecheck pins the local cells-vs-range guard that
|
||||
// front-runs the server's mid-batch "does not match range" failures.
|
||||
func TestCellsSetInput_MatrixPrecheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
wantContains string // "" = expect success
|
||||
}{
|
||||
{
|
||||
"empty cells prescribes +cells-clear",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2", "cells": []interface{}{}},
|
||||
"+cells-clear",
|
||||
},
|
||||
{
|
||||
"row count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 1 rows but --range \"A1:B3\" spans 3 rows",
|
||||
},
|
||||
{
|
||||
"column count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"has 1 columns but --range \"A1:B1\" spans 2 columns",
|
||||
},
|
||||
{
|
||||
"matching matrix passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
[]interface{}{map[string]interface{}{"value": "c"}, map[string]interface{}{"value": "d"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"bare single-cell range enforces the 1x1 match (07-21: server rejects anchors too)",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 2 columns but --range \"A1\" spans 1 columns",
|
||||
},
|
||||
{
|
||||
"single-cell range with a single cell passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", tc.input), testToken, 0)
|
||||
if tc.wantContains == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenToolErrorMsg_PartialFailureRecovery pins the no-rollback recovery
|
||||
// prescription appended to server-side "N succeeded, M failed" errors.
|
||||
func TestFlattenToolErrorMsg_PartialFailureRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrap := func(inner string) string {
|
||||
return `{"error":` + jsonQuote(inner) + `}`
|
||||
}
|
||||
|
||||
t.Run("single failure prescribes resend-from-index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 4 succeeded, 1 failed","failures":[{"index":4,"tool_name":"set_cell_range","error":"cells is required"}]}`))
|
||||
for _, want := range []string{"operations[4] (set_cell_range)", "no rollback", "resend only operations[4:]"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("msg %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple failures prescribe failed-only resend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 3 succeeded, 2 failed","failures":[{"index":1,"tool_name":"set_cell_range","error":"e1"},{"index":3,"tool_name":"resize_range","error":"e2"}]}`))
|
||||
if !strings.Contains(msg, "resend only the failed operations") {
|
||||
t.Fatalf("msg %q missing failed-only prescription", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero succeeded gets no note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 0 succeeded, 1 failed","failures":[{"index":0,"tool_name":"set_cell_range","error":"e"}]}`))
|
||||
if strings.Contains(msg, "no rollback") {
|
||||
t.Fatalf("msg %q must not carry the note when nothing was applied", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// jsonQuote wraps s as a JSON string literal (escaping quotes), mirroring how
|
||||
// the server double-encodes the inner error payload.
|
||||
func jsonQuote(s string) string {
|
||||
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
|
||||
}
|
||||
@@ -763,7 +763,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
|
||||
@@ -84,7 +87,14 @@ func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
|
||||
var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 单元格内容 ──────────────────────────────────────────────────
|
||||
"+cells-set": {"set_cell_range", cellsSetInput},
|
||||
"+cells-set": {"set_cell_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --writes plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range+cells each.
|
||||
if fv.Changed("writes") {
|
||||
return nil, sheetsValidationForFlag("writes", `"writes" is not supported inside +batch-update (it expands into its own atomic batch); call +cells-set --writes standalone, or give each sub-op a single range + cells`)
|
||||
}
|
||||
return cellsSetInput(fv, token, sid, sname)
|
||||
}},
|
||||
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
|
||||
"+cells-clear": {"clear_cell_range", cellsClearInput},
|
||||
"+cells-replace": {"replace_data", replaceInput},
|
||||
@@ -102,6 +112,11 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
|
||||
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
|
||||
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --ranges plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range each.
|
||||
if fv.Changed("ranges") {
|
||||
return nil, sheetsValidationForFlag("ranges", `"ranges" is not supported inside +batch-update (it expands into its own atomic batch); call +dim-delete --ranges standalone, or give each sub-op a single "range"`)
|
||||
}
|
||||
return dimRangeOpInput(fv, token, sid, sname, "delete")
|
||||
}},
|
||||
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
@@ -301,6 +316,133 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
|
||||
// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。
|
||||
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
|
||||
// wrappedSubOpInputKeys are nested MCP-body container keys that must never
|
||||
// appear at a sub-op input's top level — their presence means the caller
|
||||
// pasted a shortcut's structured *output* (e.g. a {"cell_styles":{…}} block)
|
||||
// where the flattened flag keys belong. None of the batch sub-op translators
|
||||
// read input under these names, so rejecting them is safe.
|
||||
var wrappedSubOpInputKeys = []string{"cell_styles", "cell_merges", "styles"}
|
||||
|
||||
// subOpKeyVocabulary returns the set of hyphen-canonical flag names a sub-op
|
||||
// input may carry for `sc`: every non-system flag in flag-defs except the
|
||||
// spreadsheet locators (reserved for the batch top level). Nil when the
|
||||
// shortcut has no flag-defs entry (vocabulary checks are then skipped).
|
||||
func subOpKeyVocabulary(sc string) map[string]bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
vocab := make(map[string]bool, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Name == "url" || df.Name == "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
vocab[df.Name] = true
|
||||
}
|
||||
return vocab
|
||||
}
|
||||
|
||||
// camelToKebab converts a lowerCamelCase key to its kebab form
|
||||
// (sheetName → sheet-name). Returns "" when the key carries no uppercase
|
||||
// letter (nothing to convert).
|
||||
func camelToKebab(key string) string {
|
||||
if strings.ToLower(key) == key {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, r := range key {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r + ('a' - 'A'))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeSubOpInputKeys validates every sub-op input key against the
|
||||
// shortcut's flag vocabulary, rewriting habitual spellings in place and
|
||||
// rejecting anything that matches nothing. Eval traces show unknown keys were
|
||||
// previously ignored silently, which turned "wrong key" (size for width,
|
||||
// camelCase sheetName, an invented styles object) into misleading
|
||||
// "missing required flag" errors downstream — the single largest batch error
|
||||
// cluster. Rewrites applied, in order:
|
||||
//
|
||||
// - underscore ↔ hyphen forms of a declared flag (already tolerated by
|
||||
// mapFlagView — accepted here as-is)
|
||||
// - lowerCamelCase → the declared flag (sheetName → sheet_name)
|
||||
// - the command's intuitive-alias table (size → width/height on the resize
|
||||
// pair) — the same commandFlagAliases the cobra path applies
|
||||
// - "ranges" with a single-entry array unwraps onto "range"; a multi-entry
|
||||
// array gets a split-into-sub-ops prescription instead
|
||||
//
|
||||
// Anything else errors with a did-you-mean. Returns a bare error; the caller
|
||||
// wraps it with the operations[i] (<shortcut>) context and key contract.
|
||||
func normalizeSubOpInputKeys(sc string, input map[string]interface{}) error {
|
||||
vocab := subOpKeyVocabulary(sc)
|
||||
if vocab == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(input))
|
||||
for k := range input {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
aliases := commandFlagAliases[sc]
|
||||
for _, k := range keys {
|
||||
hv := strings.ReplaceAll(k, "_", "-")
|
||||
if vocab[hv] {
|
||||
continue
|
||||
}
|
||||
if kebab := camelToKebab(k); kebab != "" && vocab[kebab] {
|
||||
input[strings.ReplaceAll(kebab, "-", "_")] = input[k]
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
if target, ok := aliases[strings.ToLower(hv)]; ok && vocab[target] {
|
||||
if _, taken := input[target]; !taken {
|
||||
if _, taken := input[strings.ReplaceAll(target, "-", "_")]; !taken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.ToLower(hv) == "ranges" && vocab["range"] && !vocab["ranges"] {
|
||||
if arr, isArr := input[k].([]interface{}); isArr {
|
||||
if len(arr) == 1 {
|
||||
if s, isStr := arr[0].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s takes a single \"range\" per sub-op, got %d entries in %q — split them into %d sub-ops (one per range)", sc, len(arr), k, len(arr)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if s, isStr := input[k].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("unknown input key %q", k)
|
||||
display := make([]string, 0, len(vocab))
|
||||
for name := range vocab {
|
||||
display = append(display, strings.ReplaceAll(name, "-", "_"))
|
||||
}
|
||||
sort.Strings(display)
|
||||
if match := suggest.Closest(strings.ToLower(hv), display, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
|
||||
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
|
||||
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
|
||||
@@ -312,6 +454,7 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
// - input 不是 object
|
||||
// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch)
|
||||
// - input 里手填了 excel_id / spreadsheet_token / url
|
||||
// - input 顶层出现 cell_styles / cell_merges / styles(误贴 MCP body 包裹结构)
|
||||
// - 子操作的 translator 报错(如缺必填字段)
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
@@ -335,7 +478,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
"(read ops / fan-out wrappers like +batch-update / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
index, sc,
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
}
|
||||
@@ -358,11 +501,30 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
)
|
||||
}
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
// 连字符 / 下划线两种写法都算命中(spreadsheet-token 与 spreadsheet_token 同罪)。
|
||||
for userKey := range input {
|
||||
normalized := strings.ReplaceAll(userKey, "-", "_")
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if normalized == k {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, userKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reject a "wrapped structure" sub-op input: agents copy a shortcut's nested
|
||||
// output container (e.g. +workbook-create --styles' {"cell_styles":{…}}) into
|
||||
// the op input, but the op input is the shortcut's own flags flattened into
|
||||
// JSON keys, not that wrapper. Left unflagged this surfaces far downstream as
|
||||
// an unrelated "at least one style flag is required" (helpers.go), which never
|
||||
// points at the real mistake.
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
`operations[%d] (%s): op input is the shortcut's flags flattened as JSON keys (e.g. "background_color": "#EBF1F8"); do not wrap in %s`,
|
||||
index, sc, k,
|
||||
)
|
||||
}
|
||||
@@ -373,6 +535,16 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
// Reject / rewrite off-vocabulary input keys BEFORE any value reads: an
|
||||
// unknown key silently ignored surfaces later as a misleading
|
||||
// "missing required flag" error (the top batch error cluster in evals).
|
||||
if err := normalizeSubOpInputKeys(sc, input); err != nil {
|
||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
if contract := subOpInputContract(sc); contract != "" {
|
||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
||||
}
|
||||
return nil, verr
|
||||
}
|
||||
fv := newMapFlagViewForCommand(sc, input)
|
||||
// operations is skipped by parse-time schema validation, so type-check the
|
||||
// sub-op's scalar fields here before the translator reads them via
|
||||
@@ -410,7 +582,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
// batchOpErrorDisplayLimit bounds how many per-op validation failures ride
|
||||
// on one aggregated --operations error, mirroring the schema validator's
|
||||
// display cap.
|
||||
const batchOpErrorDisplayLimit = 5
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组。逐 op 校验并**收集全部失败**
|
||||
// 一次性返回(不再 fail-fast)——agent 一轮就能修完所有坏 op,而不是
|
||||
// 修一个、重试、再撞下一个。cell 安全上限仍是全局判定,命中即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
@@ -422,10 +601,15 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
var totalCells int64
|
||||
var opErrs []error
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
opErrs = append(opErrs, err)
|
||||
continue
|
||||
}
|
||||
if len(opErrs) > 0 {
|
||||
continue // already failing — keep scanning for more bad ops, skip cell math.
|
||||
}
|
||||
totalCells += translatedCellCount(translated)
|
||||
if totalCells > maxStampMatrixCells {
|
||||
@@ -435,7 +619,27 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
switch len(opErrs) {
|
||||
case 0:
|
||||
return out, nil
|
||||
case 1:
|
||||
return nil, opErrs[0] // single failure keeps the historical error byte-for-byte.
|
||||
}
|
||||
shown := opErrs
|
||||
truncated := false
|
||||
if len(shown) > batchOpErrorDisplayLimit {
|
||||
shown = shown[:batchOpErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, e.Error()))
|
||||
}
|
||||
msg := fmt.Sprintf("%d of %d operations failed validation: %s", len(opErrs), len(rawOps), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
msg += fmt.Sprintf("; (%d more not shown — fix these first)", len(opErrs)-batchOpErrorDisplayLimit)
|
||||
}
|
||||
return nil, sheetsValidationForFlag("operations", "%s", msg).WithCause(opErrs[0])
|
||||
}
|
||||
|
||||
func translatedCellCount(op map[string]interface{}) int64 {
|
||||
|
||||
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCellsSetWrites pins the --writes plural form: scattered (cross-sheet)
|
||||
// regions fan into ONE atomic batch_update, each item self-carrying its
|
||||
// sheet selector (no top-level fallback — same convention as +batch-update
|
||||
// sub-ops and +styles-put items), with per-item errors aggregated.
|
||||
func TestCellsSetWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writes := func(items string, extra ...string) (string, string, error) {
|
||||
args := append([]string{
|
||||
"--url", testURL, "--dry-run", "--writes", items,
|
||||
}, extra...)
|
||||
return runShortcutCapturingErr(t, CellsSet, args)
|
||||
}
|
||||
|
||||
t.Run("cross-sheet items expand into one batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[
|
||||
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
|
||||
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(C:C)"}]]}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"batch_update", "明细", "汇总", "IFERROR"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("dry-run body missing %q: %s", want, stdout[:min(len(stdout), 400)])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item without sheet selector errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
requireValidation(t, err, "sheet-id or --sheet-name")
|
||||
})
|
||||
|
||||
t.Run("top-level sheet selector rejected with prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--sheet-name", "S1")
|
||||
requireValidation(t, err, "put sheet_name (or sheet_id) inside each writes item")
|
||||
})
|
||||
|
||||
t.Run("writes and range are mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--range", "A1")
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("per-item errors aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Both items pass the --writes schema (range+cells present) but fail
|
||||
// deeper: item 0 a matrix mismatch, item 1 a missing sheet selector.
|
||||
_, _, err := writes(`[
|
||||
{"sheet_name":"S1","range":"A1:B2","cells":[[{"value":"x"}]]},
|
||||
{"range":"C1","cells":[[{"value":"y"}]]}
|
||||
]`)
|
||||
ve := requireValidation(t, err, "--writes has 2 issues")
|
||||
for _, want := range []string{"--writes[0]", "--writes[1]", "sheet-name"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item keys go through the vocabulary layer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[{"sheetName":"S1","range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("camelCase sheetName must normalize: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "S1") {
|
||||
t.Fatalf("normalized item missing sheet: %s", stdout[:min(len(stdout), 300)])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot nest inside batch-update", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"writes": []interface{}{map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1", "cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
|
||||
t.Run("styles flag gets the layering prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Ergonomics (FlagErrorFunc hints) mount via the registry, not the
|
||||
// bare shortcut var — mirror the real CLI wiring.
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL, "--dry-run",
|
||||
"--writes", `[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--styles", `{"styles":[]}`,
|
||||
})
|
||||
ve := requireValidation(t, err, "unknown flag")
|
||||
if !strings.Contains(ve.Hint, "+styles-put") || !strings.Contains(ve.Hint, "cell_styles") {
|
||||
t.Fatalf("want the styles-put layering hint, got hint=%q", ve.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
150
shortcuts/sheets/chart_examples.go
Normal file
150
shortcuts/sheets/chart_examples.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─── +chart-create --print-example ─────────────────────────────────────
|
||||
//
|
||||
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
|
||||
// traces show agents paging through the full --print-schema dump for every
|
||||
// chart (25 round trips in one 35-task batch) and still missing deep
|
||||
// required fields. A ready-to-edit minimal template per chart type answers
|
||||
// the actual question ("what does a valid payload look like") in one local
|
||||
// call. Wired through PostMount, same pattern as +csv-put's flag-group
|
||||
// tweaks — no framework change.
|
||||
//
|
||||
// Templates mirror the canonical examples in the lark-sheets-chart
|
||||
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
|
||||
// inline headerMode with refs covering the header row, 1-based indices,
|
||||
// quoted sheet prefix in refs.
|
||||
|
||||
var chartExampleTemplates = map[string]string{
|
||||
"column": chartSimpleExample("column"),
|
||||
"bar": chartSimpleExample("bar"),
|
||||
"line": chartSimpleExample("line"),
|
||||
"area": chartSimpleExample("area"),
|
||||
"radar": chartSimpleExample("radar"),
|
||||
"scatter": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": "scatter"}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B20"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"pie": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 450},
|
||||
"snapshot": {
|
||||
"title": {"text": "占比标题"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "pie",
|
||||
"series": [{
|
||||
"index": 1,
|
||||
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
|
||||
}]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B11"}],
|
||||
"dim1": {"serie": {"index": 1, "aggregate": true}},
|
||||
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"combo": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 700, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "柱线组合"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "combo",
|
||||
"series": [
|
||||
{"index": 2, "comboType": "column"},
|
||||
{"index": 3, "comboType": "line"}
|
||||
]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C13"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
// chartSimpleExample renders the shared minimal shape for plot types that
|
||||
// need nothing beyond plot.type (column / bar / line / area / radar).
|
||||
func chartSimpleExample(typ string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": %q}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C10"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`, typ)
|
||||
}
|
||||
|
||||
func chartExampleTypes() []string {
|
||||
types := make([]string, 0, len(chartExampleTemplates))
|
||||
for t := range chartExampleTemplates {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// withChartPrintExample wraps +chart-create's PostMount so the command grows
|
||||
// a --print-example flag that short-circuits execution and prints a minimal
|
||||
// ready-to-edit --properties template — purely local, no identity or
|
||||
// network. --properties' cobra-level required annotation is relaxed (the
|
||||
// input builder still enforces it on the real path, same trick as
|
||||
// +csv-put's --csv).
|
||||
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().String("print-example", "",
|
||||
"Print a minimal ready-to-edit --properties template for a chart type ("+strings.Join(chartExampleTypes(), "|")+") and exit")
|
||||
// Only --properties carries a cobra-level required annotation (the
|
||||
// locator flags are xor pairs, enforced later); the input builder
|
||||
// still errors "--properties is required" on the real path.
|
||||
if fl := cmd.Flags().Lookup("properties"); fl != nil {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
prevRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
typ, _ := c.Flags().GetString("print-example")
|
||||
if typ == "" {
|
||||
return prevRunE(c, args)
|
||||
}
|
||||
tmpl, ok := chartExampleTemplates[typ]
|
||||
if !ok {
|
||||
return common.ValidationErrorf("no example for chart type %q; available: %s",
|
||||
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
|
||||
}
|
||||
fmt.Fprintln(c.OutOrStdout(), tmpl)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
63
shortcuts/sheets/chart_examples_test.go
Normal file
63
shortcuts/sheets/chart_examples_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChartPrintExample pins the --print-example contract: a known type
|
||||
// prints its template and skips execution entirely; an unknown type lists
|
||||
// the available ones.
|
||||
func TestChartPrintExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("prints template without locator flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
parent, _, _, _ := newTestRig(t, sc)
|
||||
var buf bytes.Buffer
|
||||
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
|
||||
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("print-example should run standalone, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"sectors"`) {
|
||||
t.Errorf("pie template should carry sectors, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type lists available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
|
||||
ve := requireValidation(t, err, `no example for chart type "donut"`)
|
||||
if !strings.Contains(ve.Message, "pie") {
|
||||
t.Errorf("message should list available types, got %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
|
||||
// template against the embedded chart-create properties schema — a template
|
||||
// the CLI itself would reject is worse than none.
|
||||
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
for typ, tmpl := range chartExampleTemplates {
|
||||
t.Run(typ, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
|
||||
t.Fatalf("template is not valid JSON: %v", err)
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
|
||||
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
|
||||
t.Errorf("template rejected by embedded schema: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -821,12 +821,10 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)",
|
||||
"default": "none",
|
||||
"desc": "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.",
|
||||
"enum": [
|
||||
"before",
|
||||
"after",
|
||||
"none"
|
||||
"after"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -887,8 +885,19 @@
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"
|
||||
"required": "xor",
|
||||
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"
|
||||
},
|
||||
{
|
||||
"name": "ranges",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "yes",
|
||||
@@ -1277,13 +1286,14 @@
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Comma-separated info categories to include",
|
||||
"desc": "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
|
||||
"enum": [
|
||||
"value",
|
||||
"formula",
|
||||
"style",
|
||||
"comment",
|
||||
"data_validation"
|
||||
"data_validation",
|
||||
"truncation"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1291,15 +1301,29 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "output-path",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
|
||||
},
|
||||
{
|
||||
"name": "skip-hidden",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
"desc": "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"
|
||||
},
|
||||
{
|
||||
"name": "skip-filter",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -1392,17 +1416,24 @@
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"
|
||||
"required": "optional",
|
||||
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"
|
||||
},
|
||||
{
|
||||
"name": "max-chars",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "output-path",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
|
||||
},
|
||||
{
|
||||
"name": "include-row-prefix",
|
||||
"kind": "own",
|
||||
@@ -1465,6 +1496,21 @@
|
||||
"required": "optional",
|
||||
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
|
||||
},
|
||||
{
|
||||
"name": "max-chars",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "output-path",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
|
||||
},
|
||||
{
|
||||
"name": "no-header",
|
||||
"kind": "own",
|
||||
@@ -1691,33 +1737,44 @@
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
"desc": "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Write range (A1 notation)"
|
||||
"required": "xor",
|
||||
"desc": "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"
|
||||
},
|
||||
{
|
||||
"name": "cells",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"required": "xor",
|
||||
"desc": "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "writes",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "allow-overwrite",
|
||||
"kind": "own",
|
||||
@@ -2787,6 +2844,43 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+styles-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator (target sheets are named inside --styles items)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the batched request template for each expanded operation; no network side effects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+batch-update": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
@@ -2809,7 +2903,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.",
|
||||
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
|
||||
@@ -648,6 +648,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"writes": {
|
||||
"type": "array",
|
||||
"description": "多区域写入项数组(最多 100 项),整批单次原子提交;支持跨 sheet。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"range",
|
||||
"cells"
|
||||
],
|
||||
"properties": {
|
||||
"sheet_id": {
|
||||
"type": "string",
|
||||
"description": "目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)。"
|
||||
},
|
||||
"sheet_name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名;与 sheet_id 二选一,必须写在每一项里。"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)。"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_range#/properties/cells)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cells-set-style": {
|
||||
@@ -7748,87 +7777,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+styles-put": {
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
@@ -7856,12 +7805,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8055,7 +8008,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8073,19 +8026,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8104,8 +8070,395 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -8228,12 +8581,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8427,7 +8784,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8445,19 +8802,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8476,8 +8846,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
@@ -75,9 +75,11 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"},
|
||||
{Name: "skip-filter", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -133,10 +135,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
@@ -316,8 +319,9 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
@@ -344,7 +348,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -392,7 +397,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -975,6 +980,15 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+styles-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -983,6 +997,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
|
||||
// enum + default
|
||||
rt := byName("+dim-insert", "inherit-style")
|
||||
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
|
||||
if rt == nil || len(rt.Enum) != 2 || rt.Default != "" {
|
||||
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
|
||||
}
|
||||
// required
|
||||
|
||||
@@ -38,9 +38,104 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
chainFlagAliases(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
|
||||
//
|
||||
// Eval traces show unknown-flag failures cluster on a handful of habitual
|
||||
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
|
||||
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
|
||||
// the enum-normalization contract above: a name whose value semantics are
|
||||
// identical to the real flag is rewritten silently (zero round-trips); a
|
||||
// name whose fix changes the value or moves it into a JSON field gets a
|
||||
// curated prescription on the unknown-flag error instead — never a silent
|
||||
// rewrite.
|
||||
|
||||
// commandFlagAliases maps, per command, habitual flag names onto the flag
|
||||
// actually registered. Only pairs with identical value semantics belong
|
||||
// here: the rewrite is invisible, so it must be safe to apply unread
|
||||
// (+csv-put --file with a path value still trips the file-path guard, which
|
||||
// prescribes @file / stdin).
|
||||
var commandFlagAliases = map[string]map[string]string{
|
||||
"+csv-put": {"file": "csv"},
|
||||
"+sheet-create": {"name": "title"},
|
||||
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
|
||||
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
|
||||
// here too — the rename is the single largest sub-op error cluster in
|
||||
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
|
||||
"+cols-resize": {"cols": "range", "size": "width"},
|
||||
"+rows-resize": {"rows": "range", "size": "height"},
|
||||
"+range-fill": {"source": "source-range", "target": "target-range"},
|
||||
"+range-copy": {"source": "source-range", "target": "target-range"},
|
||||
"+range-move": {"source": "source-range", "target": "target-range"},
|
||||
}
|
||||
|
||||
// intuitiveFlagHints carries the prescription for habitual names whose fix
|
||||
// is not a 1:1 rename — the value belongs to a different flag or to a field
|
||||
// inside a JSON payload. The hint spells the exact correct form so the
|
||||
// retry needs no --help round trip.
|
||||
var intuitiveFlagHints = map[string]map[string]string{
|
||||
"+sheet-copy": {
|
||||
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
},
|
||||
"+dim-insert": {
|
||||
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
|
||||
},
|
||||
"+dim-freeze": {
|
||||
"frozen-rows": "freeze the first N rows with --dimension row --count N",
|
||||
"frozen-cols": "freeze the first N columns with --dimension column --count N",
|
||||
"frozen-columns": "freeze the first N columns with --dimension column --count N",
|
||||
},
|
||||
"+cells-set-style": {
|
||||
"bold": "use --font-weight bold",
|
||||
"italic": "use --font-style italic",
|
||||
"underline": "use --font-line underline",
|
||||
},
|
||||
"+cells-set": {
|
||||
// Predictable prior from +table-put --styles: models will try to
|
||||
// attach range-level styling to a --writes call the same way.
|
||||
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
|
||||
},
|
||||
"+table-put": {
|
||||
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
|
||||
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
},
|
||||
}
|
||||
|
||||
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
|
||||
// (on top of any hook a prior PostMount installed, e.g. --token →
|
||||
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
|
||||
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
|
||||
// canonical name), and the command's intuitive-alias table. Either way a
|
||||
// habitual name parses as the real flag with zero round trips. Aliases
|
||||
// never shadow a registered flag and never appear in --help; an alias whose
|
||||
// target vanished (spec-side rename) is dropped, degrading to the
|
||||
// unknown-flag prescription.
|
||||
func chainFlagAliases(cmd *cobra.Command) {
|
||||
aliases := commandFlagAliases[cmd.Name()]
|
||||
usable := make(map[string]string, len(aliases))
|
||||
for alias, target := range aliases {
|
||||
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
|
||||
usable[alias] = target
|
||||
}
|
||||
}
|
||||
prev := cmd.Flags().GetNormalizeFunc()
|
||||
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if strings.Contains(name, "_") {
|
||||
name = strings.ReplaceAll(name, "_", "-")
|
||||
}
|
||||
if target, ok := usable[name]; ok {
|
||||
name = target
|
||||
}
|
||||
return prev(fs, name)
|
||||
})
|
||||
}
|
||||
|
||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
||||
// offending flag on params) and additionally inlines the full valid-flag
|
||||
@@ -50,6 +145,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
|
||||
// top-level sheet locator (each sub-op names its own sheet inside its input),
|
||||
// yet agents reach for --sheet-id / --sheet-name at the top level. An
|
||||
// edit-distance suggestion would only mislead here, so skip it and name the
|
||||
// real contract instead. Underscore spellings (--sheet_id) are matched too:
|
||||
// the error message itself teaches the underscore key names, and sub-op
|
||||
// inputs accept them, so agents mix the two styles.
|
||||
locatorName := strings.ReplaceAll(name, "_", "-")
|
||||
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
|
||||
}
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
@@ -67,6 +175,14 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
// A curated prescription beats both: it spells the exact correct form
|
||||
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
|
||||
if rx, ok := intuitiveFlagHints[c.Name()][name]; ok {
|
||||
hint = rx
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = rx + "; valid flags: " + list
|
||||
}
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
@@ -139,6 +255,16 @@ var enumAliases = map[string]string{
|
||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
||||
"centre": "center",
|
||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
||||
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
|
||||
// from the API docs; lowercased by canonicalEnumValue before lookup.
|
||||
"merge_all": "all",
|
||||
"merge_rows": "rows",
|
||||
"merge_columns": "columns",
|
||||
// Boolean-style word-wrap habits: true unambiguously means wrap on;
|
||||
// false means "don't wrap", whose Lark default is overflow (word-clip is
|
||||
// a distinct truncation mode nobody spells "false").
|
||||
"true": "auto-wrap",
|
||||
"false": "overflow",
|
||||
}
|
||||
|
||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
||||
|
||||
@@ -96,6 +96,55 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
|
||||
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
|
||||
// the per-op locator contract instead of offering a misleading fuzzy guess.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
|
||||
name := name
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
|
||||
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
|
||||
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
|
||||
}
|
||||
if len(verr.Params[0].Suggestions) != 0 {
|
||||
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
|
||||
// special case is scoped to the two sheet-locator flags: any other unknown
|
||||
// flag on +batch-update keeps the normal did-you-mean behaviour.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if strings.Contains(verr.Message, "no top-level sheet locator") {
|
||||
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
@@ -284,9 +333,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--col-size", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
ve := requireValidation(t, err, `unknown flag "--col-size"`)
|
||||
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
@@ -294,3 +343,191 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
|
||||
// habitual name with identical value semantics parses as the real flag on a
|
||||
// mounted command, costing zero round trips (eval: --cols, --file, --name,
|
||||
// --source/--target each burned an unknown-flag failure plus a --help call).
|
||||
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--width", "100",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "A:D") {
|
||||
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-create")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--name", "汇总",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "汇总") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+range-fill")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--source", "B2",
|
||||
"--target", "B3:B10",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{"B2", "B3:B10"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+csv-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--start-cell", "A1",
|
||||
"--file", "a,b\n1,2",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "a,b") {
|
||||
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A:C",
|
||||
"--size", "120",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "120") {
|
||||
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+rows-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "1:3",
|
||||
"--size", "36",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias never shadows a registered flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+csv-put"}
|
||||
c.Flags().String("csv", "", "")
|
||||
c.Flags().String("file", "", "") // hypothetical real flag wins
|
||||
chainFlagAliases(c)
|
||||
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("file"); got != "x" {
|
||||
t.Errorf("registered --file should keep its own value, got %q", got)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("csv"); got != "" {
|
||||
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
|
||||
// names whose fix is not a rename answer with the exact correct form, so the
|
||||
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
|
||||
// --help calls, +dim-insert kept failing even after reading help).
|
||||
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
command string
|
||||
args []string
|
||||
wrong string
|
||||
wantHint []string
|
||||
}{
|
||||
{
|
||||
command: "+dim-insert",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
|
||||
wrong: "--dimension",
|
||||
wantHint: []string{"--position", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
|
||||
wrong: "--frozen-rows",
|
||||
wantHint: []string{"--dimension row --count N"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
|
||||
wrong: "--bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+sheet-copy",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
|
||||
wrong: "--new-sheet-name",
|
||||
wantHint: []string{"--title", "source sheet"},
|
||||
},
|
||||
{
|
||||
command: "+table-put",
|
||||
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
|
||||
wrong: "--start-cell",
|
||||
wantHint: []string{`"start_cell"`, "+csv-put"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, tc.command)
|
||||
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
|
||||
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
|
||||
for _, want := range tc.wantHint {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
|
||||
// listing of introspectable flags; otherwise it returns the schema
|
||||
// subtree JSON for the named flag, or an error if the flag is not
|
||||
// registered.
|
||||
//
|
||||
// flagName also accepts a dotted path (properties.plotArea.axes): the
|
||||
// first segment names the flag, the rest walk the schema's properties
|
||||
// (descending through array items implicitly), returning just that
|
||||
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
|
||||
// lines — otherwise force agents to page through the full dump for one
|
||||
// nested field; eval traces show 25 such round trips in one batch.
|
||||
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return func(flagName string) ([]byte, error) {
|
||||
idx, err := loadFlagSchemas()
|
||||
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return json.MarshalIndent(map[string]interface{}{
|
||||
"shortcut": command,
|
||||
"introspectable_flags": flags,
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
|
||||
}, "", " ")
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
name, path := splitSchemaPath(flagName)
|
||||
schema, ok := entry[name]
|
||||
if !ok {
|
||||
// Tolerate the wire-vocabulary underscore form (--flag-name
|
||||
// border_styles for border-styles) — agents copy field names out
|
||||
// of JSON payloads where underscores are canonical.
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
schema, ok = entry[alt]
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
@@ -114,14 +131,121 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
if err := json.Unmarshal(schema, &pretty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path) > 0 {
|
||||
pretty, err = sliceSchemaByPath(pretty, name, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
return json.MarshalIndent(pretty, "", " ")
|
||||
}
|
||||
}
|
||||
|
||||
// splitSchemaPath splits a --flag-name value into the flag name and the
|
||||
// optional dotted schema path after it.
|
||||
func splitSchemaPath(flagName string) (string, []string) {
|
||||
parts := strings.Split(flagName, ".")
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
|
||||
// Each segment matches a key under "properties"; array levels are descended
|
||||
// implicitly through "items" (an explicit "items" segment also works), and
|
||||
// oneOf branches are searched for the first one carrying the key. A miss
|
||||
// errors with the keys actually available at that level so the caller can
|
||||
// re-issue the path without a full dump.
|
||||
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
|
||||
node := schema
|
||||
walked := flagName
|
||||
for _, seg := range path {
|
||||
next, ok := schemaChild(node, seg)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
node = next
|
||||
walked += "." + seg
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// schemaChild resolves one path segment against a schema node, descending
|
||||
// through items / oneOf wrappers as needed.
|
||||
func schemaChild(node interface{}, seg string) (interface{}, bool) {
|
||||
for depth := 0; depth < 8; depth++ {
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if seg == "items" {
|
||||
if items, ok := m["items"]; ok {
|
||||
return items, true
|
||||
}
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
if child, ok := props[seg]; ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
node = items
|
||||
continue
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
if child, ok := schemaChild(b, seg); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// schemaChildKeys lists the property keys reachable at a schema node (through
|
||||
// items / oneOf wrappers), for the path-miss error.
|
||||
func schemaChildKeys(node interface{}) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var collect func(n interface{}, depth int)
|
||||
collect = func(n interface{}, depth int) {
|
||||
if depth > 8 {
|
||||
return
|
||||
}
|
||||
m, ok := n.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
for k := range props {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
return
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
collect(items, depth+1)
|
||||
return
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
collect(b, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(node, 0)
|
||||
keys := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── schema-driven flag validation ────────────────────────────────────
|
||||
@@ -94,7 +96,15 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
}
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, &schema, "", c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
vErr := c.errs[0]
|
||||
if len(c.errs) == 1 {
|
||||
// Single failure keeps the historical message byte-for-byte.
|
||||
//
|
||||
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
||||
// --properties) are the highest-frequency usage-layer failure for
|
||||
// sheets, and agents often burn several retries guessing the shape.
|
||||
@@ -107,18 +117,63 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
// branch means entry[name] resolved a schema from the embedded
|
||||
// index, so the suggested command is guaranteed to print it.
|
||||
var tm *typeMismatchError
|
||||
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
isTypeMismatch := errors.As(vErr, &tm)
|
||||
if isTypeMismatch && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
|
||||
name, vErr.Error(), sk, command, name).WithCause(vErr)
|
||||
}
|
||||
}
|
||||
// Deep type mismatches don't get a whole-shape skeleton (it wouldn't
|
||||
// address the actual field), but if the field itself carries an enum /
|
||||
// description, append that one line — same "fix on first retry" goal.
|
||||
msg := vErr.Error()
|
||||
if isTypeMismatch {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, vErr.Error(), command, name).WithCause(vErr)
|
||||
name, msg, command, name).WithCause(vErr)
|
||||
}
|
||||
return nil
|
||||
// Multiple failures: report them all at once (numbered, each with its
|
||||
// own inline teaching hint) so the agent fixes the whole payload in one
|
||||
// retry instead of the fail-fast "fix one, hit the next" loop.
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, formatSchemaErrorList(c.errs), command, name).WithCause(vErr)
|
||||
}
|
||||
|
||||
// formatSchemaErrorList renders collected failures as a numbered one-line
|
||||
// list: "N validation errors: 1) …; 2) …". Type-mismatch entries carry
|
||||
// their enum/description suffix just like the single-error path. Entries
|
||||
// beyond schemaErrorDisplayLimit collapse into a "(more …)" tail — the
|
||||
// collector stops at cap, so the exact total is unknown by design.
|
||||
func formatSchemaErrorList(errs []error) string {
|
||||
shown := errs
|
||||
truncated := false
|
||||
if len(shown) > schemaErrorDisplayLimit {
|
||||
shown = shown[:schemaErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
msg := e.Error()
|
||||
var tm *typeMismatchError
|
||||
if errors.As(e, &tm) {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, msg))
|
||||
}
|
||||
out := fmt.Sprintf("%d validation errors: %s", len(shown), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
out = fmt.Sprintf("%d+ validation errors: %s; (more errors not shown — fix these first)", schemaErrorDisplayLimit, strings.Join(parts, "; "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateInputAgainstSchema validates input[flag] for every flag the
|
||||
@@ -187,8 +242,10 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
}
|
||||
|
||||
// schemaProperty mirrors the JSON Schema subset used by
|
||||
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
|
||||
// they're documentation.
|
||||
// data/flag-schemas.json. Description is retained (not just documentation)
|
||||
// so a required-missing or type-mismatch error can inline the one-line
|
||||
// field doc — the agent then fixes the input without a --print-schema round
|
||||
// trip. Other unknown keys stay dropped.
|
||||
//
|
||||
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
|
||||
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
|
||||
@@ -204,6 +261,7 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
// map<string, array<string>> fields (groups / collapse).
|
||||
type schemaProperty struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Nullable bool `json:"nullable"`
|
||||
Enum []interface{} `json:"enum"`
|
||||
Properties map[string]*schemaProperty `json:"properties"`
|
||||
@@ -242,20 +300,66 @@ func (a *additionalProps) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaErrorCollector accumulates validation failures during one full
|
||||
// traversal so the caller can report every problem in a single reply
|
||||
// instead of the fail-fast "fix one, retry, hit the next" loop. Capacity
|
||||
// is bounded (collectSchemaErrorsCap) so a pathological payload — e.g. a
|
||||
// 5000-row --cells array where every cell is malformed — cannot balloon
|
||||
// the error message or the traversal cost: once full, collection
|
||||
// short-circuits everywhere via full().
|
||||
type schemaErrorCollector struct {
|
||||
errs []error
|
||||
}
|
||||
|
||||
// collectSchemaErrorsCap bounds how many errors one traversal gathers:
|
||||
// schemaErrorDisplayLimit entries are rendered; one extra is collected
|
||||
// only to know that truncation happened.
|
||||
const (
|
||||
schemaErrorDisplayLimit = 5
|
||||
collectSchemaErrorsCap = schemaErrorDisplayLimit + 1
|
||||
)
|
||||
|
||||
func (c *schemaErrorCollector) add(err error) {
|
||||
if len(c.errs) < collectSchemaErrorsCap {
|
||||
c.errs = append(c.errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *schemaErrorCollector) full() bool { return len(c.errs) >= collectSchemaErrorsCap }
|
||||
|
||||
// validateAgainstSchema recursively checks `value` against `schema`,
|
||||
// prefixing any failure with the JSON path navigated so far.
|
||||
// prefixing any failure with the JSON path navigated so far. It reports
|
||||
// only the first failure — callers that want the full list (the
|
||||
// error-as-teaching aggregate path) use collectSchemaErrors directly.
|
||||
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
|
||||
if schema == nil {
|
||||
return nil // defensive — current callers always pass &schema, but
|
||||
// keeps validator safe for future programmatic construction.
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, schema, path, c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.errs[0]
|
||||
}
|
||||
|
||||
// collectSchemaErrors is the traversal engine behind validateAgainstSchema:
|
||||
// same checks, same messages, same deterministic order, but it keeps
|
||||
// walking after a failure and appends every problem to the collector
|
||||
// (until cap). Two deliberate exceptions to "keep walking":
|
||||
// - a type mismatch stops descent into that node (its children would
|
||||
// produce cascading nonsense against the wrong-typed value);
|
||||
// - oneOf alternatives are probed with throwaway collectors (a failed
|
||||
// alternative is not an error when a later one matches).
|
||||
func collectSchemaErrors(value interface{}, schema *schemaProperty, path string, c *schemaErrorCollector) {
|
||||
if schema == nil || c.full() {
|
||||
return
|
||||
}
|
||||
if value == nil && schema.Nullable {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
|
||||
c.add(&typeMismatchError{path: path, expected: schema.Type, got: jsType(value), enum: schema.Enum, description: schema.Description})
|
||||
return // wrong container type — descending would cascade nonsense.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,20 +367,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// already reported above). Apply to both `number` and `integer` types.
|
||||
if num, ok := value.(float64); ok {
|
||||
if schema.Minimum != nil && num < *schema.Minimum {
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,20 +398,22 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
if len(schema.OneOf) > 0 {
|
||||
matched := false
|
||||
for _, sub := range schema.OneOf {
|
||||
if validateAgainstSchema(value, sub, path) == nil {
|
||||
probe := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, sub, path, probe)
|
||||
if len(probe.errs) == 0 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,8 +422,18 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// the schema also describes their per-key shape via `properties`.
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
// Inline the missing field's type / one-line description / enum so
|
||||
// the agent supplies a correctly-shaped value on the first retry
|
||||
// instead of fetching the full schema.
|
||||
if hint := schemaFieldHint(schema.Properties[key]); hint != "" {
|
||||
msg += "; expected " + hint
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
@@ -327,6 +443,9 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
sub := schema.Properties[key]
|
||||
v, present := obj[key]
|
||||
if !present {
|
||||
@@ -350,14 +469,12 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(v, sub, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(v, sub, child, c)
|
||||
}
|
||||
}
|
||||
// additionalProperties: enforce only when explicitly declared.
|
||||
// Absent means lenient (matches the file header's stance). Sort
|
||||
// extras so the first rejection is deterministic across runs.
|
||||
// extras so rejection order is deterministic across runs.
|
||||
if schema.AdditionalProperties != nil {
|
||||
extras := make([]string, 0)
|
||||
for key := range obj {
|
||||
@@ -368,17 +485,29 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if schema.AdditionalProperties.Strict {
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
// Inline the node's declared keys (and a did-you-mean when the
|
||||
// unknown key is a near miss) so the agent renames it in one
|
||||
// retry instead of a --print-schema round trip.
|
||||
if legal := sortedSchemaPropertyKeys(schema.Properties); len(legal) > 0 {
|
||||
if guess := suggest.Closest(key, legal, 1); len(guess) > 0 {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, guess[0])
|
||||
}
|
||||
msg += "; valid properties: " + formatPropertyKeyList(legal)
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
continue
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(obj[key], schema.AdditionalProperties.Schema, child, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,33 +516,50 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if schema.Type == "array" && schema.Items != nil {
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil // type mismatch already reported above.
|
||||
return // type mismatch already reported above.
|
||||
}
|
||||
for i, item := range arr {
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
|
||||
return err
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
collectSchemaErrors(item, schema.Items, child, c)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// typeMismatchError is the type-check branch of validateAgainstSchema
|
||||
// as a typed error, so validateValueAgainstSchema can recognize shape
|
||||
// confusion (vs. deep value errors) and inline a skeleton of the
|
||||
// expected shape. Error() keeps the exact legacy wording.
|
||||
// expected shape. Error() keeps the exact legacy wording; enum /
|
||||
// description ride alongside for the deep-mismatch hintSuffix, so they
|
||||
// never leak into the shallow-skeleton message.
|
||||
type typeMismatchError struct {
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
enum []interface{}
|
||||
description string
|
||||
}
|
||||
|
||||
func (e *typeMismatchError) Error() string {
|
||||
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
|
||||
}
|
||||
|
||||
// hintSuffix renders the field's description / enum as a one-line tail for
|
||||
// the deep type-mismatch fallback (type is already stated by Error()).
|
||||
// Empty when the field declares neither.
|
||||
func (e *typeMismatchError) hintSuffix() string {
|
||||
var parts []string
|
||||
if d := oneLineDescription(e.description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(e.enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(e.enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// pathDepth counts how many levels below the flag root a JSON path
|
||||
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
|
||||
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
|
||||
@@ -605,6 +751,70 @@ func joinFormatted(values []interface{}) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// schemaFieldHint renders a compact one-line "type X, description: …, one of
|
||||
// […]" sketch of a single field's schema, used to enrich a required-missing
|
||||
// error so the agent supplies a correctly-shaped value without --print-schema.
|
||||
// Empty when the field declares none of type / description / enum.
|
||||
func schemaFieldHint(s *schemaProperty) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if s.Type != "" {
|
||||
parts = append(parts, fmt.Sprintf("type %q", s.Type))
|
||||
}
|
||||
if d := oneLineDescription(s.Description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(s.Enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(s.Enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// sortedSchemaPropertyKeys returns the declared property names in a stable
|
||||
// (sorted) order so the valid-property list in a strict unexpected-property
|
||||
// error is deterministic across runs.
|
||||
func sortedSchemaPropertyKeys(props map[string]*schemaProperty) []string {
|
||||
keys := make([]string, 0, len(props))
|
||||
for k := range props {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// propertyKeyDisplayLimit caps how many declared property names ride inline on
|
||||
// a strict unexpected-property error, so a wide object doesn't bury the actual
|
||||
// error under a wall of keys. Overflow is summarised as "(N more)".
|
||||
const propertyKeyDisplayLimit = 15
|
||||
|
||||
func formatPropertyKeyList(keys []string) string {
|
||||
if len(keys) <= propertyKeyDisplayLimit {
|
||||
return "[" + strings.Join(keys, ", ") + "]"
|
||||
}
|
||||
shown := keys[:propertyKeyDisplayLimit]
|
||||
return fmt.Sprintf("[%s, … (%d more)]", strings.Join(shown, ", "), len(keys)-propertyKeyDisplayLimit)
|
||||
}
|
||||
|
||||
// descriptionMaxLen bounds an inlined field description to one reasonable line;
|
||||
// schema descriptions can run several sentences, which would swamp the error.
|
||||
const descriptionMaxLen = 120
|
||||
|
||||
// oneLineDescription collapses a (possibly multi-line) schema description into
|
||||
// a single whitespace-normalised line, truncated to descriptionMaxLen runes.
|
||||
// Returns "" for an empty / whitespace-only description.
|
||||
func oneLineDescription(s string) string {
|
||||
collapsed := strings.Join(strings.Fields(s), " ")
|
||||
if collapsed == "" {
|
||||
return ""
|
||||
}
|
||||
if r := []rune(collapsed); len(r) > descriptionMaxLen {
|
||||
return string(r[:descriptionMaxLen]) + "…"
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
|
||||
// suggestEnumMatch returns the canonical enum entry when the user's
|
||||
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
|
||||
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -438,6 +440,373 @@ func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys pins the strict
|
||||
// additionalProperties:false enhancement: the error lists the node's legal
|
||||
// property keys (sorted, capped at 15 with an "(N more)" overflow) and, when
|
||||
// the unknown key is a near miss, appends a did-you-mean.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("lists legal keys and suggests a near miss", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{
|
||||
"background_color":{"type":"string"},
|
||||
"font_weight":{"type":"string"},
|
||||
"font_size":{"type":"integer"}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"background_colour": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key under strict schema must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, `unexpected property "background_colour"`) {
|
||||
t.Errorf("want the offending key named; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, `did you mean "background_color"?`) {
|
||||
t.Errorf("want a did-you-mean for the near miss; got %q", msg)
|
||||
}
|
||||
for _, want := range []string{"valid properties:", "background_color", "font_size", "font_weight"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want valid-property list to contain %q; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no did-you-mean for an unrelated key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"zzzzzzzz": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "did you mean") {
|
||||
t.Errorf("unrelated key should get no suggestion; got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "valid properties: [background_color]") {
|
||||
t.Errorf("want the valid-property list; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wide object truncates the key list with overflow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := make([]string, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
props = append(props, fmt.Sprintf(`"k%02d":{"type":"string"}`, i))
|
||||
}
|
||||
schema := parseSchema(t, `{"type":"object","additionalProperties":false,"properties":{`+strings.Join(props, ",")+`}}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"nope": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "(5 more)") { // 20 keys, cap 15
|
||||
t.Errorf("want overflow marker '(5 more)'; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingInlinesFieldHint pins that a
|
||||
// required-property-missing error inlines the field's type / one-line
|
||||
// description / enum when the schema describes that field.
|
||||
func TestValidateAgainstSchema_RequiredMissingInlinesFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"required":["operation"],
|
||||
"properties":{
|
||||
"operation":{
|
||||
"type":"string",
|
||||
"description":"Which mutation to run.",
|
||||
"enum":["insert","delete","move"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required property must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "operation"`,
|
||||
`type "string"`,
|
||||
"description: Which mutation to run.",
|
||||
`one of ["insert", "delete", "move"]`,
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain pins that a
|
||||
// missing required key with no describing schema keeps the plain legacy
|
||||
// message (no trailing "expected ...").
|
||||
func TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"type":"object","required":["a"]}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "; expected") {
|
||||
t.Errorf("no field schema → no inlined hint; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum pins that a deep
|
||||
// type mismatch (past the skeleton depth limit) still gets no whole-shape
|
||||
// skeleton, but appends the field's enum / description one-liner.
|
||||
func TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A wrong-typed value three levels deep where the field is an enum string.
|
||||
schema := parseSchema(t, `{
|
||||
"type":"array",
|
||||
"items":{"type":"array","items":{"type":"object","properties":{
|
||||
"align":{"type":"string","description":"Text alignment.","enum":["left","center","right"]}
|
||||
}}}
|
||||
}`)
|
||||
deep := parseValue(t, `[[{"align":42}]]`)
|
||||
err := validateAgainstSchema(deep, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("wrong type for align must fail")
|
||||
}
|
||||
var tm *typeMismatchError
|
||||
if !errors.As(err, &tm) {
|
||||
t.Fatalf("want *typeMismatchError, got %T", err)
|
||||
}
|
||||
suffix := tm.hintSuffix()
|
||||
for _, want := range []string{"description: Text alignment.", `one of ["left", "center", "right"]`} {
|
||||
if !strings.Contains(suffix, want) {
|
||||
t.Errorf("want %q in hintSuffix; got %q", want, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaFieldHint covers the single-field sketch used by
|
||||
// required-missing errors: each of type / description / enum contributes
|
||||
// its own segment, absent parts are simply skipped, and a nil / empty
|
||||
// schema yields no hint at all.
|
||||
func TestSchemaFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
schema *schemaProperty
|
||||
want string
|
||||
}{
|
||||
{"nil schema", nil, ""},
|
||||
{"empty schema", &schemaProperty{}, ""},
|
||||
{"type only", &schemaProperty{Type: "string"}, `type "string"`},
|
||||
{"description only", &schemaProperty{Description: "Cell note."}, "description: Cell note."},
|
||||
{"enum only", &schemaProperty{Enum: []interface{}{"a", "b"}}, `one of ["a", "b"]`},
|
||||
{
|
||||
"all three",
|
||||
&schemaProperty{Type: "string", Description: "段类型", Enum: []interface{}{"text", "link"}},
|
||||
`type "string", description: 段类型, one of ["text", "link"]`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := schemaFieldHint(tc.schema); got != tc.want {
|
||||
t.Errorf("schemaFieldHint = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatPropertyKeyList_Boundaries pins the display cap edges: exactly
|
||||
// at the cap nothing is folded, one past the cap folds into "(1 more)".
|
||||
func TestFormatPropertyKeyList_Boundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := make([]string, 0, propertyKeyDisplayLimit+1)
|
||||
for i := 0; i < propertyKeyDisplayLimit; i++ {
|
||||
keys = append(keys, fmt.Sprintf("k%02d", i))
|
||||
}
|
||||
if got := formatPropertyKeyList(keys); strings.Contains(got, "more)") {
|
||||
t.Errorf("exactly %d keys must not fold, got %q", propertyKeyDisplayLimit, got)
|
||||
}
|
||||
keys = append(keys, "overflow")
|
||||
if got := formatPropertyKeyList(keys); !strings.Contains(got, "(1 more)") {
|
||||
t.Errorf("%d keys should fold into '(1 more)', got %q", propertyKeyDisplayLimit+1, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeMismatchHintSuffix_EmptyWhenUndeclared pins that a field with
|
||||
// neither enum nor description adds no suffix — the deep-mismatch fallback
|
||||
// message must stay byte-identical to the legacy wording in that case.
|
||||
func TestTypeMismatchHintSuffix_EmptyWhenUndeclared(t *testing.T) {
|
||||
t.Parallel()
|
||||
tm := &typeMismatchError{path: "a.b", expected: "string", got: "number"}
|
||||
if got := tm.hintSuffix(); got != "" {
|
||||
t.Errorf("no enum/description → empty suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo pins the
|
||||
// did-you-mean for a key that differs from a legal one only in casing /
|
||||
// underscore style — a high-frequency LLM slip.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"Background_Color": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("case-typo key under strict schema must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `did you mean "background_color"?`) {
|
||||
t.Errorf("want case-insensitive did-you-mean; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_RequiredMissingRealSchema replays 场景3
|
||||
// of the doubao case against the real embedded flag-schemas.json: a
|
||||
// rich_text segment without "type" must inline the field's type, enum and
|
||||
// description while keeping the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_RequiredMissingRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("rich_text without type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "type" is missing`,
|
||||
`expected type "string"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepMismatchRealSchema replays 场景4: a
|
||||
// numeric rich_text "type" three levels deep gets the field's enum inline
|
||||
// (no whole-shape skeleton), still with the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_DeepMismatchRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"type":42,"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("numeric rich_text type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`expected type "string", got "number"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema deep-mismatch error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
if strings.Contains(msg, "expected shape:") {
|
||||
t.Errorf("deep mismatch must not inline a skeleton; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregatesMultipleErrors pins the
|
||||
// aggregate path: a payload with several independent problems reports them
|
||||
// all in one numbered reply (each with its own teaching hint) instead of
|
||||
// the fail-fast fix-one-retry-hit-the-next loop.
|
||||
func TestValidateValueAgainstSchema_AggregatesMultipleErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Two independent problems in one --cells payload: cell[0][0].rich_text[0]
|
||||
// misses required "type"; cell[0][1].note has the wrong type.
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]},{"note":12.5}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with two problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"2 validation errors:",
|
||||
`1) required property "type" is missing`,
|
||||
`one of ["text"`, // teaching hint rides along in aggregate mode too
|
||||
`2) [0][1].note: expected type "string"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in aggregated error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregateCapTruncates pins the display
|
||||
// cap: a pathological payload reports schemaErrorDisplayLimit entries and
|
||||
// an explicit truncation tail, never the full flood.
|
||||
func TestValidateValueAgainstSchema_AggregateCapTruncates(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Seven cells all missing required rich_text "type" → 7 independent errors.
|
||||
row := make([]string, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
row = append(row, `{"rich_text":[{"text":"x"}]}`)
|
||||
}
|
||||
value := parseValue(t, `[[`+strings.Join(row, ",")+`]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with seven problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "5+ validation errors:") {
|
||||
t.Errorf("want capped header '5+ validation errors:'; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "more errors not shown") {
|
||||
t.Errorf("want truncation tail; got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "6)") {
|
||||
t.Errorf("must not render entries beyond the display limit; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectSchemaErrors_OneOfProbeDoesNotLeak pins that failed oneOf
|
||||
// alternatives don't leak probe errors into the caller's collector when a
|
||||
// later alternative matches.
|
||||
func TestCollectSchemaErrors_OneOfProbeDoesNotLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"oneOf":[{"type":"string"},{"type":"number"}]}`)
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(42.0, schema, "", c)
|
||||
if len(c.errs) != 0 {
|
||||
t.Errorf("number matches the second oneOf alternative; want no errors, got %v", c.errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneLineDescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := oneLineDescription(" "); got != "" {
|
||||
t.Errorf("whitespace-only → empty, got %q", got)
|
||||
}
|
||||
if got := oneLineDescription("line one\n line two"); got != "line one line two" {
|
||||
t.Errorf("multi-line collapse = %q", got)
|
||||
}
|
||||
long := strings.Repeat("x", 200)
|
||||
got := oneLineDescription(long)
|
||||
if !strings.HasSuffix(got, "…") || len([]rune(got)) != descriptionMaxLen+1 {
|
||||
t.Errorf("long description should truncate to %d runes + ellipsis, got %d", descriptionMaxLen, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
|
||||
@@ -34,6 +34,7 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+rows-resize": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+styles-put": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
|
||||
@@ -407,6 +406,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
}
|
||||
// Unambiguous habitual shapes are rewritten onto the wire contract
|
||||
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
|
||||
// so both the standalone cobra path and +batch-update sub-ops (whose
|
||||
// mapFlagView.Str re-encodes composites through here) get the rewrite.
|
||||
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
|
||||
out = norm(out)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
// --properties (validated at the input-builder tail after enhance
|
||||
// hooks fill in flat-flag-derived fields) and any flag without an
|
||||
@@ -417,6 +423,92 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
|
||||
// input shapes onto the wire contract before schema validation — same
|
||||
// contract as enum normalization: only a shape whose meaning is beyond
|
||||
// doubt may be rewritten; anything ambiguous must fail with a prescription
|
||||
// instead. Applied to the parsed JSON value inside parseJSONFlag.
|
||||
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
|
||||
"+cells-set": {"cells": wrapLoneCellObject},
|
||||
"+chart-create": {"properties": normalizeChartHexColors},
|
||||
"+chart-update": {"properties": normalizeChartHexColors},
|
||||
}
|
||||
|
||||
// normalizeChartHexColors walks a chart properties payload and prefixes bare
|
||||
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
|
||||
// Excel-habit form the chart backend rejects with "expected rgba() or
|
||||
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
|
||||
// bare hex color is untouched.
|
||||
func normalizeChartHexColors(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, val := range t {
|
||||
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
|
||||
t[k] = "#" + s
|
||||
continue
|
||||
}
|
||||
normalizeChartHexColors(val)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, e := range t {
|
||||
normalizeChartHexColors(e)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isColorKey(k string) bool {
|
||||
return k == "color" || strings.HasSuffix(k, "_color") || strings.HasSuffix(k, "Color")
|
||||
}
|
||||
|
||||
func isBareHexColor(s string) bool {
|
||||
if len(s) != 6 && len(s) != 8 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cellObjectKeys pins the property vocabulary of a single cell in the
|
||||
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
|
||||
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
|
||||
var cellObjectKeys = map[string]struct{}{
|
||||
"border_styles": {},
|
||||
"cell_styles": {},
|
||||
"data_validation": {},
|
||||
"formula": {},
|
||||
"multiple_values": {},
|
||||
"note": {},
|
||||
"rich_text": {},
|
||||
"value": {},
|
||||
}
|
||||
|
||||
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
|
||||
// --cells contract expects. Eval traces show agents writing a single cell
|
||||
// routinely pass {"value":…} without the two array layers; when every key
|
||||
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
|
||||
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
|
||||
// row or a column) is returned untouched for the schema validator to
|
||||
// prescribe.
|
||||
func wrapLoneCellObject(v interface{}) interface{} {
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok || len(obj) == 0 {
|
||||
return v
|
||||
}
|
||||
for k := range obj {
|
||||
if _, known := cellObjectKeys[k]; !known {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return []interface{}{[]interface{}{obj}}
|
||||
}
|
||||
|
||||
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
|
||||
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
@@ -448,146 +540,3 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
style := map[string]interface{}{}
|
||||
if v := runtime.Str("background-color"); v != "" {
|
||||
style["background_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
if v := runtime.Str("font-style"); v != "" {
|
||||
style["font_style"] = v
|
||||
}
|
||||
if v := runtime.Str("font-weight"); v != "" {
|
||||
style["font_weight"] = v
|
||||
}
|
||||
if v := runtime.Str("font-line"); v != "" {
|
||||
style["font_line"] = v
|
||||
}
|
||||
if v := runtime.Str("horizontal-alignment"); v != "" {
|
||||
style["horizontal_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("vertical-alignment"); v != "" {
|
||||
style["vertical_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("word-wrap"); v != "" {
|
||||
style["word_wrap"] = v
|
||||
}
|
||||
if v := runtime.Str("number-format"); v != "" {
|
||||
style["number_format"] = v
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. path labels the map for the error message.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// Structure is checked leniently to match the pass-through contract: any
|
||||
// element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects). Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if runtime.Str("border-styles") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "border-styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// requireAnyStyleFlag ensures at least one style-defining flag (style or
|
||||
// border) is set — otherwise the request would do nothing.
|
||||
func requireAnyStyleFlag(runtime flagView) error {
|
||||
if len(buildCellStyleFromFlags(runtime)) > 0 {
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
|
||||
WithParams(
|
||||
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
|
||||
)
|
||||
}
|
||||
|
||||
209
shortcuts/sheets/json_flag_normalize_test.go
Normal file
209
shortcuts/sheets/json_flag_normalize_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
|
||||
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
|
||||
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
|
||||
// untouched for the schema validator to prescribe.
|
||||
func TestWrapLoneCellObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wrapped bool
|
||||
}{
|
||||
{"lone value cell", `{"value":"hi"}`, true},
|
||||
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
|
||||
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
|
||||
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
|
||||
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
|
||||
{"empty object stays", `{}`, false},
|
||||
{"scalar stays", `"hi"`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
|
||||
t.Fatalf("bad fixture: %v", err)
|
||||
}
|
||||
out := wrapLoneCellObject(v)
|
||||
_, isWrapped := out.([]interface{})
|
||||
_, wasArray := v.([]interface{})
|
||||
if tc.wrapped && (!isWrapped || wasArray) {
|
||||
t.Errorf("expected wrap to [[cell]], got %#v", out)
|
||||
}
|
||||
if !tc.wrapped && !wasArray && isWrapped {
|
||||
t.Errorf("expected no wrap, got %#v", out)
|
||||
}
|
||||
if tc.wrapped {
|
||||
rows, _ := out.([]interface{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(rows))
|
||||
}
|
||||
cells, _ := rows[0].([]interface{})
|
||||
if len(cells) != 1 {
|
||||
t.Fatalf("want 1 cell, got %d", len(cells))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
|
||||
// vocabulary against the embedded +cells-set --cells schema: if the spec
|
||||
// repo adds or removes a cell property, this fails and cellObjectKeys must
|
||||
// be updated (an outdated set only narrows the auto-wrap, but silently
|
||||
// narrowing is still drift).
|
||||
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagSchemas: %v", err)
|
||||
}
|
||||
raw, ok := idx.Flags["+cells-set"]["cells"]
|
||||
if !ok {
|
||||
t.Fatal("embedded schema for +cells-set --cells missing")
|
||||
}
|
||||
var schema schemaProperty
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
cell := schema.Items
|
||||
if cell != nil && cell.Items != nil {
|
||||
cell = cell.Items
|
||||
}
|
||||
if cell == nil || len(cell.Properties) == 0 {
|
||||
t.Fatal("schema shape changed: expected array→array→object with properties")
|
||||
}
|
||||
for k := range cell.Properties {
|
||||
if _, ok := cellObjectKeys[k]; !ok {
|
||||
t.Errorf("schema property %q missing from cellObjectKeys", k)
|
||||
}
|
||||
}
|
||||
for k := range cellObjectKeys {
|
||||
if _, ok := cell.Properties[k]; !ok {
|
||||
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
|
||||
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
|
||||
// instead of failing "expected type array, got object".
|
||||
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `{"value":"hello"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
|
||||
// wrong JSON kind inlines the expected shape; mangled JSON steers to
|
||||
// stdin/@file.
|
||||
func TestTablePut_SheetsDecodeHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[)`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"stdin", "@./payload.json"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
|
||||
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
|
||||
// pass-through of everything else, including the parseJSONFlag wiring for
|
||||
// the batch sub-op path.
|
||||
func TestNormalizeChartHexColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := map[string]interface{}{
|
||||
"plotArea": map[string]interface{}{
|
||||
"plot": map[string]interface{}{
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
|
||||
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
|
||||
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
|
||||
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
normalizeChartHexColors(props)
|
||||
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
|
||||
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
|
||||
t.Errorf("bare hex should gain #, got %v", got)
|
||||
}
|
||||
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
|
||||
t.Errorf("already-prefixed color must not change, got %v", got)
|
||||
}
|
||||
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
|
||||
t.Errorf("rgba color must not change, got %v", got)
|
||||
}
|
||||
last := series[3].(map[string]interface{})
|
||||
if got := last["font_color"]; got != "#ED7D31AA" {
|
||||
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
|
||||
}
|
||||
if got := last["label"]; got != "not a color 4472C4" {
|
||||
t.Errorf("non-color key must not change, got %v", got)
|
||||
}
|
||||
|
||||
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
|
||||
// and picks up the normalizer.
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
|
||||
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
|
||||
})
|
||||
out, err := parseJSONFlag(fv, "properties")
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONFlag: %v", err)
|
||||
}
|
||||
title := out.(map[string]interface{})["title"].(map[string]interface{})
|
||||
if title["font_color"] != "#112233" {
|
||||
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -29,10 +30,14 @@ import (
|
||||
// The tool's contract (post-translation):
|
||||
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
|
||||
//
|
||||
// continue_on_error defaults to false (strict transaction): any failure
|
||||
// rolls back the whole batch. CLI leaves the default in place for the
|
||||
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
|
||||
// only +batch-update lets callers flip it via --continue-on-error.
|
||||
// continue_on_error defaults to false (fail-fast): execution stops at the
|
||||
// first failing sub-op, but sub-ops already applied are NOT rolled back —
|
||||
// the server reports "N succeeded, M failed" and the N stay in the sheet
|
||||
// (verified against live batches; earlier docs wrongly promised a rollback,
|
||||
// which made agents resend whole batches and double-apply the successes).
|
||||
// CLI leaves the default in place for the fan-out shortcuts since they're
|
||||
// idempotent stamps; only +batch-update lets callers flip it via
|
||||
// --continue-on-error.
|
||||
|
||||
// BatchUpdate accepts a CLI-shape operations array (each item
|
||||
// {shortcut, input}); on Validate / DryRun / Execute we translate each
|
||||
@@ -42,7 +47,7 @@ import (
|
||||
var BatchUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-update",
|
||||
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
|
||||
Description: "Execute a batch of write shortcuts in one request; fail-fast on the first failing sub-op (already-applied sub-ops are NOT rolled back).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -64,7 +69,11 @@ var BatchUpdate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := batchUpdateInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
||||
dr.Set("warning_message", dimInsertBeforeStyleWarning)
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -75,6 +84,9 @@ var BatchUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,7 +95,8 @@ var BatchUpdate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
|
||||
"high-risk-write: always pass --yes (or --dry-run to preview) — without it the call exits 10 asking for confirmation.",
|
||||
"Execution is fail-fast, NOT transactional: on \"N succeeded, M failed\" the succeeded sub-ops stay applied (no rollback) — fix the failure and resend ONLY the operations from the first failed index onward; resending the whole batch re-applies the succeeded ones. Pass --continue-on-error to keep going past failures instead.",
|
||||
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
|
||||
},
|
||||
}
|
||||
@@ -124,6 +137,46 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// batchNeedsDimInsertBeforeStyleWarning reports whether any +dim-insert sub-op
|
||||
// requests --inherit-style before at the first row/column, where the
|
||||
// preceding-side style cannot be copied (no preceding row/column exists).
|
||||
func batchNeedsDimInsertBeforeStyleWarning(runtime *common.RuntimeContext) bool {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sc, _ := op["shortcut"].(string)
|
||||
if sc != "+dim-insert" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
isBefore := false
|
||||
for _, key := range []string{"inherit-style", "inherit_style", "inheritStyle"} {
|
||||
if v, _ := input[key].(string); strings.EqualFold(v, "before") {
|
||||
isBefore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isBefore {
|
||||
continue
|
||||
}
|
||||
posRaw, hasPos := input["position"]
|
||||
if !hasPos {
|
||||
continue
|
||||
}
|
||||
// Warn only at the first row/column (idx 0).
|
||||
if _, idx, err := parseA1Position(strings.TrimSpace(fmt.Sprintf("%v", posRaw))); err == nil && idx == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
|
||||
// operations list directly) or an envelope object { operations, continue_on_error }
|
||||
// for back-compat with the legacy --data shape. Returns the operations array.
|
||||
@@ -160,6 +213,11 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-batch-set-style"),
|
||||
Tips: []string{
|
||||
"DEPRECATED: superseded by +styles-put, whose one spec also covers merges, row/col sizes and freeze — prefer it for new work.",
|
||||
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
|
||||
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
@@ -189,6 +247,10 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Phase-1 deprecation (docs already point at +styles-put): keep the
|
||||
// command working, steer new usage to the superset in-band.
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"note: +cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work")
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -58,6 +58,39 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_DimInsertInheritAfterCopiesFollowingStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"D","count":1,"inherit_style":"after"}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["tool_name"] != "modify_sheet_structure" {
|
||||
t.Fatalf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
|
||||
}
|
||||
in, _ := op["input"].(map[string]interface{})
|
||||
// inherit_style=after copies the following column's style via a plain
|
||||
// before-insert at the same position (the backend anchors on the following
|
||||
// column), so position stays D with side=before.
|
||||
assertInputEquals(t, in, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": "sh1",
|
||||
"operation": "insert",
|
||||
"position": "D",
|
||||
"count": float64(1),
|
||||
"side": "before",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
@@ -405,6 +438,21 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
|
||||
wantMatch: "'input' must be a JSON object",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_styles":{"background_color":"#EBF1F8"}}}]`,
|
||||
wantMatch: "do not wrap in cell_styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","styles":{"font_weight":"bold"}}}]`,
|
||||
wantMatch: "do not wrap in styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_merges structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_merges":[{"range":"A1:B1"}]}}]`,
|
||||
wantMatch: "do not wrap in cell_merges",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -420,6 +468,99 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper guards the
|
||||
// wrapped-structure rejection against overreach: the same style fields in
|
||||
// their correct flattened form must translate cleanly — only the wrapper
|
||||
// container keys (cell_styles / styles / cell_merges) are rejected.
|
||||
func TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet_name": "s",
|
||||
"range": "A1",
|
||||
"background_color": "#EBF1F8",
|
||||
"font_weight": "bold",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("flattened style keys must pass the wrapper check, got %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
|
||||
if style["background_color"] != "#EBF1F8" || style["font_weight"] != "bold" {
|
||||
t.Fatalf("translated style = %#v", style)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags locks the static
|
||||
// assumption wrappedSubOpInputKeys relies on: no shortcut registered in
|
||||
// batchOpDispatch declares a flag named cell_styles / cell_merges / styles.
|
||||
// If a future dispatch-table addition (e.g. +table-put) carries one of these
|
||||
// flags, its legitimate input would be silently rejected by the wrapper
|
||||
// check — this test turns that silent breakage into a build-time failure.
|
||||
func TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
wrapped := make(map[string]struct{}, len(wrappedSubOpInputKeys))
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
wrapped[k] = struct{}{}
|
||||
}
|
||||
for shortcut := range batchOpDispatch {
|
||||
for _, f := range flagsFor(shortcut) {
|
||||
key := strings.ReplaceAll(f.Name, "-", "_")
|
||||
if _, clash := wrapped[key]; clash {
|
||||
t.Errorf("%s declares flag --%s which collides with wrappedSubOpInputKeys; "+
|
||||
"exempt this shortcut from the wrapper check before adding it to batchOpDispatch",
|
||||
shortcut, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_AggregatesMultipleOpErrors pins op-level aggregation: when
|
||||
// several operations are invalid, one reply names them all (numbered, with
|
||||
// each op's own error) instead of failing on the first bad op only. A single
|
||||
// bad op keeps the historical single-error message (no aggregate wrapper).
|
||||
func TestBatchUpdate_AggregatesMultipleOpErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops reported together", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-set","input":{"sheet_name":"s","range":"A1"}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"1) ", "2) ", "operations[0]", "operations[1]"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("aggregated op error should contain %q, got %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps plain message", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "not allowed in +batch-update")
|
||||
if strings.Contains(err.Error(), "operations failed validation") {
|
||||
t.Errorf("single bad op must not get the aggregate wrapper, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
|
||||
// highest-frequency batch failures, so an agent can repair its payload in a
|
||||
// single retry without --help / --print-schema round trips.
|
||||
|
||||
@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write — always preview with --dry-run; clear is not undoable.",
|
||||
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
|
||||
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
|
||||
},
|
||||
}
|
||||
@@ -266,9 +266,13 @@ var ColsResize = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
|
||||
`Different widths per column in one atomic call: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
|
||||
},
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
}
|
||||
|
||||
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
|
||||
|
||||
@@ -69,8 +69,7 @@ var CellsGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -84,21 +83,28 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
}
|
||||
// Preserve omission so the tool can keep the legacy fallback where
|
||||
// skip_filter inherits skip_hidden. An explicit false must still be sent.
|
||||
if runtime.Changed("skip-filter") {
|
||||
input["skip_filter"] = runtime.Bool("skip-filter")
|
||||
}
|
||||
// --cell-limit was removed from the CLI surface; --max-chars is the single
|
||||
// read cap. Pin cell_limit very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["cell_limit"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
|
||||
// tool's two coarse switches:
|
||||
// tool's switches:
|
||||
//
|
||||
// - include_styles (bool) — toggled by "style" presence
|
||||
// - value_render_option (enum) — "formula" → formula; otherwise omitted
|
||||
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
|
||||
// the tool estimate and return per-cell isRowTruncated / isColTruncated
|
||||
//
|
||||
// "value", "comment", and "data_validation" are always returned by the tool
|
||||
// per the schema; they have no dedicated knob today but are accepted in
|
||||
@@ -119,6 +125,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
|
||||
if want["formula"] {
|
||||
input["value_render_option"] = "formula"
|
||||
}
|
||||
if want["truncation"] {
|
||||
input["include_truncation_info"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
|
||||
@@ -139,9 +148,6 @@ var CsvGet = common.Shortcut{
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return sheetsValidationForFlag("range", "--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -165,16 +171,25 @@ var CsvGet = common.Shortcut{
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
// csvGetFullSheetRange is the range sent when --range is omitted: the tool
|
||||
// requires one, but clips anything past the grid bounds and reports the clip
|
||||
// in actual_range — so an over-wide whole-columns range reads the entire
|
||||
// sheet in one call, with no workbook-info pre-flight. Eval traces show
|
||||
// "read the whole sheet" as a recurring intent (--range was the single most
|
||||
// missed required flag once the rest of the surface was fixed).
|
||||
const csvGetFullSheetRange = "A:ZZZ"
|
||||
|
||||
func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
} else {
|
||||
input["range"] = csvGetFullSheetRange
|
||||
}
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
@@ -183,7 +198,7 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin
|
||||
// read cap. Pin max_rows very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["max_rows"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
|
||||
@@ -34,6 +34,41 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-get skip filtered rows only",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--skip-filter"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"skip_filter": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-get skip hidden but keep filtered rows",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--skip-hidden", "--skip-filter=false"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"skip_hidden": true,
|
||||
"skip_filter": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
// --include truncation toggles include_truncation_info so the tool
|
||||
// estimates and returns per-cell isRowTruncated / isColTruncated.
|
||||
name: "+cells-get include=truncation",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"include_styles": false,
|
||||
"include_truncation_info": true,
|
||||
"cell_limit": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Canonical form: --sheet-id + bare --range. Aligned with
|
||||
// +cells-get / +csv-get; before the e2e BUG-019 fix this
|
||||
@@ -74,6 +109,17 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsGet_OmitsSkipFilterWhenUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, CellsGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2",
|
||||
})
|
||||
got := decodeToolInput(t, body, "get_cell_ranges")
|
||||
if _, ok := got["skip_filter"]; ok {
|
||||
t.Fatalf("skip_filter must be omitted when --skip-filter is unset: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownGet_RequiresSheetSelector locks the +cells-get-style
|
||||
// selector contract: at least one of --sheet-id / --sheet-name must be
|
||||
// supplied. Before BUG-019 fix this shortcut required a "Sheet!A1"
|
||||
@@ -92,7 +138,9 @@ func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
|
||||
|
||||
// TestReadData_RequiresRange covers the trim-based --range guard on the
|
||||
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
|
||||
// must still be rejected by Validate).
|
||||
// must still be rejected by Validate). +csv-get is deliberately absent:
|
||||
// its --range is optional — omitted/blank means a whole-sheet read (see
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet).
|
||||
func TestReadData_RequiresRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -100,7 +148,6 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
sc common.Shortcut
|
||||
}{
|
||||
{"+cells-get", CellsGet},
|
||||
{"+csv-get", CsvGet},
|
||||
{"+dropdown-get", DropdownGet},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -114,6 +161,23 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet pins the whole-sheet default:
|
||||
// with --range omitted the request carries the over-wide clip range, so a
|
||||
// full read needs no workbook-info pre-flight (eval: --range was the most
|
||||
// missed required flag on +csv-get once the rest of the surface settled).
|
||||
func TestCsvGet_RangeOptionalDefaultsToFullSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, CsvGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rangeless +csv-get must pass validation, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, csvGetFullSheetRange) {
|
||||
t.Fatalf("dry-run body should carry the full-sheet range %q, got %q", csvGetFullSheetRange, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
|
||||
// directly (white-box).
|
||||
func TestInfoTypeFromInclude(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,12 +129,20 @@ var DimInsert = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-insert"),
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
|
||||
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
|
||||
},
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if dimInsertNeedsBeforeStyleWarning(runtime) {
|
||||
dr.Set("warning_message", dimInsertBeforeStyleWarning)
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -148,6 +157,9 @@ var DimInsert = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dimInsertNeedsBeforeStyleWarning(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -157,8 +169,31 @@ var DimInsert = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
|
||||
// be copied: --inherit-style before at the first row/column, where no
|
||||
// preceding row/column exists. The row/column is still inserted before
|
||||
// --position, just without style inheritance. (--inherit-style after has no
|
||||
// such edge — a plain before-insert always has a following row/column.)
|
||||
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
|
||||
|
||||
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
|
||||
if !runtime.Changed("inherit-style") || runtime.Str("inherit-style") != "before" {
|
||||
return false
|
||||
}
|
||||
// Only the first row/column (idx 0) has no preceding row/column.
|
||||
_, idx, err := parseA1Position(strings.TrimSpace(runtime.Str("position")))
|
||||
return err == nil && idx == 0
|
||||
}
|
||||
|
||||
// dimInsertInput passes --position (1-based row number "3" or column letter
|
||||
// "C") straight to the tool's `position` field; --count maps to `count`.
|
||||
// "C") to the tool's `position` field; --count maps to `count`.
|
||||
//
|
||||
// +dim-insert's public contract is always "insert before --position";
|
||||
// --inherit-style only selects which side's style the new row/column copies,
|
||||
// never the insertion side. The sheet-ai tool always copies the *anchor*
|
||||
// column's style (the target passed as position), regardless of side — so
|
||||
// --inherit-style before is emulated by anchoring one unit earlier. See the
|
||||
// switch below.
|
||||
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -184,11 +219,27 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
"count": count,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
// --inherit-style selects which side's style the blank row/column copies;
|
||||
// the insertion always lands *before* --position. Empirically the addCol
|
||||
// backend copies the *anchor* column's style (the target passed as
|
||||
// position), regardless of side — side only decides whether the blank lands
|
||||
// before or after that anchor (verified live, see
|
||||
// TestDimInsertInheritStyleSideMapping):
|
||||
// after → side=before at P: the blank lands at P and anchor P becomes the
|
||||
// *following* neighbour, so the blank copies it. Position unchanged.
|
||||
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
|
||||
// == insert-before-P) and anchor P-1 becomes the *preceding*
|
||||
// neighbour, so the blank copies it.
|
||||
switch runtime.Str("inherit-style") {
|
||||
case "before":
|
||||
input["side"] = "before"
|
||||
case "after":
|
||||
input["side"] = "after"
|
||||
input["side"] = "before"
|
||||
case "before":
|
||||
if prev, ok := a1PositionBefore(position); ok {
|
||||
input["side"] = "after"
|
||||
input["position"] = prev
|
||||
}
|
||||
// First row/column: no preceding row/column exists, so fall back to a
|
||||
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -203,10 +254,34 @@ var DimDelete = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-delete"),
|
||||
Validate: validateDimRangeOp("delete"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("ranges") {
|
||||
if runtime.Changed("range") {
|
||||
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
|
||||
}
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
}
|
||||
return validateDimRangeOp("delete")(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
if runtime.Changed("ranges") {
|
||||
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
}
|
||||
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
@@ -219,6 +294,21 @@ var DimDelete = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("ranges") {
|
||||
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -232,9 +322,76 @@ var DimDelete = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"Row/column deletion is irreversible. Always preview with --dry-run first.",
|
||||
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one atomic call — the CLI orders positions descending, so indexes never shift under you.`,
|
||||
},
|
||||
}
|
||||
|
||||
// dimDeleteRangesOps parses --ranges into one atomic batch of
|
||||
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
|
||||
// deleting an earlier row shifts every later index up, so ascending
|
||||
// execution deletes the wrong rows — the recurring failure of hand-built
|
||||
// dim-delete batches in eval traces. Same-dimension and non-overlap are
|
||||
// enforced up front.
|
||||
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := requireJSONArray(runtime, "ranges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
|
||||
}
|
||||
if len(raw) > maxBatchRanges {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
|
||||
}
|
||||
type span struct {
|
||||
raw string
|
||||
start, end int
|
||||
}
|
||||
spans := make([]span, 0, len(raw))
|
||||
dimension := ""
|
||||
for i, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
|
||||
}
|
||||
dim, start, end, err := parseA1Range(s)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
|
||||
}
|
||||
if dimension == "" {
|
||||
dimension = dim
|
||||
} else if dim != dimension {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
|
||||
}
|
||||
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
|
||||
for i := 1; i < len(spans); i++ {
|
||||
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
|
||||
// (or duplicate) makes the later delete hit already-shifted positions.
|
||||
if spans[i].end >= spans[i-1].start {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
|
||||
}
|
||||
}
|
||||
ops := make([]interface{}, 0, len(spans))
|
||||
for _, sp := range spans {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "delete",
|
||||
"range": sp.raw,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "modify_sheet_structure",
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// validateDimRangeOp returns a Validate closure that delegates to
|
||||
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
|
||||
// extra `op` argument. Token check happens here; the rest is the builder.
|
||||
@@ -292,7 +449,10 @@ var DimFreeze = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-freeze"),
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --dimension row --count 2 (freezes the first 2 rows; --count 0 unfreezes)",
|
||||
},
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -557,6 +717,23 @@ func columnIndexToLetter(idx int) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
|
||||
// "C" → "B"), preserving row/column form. ok is false when s is the first
|
||||
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
|
||||
// position. Callers validate via parseA1Position first, so in practice ok is
|
||||
// false only at the first row/column.
|
||||
func a1PositionBefore(s string) (pos string, ok bool) {
|
||||
dimension, idx, err := parseA1Position(s)
|
||||
if err != nil || idx == 0 {
|
||||
return "", false
|
||||
}
|
||||
if dimension == "row" {
|
||||
// idx is 0-based; the 1-based number one row earlier is idx itself.
|
||||
return strconv.Itoa(idx), true
|
||||
}
|
||||
return columnIndexToLetter(idx - 1), true
|
||||
}
|
||||
|
||||
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
|
||||
//
|
||||
// Moves a contiguous block of rows or columns to a new index in the same
|
||||
|
||||
@@ -48,6 +48,8 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// --inherit-style before copies the preceding row: anchor row 5 and
|
||||
// insert after it (side=after), so the blank still lands before row 6.
|
||||
name: "+dim-insert row position=6 count=3 inherit-before",
|
||||
sc: DimInsert,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
|
||||
@@ -56,9 +58,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": "6",
|
||||
"position": "5",
|
||||
"count": float64(3),
|
||||
"side": "before",
|
||||
"side": "after",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -169,6 +171,93 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDimInsertInheritStyleSideMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
position string
|
||||
inherit string
|
||||
wantPosition string
|
||||
wantSide string
|
||||
wantSideSet bool
|
||||
}{
|
||||
{
|
||||
name: "after copies the following style with a plain before-insert, position unchanged",
|
||||
position: "D",
|
||||
inherit: "after",
|
||||
wantPosition: "D",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before anchors one column earlier (side=after) to copy the preceding style",
|
||||
position: "D",
|
||||
inherit: "before",
|
||||
wantPosition: "C",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before on a row anchors one row earlier",
|
||||
position: "6",
|
||||
inherit: "before",
|
||||
wantPosition: "5",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before at the first column falls back to a plain before-insert",
|
||||
position: "A",
|
||||
inherit: "before",
|
||||
wantPosition: "A",
|
||||
wantSideSet: false,
|
||||
},
|
||||
{
|
||||
name: "after at the first column still works (before-insert anchors the following)",
|
||||
position: "A",
|
||||
inherit: "after",
|
||||
wantPosition: "A",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "default (flag omitted) omits side, backend inherits the following row/column",
|
||||
position: "D",
|
||||
wantPosition: "D",
|
||||
wantSideSet: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", tc.position, "--count", "1"}
|
||||
if tc.inherit != "" {
|
||||
args = append(args, "--inherit-style", tc.inherit)
|
||||
}
|
||||
body := parseDryRunBody(t, DimInsert, args)
|
||||
got := decodeToolInput(t, body, "modify_sheet_structure")
|
||||
assertInputEquals(t, got, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": tc.wantPosition,
|
||||
"count": float64(1),
|
||||
})
|
||||
|
||||
gv, ok := got["side"]
|
||||
if ok != tc.wantSideSet {
|
||||
t.Fatalf("side presence = %v, want %v (input=%#v)", ok, tc.wantSideSet, got)
|
||||
}
|
||||
if ok && gv != tc.wantSide {
|
||||
t.Fatalf("side = %v, want %q", gv, tc.wantSide)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimRange_Validation covers the A1 range parser's edge cases routed
|
||||
// through +dim-hide (any --range shortcut works; we just need to exercise
|
||||
// the validator).
|
||||
|
||||
272
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
272
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── +styles-put ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Declarative visual spec for EXISTING spreadsheets. Eval attribution
|
||||
// showed ~73% of real +batch-update calls were pure formatting finishers
|
||||
// (style stamps + merges + resizes + freeze) hand-assembled as imperative
|
||||
// operations arrays — the top error surface. +styles-put replaces that
|
||||
// with the {styles:[...]} protocol already shared by +workbook-create /
|
||||
// +table-put --styles (identical vocabulary, parsed by the same
|
||||
// parseWorkbookCreateStyleItem), applied to a live workbook and expanded
|
||||
// client-side into ONE atomic batch_update.
|
||||
//
|
||||
// Per-sheet expansion order (server behavior verified live: style stamps
|
||||
// over merged regions are allowed — the top-left-only restriction applies
|
||||
// to value writes, not styles):
|
||||
//
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze
|
||||
var StylesPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+styles-put",
|
||||
Description: "Apply one declarative visual spec (styles/merges/row-col sizes/freeze) to existing sheets in one atomic batch.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+styles-put"),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +styles-put --url <URL> --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:F1","font_weight":"bold"}],"freeze":{"rows":1}}]}'`,
|
||||
"Same --styles vocabulary as +workbook-create / +table-put; one item per target sheet, name = the real sheet name.",
|
||||
"Style stamps are safe to re-run; the whole spec goes out as one atomic batch.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stylesPutOperations(runtime, token)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
ops, _ := stylesPutOperations(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops, err := stylesPutOperations(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// stylesPutOperations parses --styles ({styles:[...]}, one item per target
|
||||
// sheet) and expands it into the MCP batch_update operations array. Reuses
|
||||
// the shared workbook-create style item parser, so field validation, alias
|
||||
// normalization (border "all" shorthand, style vocabulary) and the
|
||||
// aggregate-all-issues error shape are identical across the three --styles
|
||||
// carriers.
|
||||
func stylesPutOperations(runtime flagView, token string) ([]interface{}, error) {
|
||||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles is required")
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := parseWorkbookCreateStylesItems(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles.styles must be a non-empty array (one item per target sheet)")
|
||||
}
|
||||
var probs []error
|
||||
type sheetSpec struct {
|
||||
name string
|
||||
payload *workbookCreateStylePayload
|
||||
}
|
||||
specs := make([]sheetSpec, 0, len(items))
|
||||
seenName := map[string]bool{}
|
||||
for i, item := range items {
|
||||
path := fmt.Sprintf("--styles.styles[%d]", i)
|
||||
name, _ := item["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name is required (the real sheet name; check +workbook-info)", path))
|
||||
continue
|
||||
}
|
||||
if seenName[name] {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name %q appears twice; merge the two items", path, name))
|
||||
continue
|
||||
}
|
||||
seenName[name] = true
|
||||
payload, itemProbs := parseWorkbookCreateStyleItem(item, path)
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
specs = append(specs, sheetSpec{name: name, payload: payload})
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ops := make([]interface{}, 0, len(specs)*4)
|
||||
var totalCells int64
|
||||
appendVisual := func(name string, op workbookCreateStyleOp) {
|
||||
input, toolName := workbookCreateVisualOpInput(token, "", name, op)
|
||||
if toolName == "" {
|
||||
return
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{"tool_name": toolName, "input": input})
|
||||
}
|
||||
for _, spec := range specs {
|
||||
// merges first so subsequent style stamps see the final grid.
|
||||
for _, m := range spec.payload.CellMerges {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "cell_merge", Range: m.Range, MergeType: m.MergeType})
|
||||
}
|
||||
for _, cs := range coalesceStyleStamps(spec.payload.CellStyles) {
|
||||
rows, cols, err := rangeDimensions(cs.Range)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("styles", "cell_styles range %q: %v", cs.Range, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("styles", cs.Range, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": spec.name,
|
||||
"range": stripSheetPrefix(cs.Range),
|
||||
"cells": fillCellsMatrix(rows, cols, cs.Style),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, rs := range spec.payload.RowSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "row_size", Range: rs.Range, ResizeType: rs.ResizeType, Size: rs.Size})
|
||||
}
|
||||
for _, csz := range spec.payload.ColSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "col_size", Range: csz.Range, ResizeType: csz.ResizeType, Size: csz.Size})
|
||||
}
|
||||
if f := spec.payload.Freeze; f != nil {
|
||||
if f.Rows > 0 {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze_rows", Size: f.Rows})
|
||||
}
|
||||
if f.Cols > 0 {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze_cols", Size: f.Cols})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ops) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("styles",
|
||||
"--styles expands to %d operations even after merging adjacent same-style ranges, over the %d cap; split the spec into several +styles-put calls — and for alternating-row banding or value-dependent coloring use +cond-format-create instead of per-row stamps",
|
||||
len(ops), maxBatchOperations)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// coalesceStyleStamps merges cell_styles entries that carry the IDENTICAL
|
||||
// style into larger rectangles: same column span + contiguous/overlapping
|
||||
// rows fuse vertically, same row span + contiguous columns fuse
|
||||
// horizontally, iterated to a fixpoint. Models routinely emit one entry per
|
||||
// row (07-21 rerun: specs expanding to 184/203/861 operations against the
|
||||
// 100-op cap); a declarative spec describes intent, so execution shape is
|
||||
// the CLI's to optimize. Entries with unparsable ranges pass through
|
||||
// untouched (the per-op validation reports them with proper context).
|
||||
func coalesceStyleStamps(ops []workbookCreateCellStyleOp) []workbookCreateCellStyleOp {
|
||||
if len(ops) < 2 {
|
||||
return ops
|
||||
}
|
||||
type rect struct{ c1, r1, c2, r2 int }
|
||||
type group struct {
|
||||
style map[string]interface{}
|
||||
rects []rect
|
||||
}
|
||||
var order []string
|
||||
groups := map[string]*group{}
|
||||
out := make([]workbookCreateCellStyleOp, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
key, jerr := json.Marshal(op.Style) // map keys marshal sorted → canonical
|
||||
if err != nil || jerr != nil {
|
||||
out = append(out, op)
|
||||
continue
|
||||
}
|
||||
g, ok := groups[string(key)]
|
||||
if !ok {
|
||||
g = &group{style: op.Style}
|
||||
groups[string(key)] = g
|
||||
order = append(order, string(key))
|
||||
}
|
||||
g.rects = append(g.rects, rect{c1, r1, c2, r2})
|
||||
}
|
||||
for _, key := range order {
|
||||
g := groups[key]
|
||||
rects := g.rects
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for i := 0; i < len(rects) && !changed; i++ {
|
||||
for j := i + 1; j < len(rects); j++ {
|
||||
a, b := rects[i], rects[j]
|
||||
var merged rect
|
||||
switch {
|
||||
case a.c1 == b.c1 && a.c2 == b.c2 && b.r1 <= a.r2+1 && a.r1 <= b.r2+1:
|
||||
merged = rect{a.c1, min(a.r1, b.r1), a.c2, max(a.r2, b.r2)}
|
||||
case a.r1 == b.r1 && a.r2 == b.r2 && b.c1 <= a.c2+1 && a.c1 <= b.c2+1:
|
||||
merged = rect{min(a.c1, b.c1), a.r1, max(a.c2, b.c2), a.r2}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
rects[i] = merged
|
||||
rects = append(rects[:j], rects[j+1:]...)
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rc := range rects {
|
||||
out = append(out, workbookCreateCellStyleOp{
|
||||
Range: fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(rc.c1), rc.r1+1,
|
||||
columnIndexToLetter(rc.c2), rc.r2+1),
|
||||
Style: g.style,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripSheetPrefix drops an optional "Sheet!"-style prefix from an A1 range:
|
||||
// the target sheet is already carried by the spec item's name, and the
|
||||
// batch sub-op input names the sheet separately.
|
||||
func stripSheetPrefix(rangeStr string) string {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
return strings.TrimSpace(rangeStr[idx+1:])
|
||||
}
|
||||
return strings.TrimSpace(rangeStr)
|
||||
}
|
||||
347
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
347
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stylesPutView(spec map[string]interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+styles-put", map[string]interface{}{"styles": spec})
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_ExpansionOrder pins the per-sheet expansion:
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze, all inside one
|
||||
// batch_update operations array (server-side order dependence verified live).
|
||||
func TestStylesPutOperations_ExpansionOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Sheet1",
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "A5:A8"}},
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "A1:B1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:B", "type": "pixel", "size": float64(120)}},
|
||||
"freeze": map[string]interface{}{"rows": float64(1), "cols": float64(2)},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantTools := []string{"merge_cells", "set_cell_range", "resize_range", "resize_range", "modify_sheet_structure", "modify_sheet_structure"}
|
||||
if len(ops) != len(wantTools) {
|
||||
t.Fatalf("got %d ops, want %d", len(ops), len(wantTools))
|
||||
}
|
||||
for i, want := range wantTools {
|
||||
op := ops[i].(map[string]interface{})
|
||||
if op["tool_name"] != want {
|
||||
t.Fatalf("ops[%d].tool_name = %v, want %s", i, op["tool_name"], want)
|
||||
}
|
||||
input := op["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "Sheet1" {
|
||||
t.Fatalf("ops[%d] missing sheet_name: %v", i, input)
|
||||
}
|
||||
if input["excel_id"] != testToken {
|
||||
t.Fatalf("ops[%d] missing excel_id", i)
|
||||
}
|
||||
}
|
||||
// The style stamp carries a cells matrix matching the range (1×2).
|
||||
stamp := ops[1].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := stamp["cells"].([][]interface{})
|
||||
if len(cells) != 1 || len(cells[0]) != 2 {
|
||||
t.Fatalf("style stamp matrix = %dx%d, want 1x2", len(cells), len(cells[0]))
|
||||
}
|
||||
// Freeze ops carry the freeze counts.
|
||||
fr := ops[4].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if fr["operation"] != "freeze" || fr["freeze_rows"] != 1 {
|
||||
t.Fatalf("freeze rows op = %v", fr)
|
||||
}
|
||||
fc := ops[5].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if fc["freeze_columns"] != 2 {
|
||||
t.Fatalf("freeze cols op = %v", fc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_Validation pins the aggregate error shape and the
|
||||
// section/name requirements.
|
||||
func TestStylesPutOperations_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing name and empty item aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}}},
|
||||
map[string]interface{}{"name": "S2"},
|
||||
},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, "name is required")
|
||||
if !strings.Contains(ve.Message, "at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze") {
|
||||
t.Fatalf("message %q missing empty-item issue", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate sheet name rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}
|
||||
item2 := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(2)}}
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{item, item2},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "appears twice")
|
||||
})
|
||||
|
||||
t.Run("freeze-only item is valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 1 {
|
||||
t.Fatalf("ops=%d err=%v", len(ops), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all-zero freeze rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(0)}}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "at least one dimension")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesPayloadVocabularyForgiveness pins the 07-20 rerun fixes: the
|
||||
// payload path (--styles cell_styles objects) accepts the same habitual
|
||||
// vocabulary the flag path already normalized — border family folding, wrap
|
||||
// aliases, and enum VALUE canonicalization (CSS center → Lark middle etc.).
|
||||
func TestStylesPayloadVocabularyForgiveness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stamp := func(styleFields map[string]interface{}) ([]interface{}, error) {
|
||||
item := map[string]interface{}{"range": "A1:B1"}
|
||||
for k, v := range styleFields {
|
||||
item[k] = v
|
||||
}
|
||||
return stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{item},
|
||||
}},
|
||||
}), testToken)
|
||||
}
|
||||
cellProto := func(t *testing.T, ops []interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
return cells[0][0].(map[string]interface{})
|
||||
}
|
||||
|
||||
t.Run("vertical_alignment center canonicalizes to middle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"vertical_alignment": "center", "font_weight": "BOLD"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["vertical_alignment"] != "middle" || cs["font_weight"] != "bold" {
|
||||
t.Fatalf("cell_styles = %v, want middle/bold", cs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("off-enum value rejected client-side with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"vertical_alignment": "botom"})
|
||||
requireValidation(t, err, `did you mean "bottom"`)
|
||||
})
|
||||
|
||||
t.Run("boolean wrap_text folds to word_wrap auto-wrap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"wrap_text": true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["word_wrap"] != "auto-wrap" {
|
||||
t.Fatalf("word_wrap = %v, want auto-wrap", cs["word_wrap"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("borders object folds into border_styles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"borders": map[string]interface{}{"style": "solid", "color": "#000000"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top == nil || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles = %v, want all-sides solid", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flattened border_bottom and border_top_color fold per side", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"border_bottom": map[string]interface{}{"style": "solid"},
|
||||
"border_top_color": "#FF0000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
bottom, _ := bs["bottom"].(map[string]interface{})
|
||||
topSide, _ := bs["top"].(map[string]interface{})
|
||||
if bottom["style"] != "solid" || topSide["color"] != "#FF0000" {
|
||||
t.Fatalf("border_styles = %v", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("border_style thin means thin solid line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"border_style": "thin"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top["weight"] != "thin" || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles.top = %v, want thin solid", top)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fore_color prescribes instead of guessing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"fore_color": "#FF0000"})
|
||||
requireValidation(t, err, "fore_color is ambiguous")
|
||||
})
|
||||
|
||||
t.Run("bare string cell_merges accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_merges": []interface{}{"A5:B6"},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A5:B6" || input["merge_type"] != "all" {
|
||||
t.Fatalf("merge op = %v", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesResizeSizeAliases pins the one-way Excel-vocabulary aliases on
|
||||
// the shared styles resize parser: height in row_sizes / width in col_sizes
|
||||
// resolve to size silently; the wrong dimension's word is a targeted error.
|
||||
func TestStylesResizeSizeAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("height aliases to size in row_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "height": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
block := input["resize_height"].(map[string]interface{})
|
||||
if block["value"] != 36 {
|
||||
t.Fatalf("resize_height = %v, want value 36", block)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("width aliases to size in col_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:C", "type": "pixel", "width": float64(120)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong-dimension word is a targeted error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "width": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "does not apply to this array")
|
||||
})
|
||||
|
||||
t.Run("size plus alias together rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36), "height": float64(40)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "either size or height")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimDeleteRangesOps pins the descending-order expansion and the
|
||||
// same-dimension / non-overlap guards.
|
||||
func TestDimDeleteRangesOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := func(ranges ...interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+dim-delete", map[string]interface{}{"ranges": ranges})
|
||||
}
|
||||
|
||||
t.Run("rows execute descending", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := dimDeleteRangesOps(view("5:5", "11:13", "8:8"), testToken, "", "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got []string
|
||||
for _, op := range ops {
|
||||
got = append(got, op.(map[string]interface{})["input"].(map[string]interface{})["range"].(string))
|
||||
}
|
||||
want := []string{"11:13", "8:8", "5:5"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed dimensions rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:5", "C:C"), testToken, "", "S1")
|
||||
requireValidation(t, err, "rows OR columns")
|
||||
})
|
||||
|
||||
t.Run("overlap rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:8", "7:9"), testToken, "", "S1")
|
||||
requireValidation(t, err, "overlap")
|
||||
})
|
||||
|
||||
t.Run("ranges cannot nest inside batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+dim-delete", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"5:5", "8:8"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
}
|
||||
@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
|
||||
return tablePutWrite(ctx, runtime, token, payload, styles)
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
|
||||
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
|
||||
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
|
||||
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
|
||||
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
|
||||
// error, so the retry needs no --print-schema round trip. Field vocabulary
|
||||
// mirrors tableSheetIn.
|
||||
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
|
||||
|
||||
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
|
||||
// validated payload. UseNumber keeps numeric cells as json.Number so large
|
||||
// integers (order IDs, etc.) survive without precision loss or scientific
|
||||
@@ -259,7 +265,19 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
|
||||
Sheets []tableSheetIn `json:"sheets"`
|
||||
}
|
||||
if err := dec.Decode(&wire); err != nil {
|
||||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
// Eval traces show two distinct decode failures that each burned
|
||||
// retries: a field with the wrong JSON kind (columns as objects,
|
||||
// dtypes as an array) — fixed by seeing the expected shape once —
|
||||
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
|
||||
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
var ute *json.UnmarshalTypeError
|
||||
if errors.As(err, &ute) {
|
||||
return nil, verr.WithHint(
|
||||
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
|
||||
tablePutSheetsSkeleton)
|
||||
}
|
||||
return nil, verr.WithHint(
|
||||
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
|
||||
}
|
||||
// Reject trailing non-whitespace after the first JSON value: json.Decoder
|
||||
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
|
||||
@@ -1208,8 +1226,7 @@ var TableGet = common.Shortcut{
|
||||
}
|
||||
sheets = append(sheets, spec)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, map[string]interface{}{"sheets": sheets})
|
||||
},
|
||||
Tips: []string{
|
||||
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
|
||||
@@ -1354,11 +1371,18 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
"value_render_option": "raw_value",
|
||||
"cell_limit": unboundedReadLimit,
|
||||
}
|
||||
// --max-chars binds the char budget (default 500000); --output-path lifts it
|
||||
// to unbounded. Without this the tool applied its own ~50000 default and
|
||||
// silently dropped rows past it with no signal in the +table-get output.
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
sheetSelectorForToolInput(input, t.id, t.name)
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
truncated := cellRangesTruncated(out)
|
||||
grid := extractCellGrid(out)
|
||||
if len(grid) == 0 {
|
||||
return emptySpec(), nil
|
||||
@@ -1433,9 +1457,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
if len(formats) > 0 {
|
||||
spec["formats"] = formats
|
||||
}
|
||||
// The tool clipped the read at max_chars: rows past the cap are missing from
|
||||
// data. Surface it so the caller doesn't mistake a partial read for the whole
|
||||
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
|
||||
if truncated {
|
||||
spec["truncated"] = true
|
||||
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the whole sheet in one lossless pass (no cap). Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
|
||||
// max_chars — either the top-level has_more flag or the first range's truncated
|
||||
// flag. Used by +table-get, whose spec output otherwise drops both signals.
|
||||
func cellRangesTruncated(out interface{}) bool {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if hm, ok := m["has_more"].(bool); ok && hm {
|
||||
return true
|
||||
}
|
||||
ranges, _ := m["ranges"].([]interface{})
|
||||
if len(ranges) > 0 {
|
||||
if r0, ok := ranges[0].(map[string]interface{}); ok {
|
||||
if t, ok := r0["truncated"].(bool); ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
|
||||
// or "" for an empty sheet.
|
||||
//
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/drive"
|
||||
@@ -405,7 +406,11 @@ var SheetCopy = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+sheet-copy"),
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
|
||||
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
|
||||
},
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -914,6 +919,14 @@ type workbookCreateStylePayload struct {
|
||||
RowSizes []workbookCreateResizeOp
|
||||
ColSizes []workbookCreateResizeOp
|
||||
CellMerges []workbookCreateMergeOp
|
||||
Freeze *workbookCreateFreezeOp
|
||||
}
|
||||
|
||||
// workbookCreateFreezeOp freezes the first Rows rows / Cols columns.
|
||||
// Zero means "leave that dimension alone".
|
||||
type workbookCreateFreezeOp struct {
|
||||
Rows int
|
||||
Cols int
|
||||
}
|
||||
|
||||
type workbookCreateCellStyleOp struct {
|
||||
@@ -965,7 +978,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
|
||||
if len(items) != 1 {
|
||||
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
|
||||
}
|
||||
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
|
||||
@@ -988,21 +1005,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
|
||||
}
|
||||
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
|
||||
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
|
||||
var probs []error
|
||||
for i, item := range items {
|
||||
name, _ := item["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
|
||||
continue
|
||||
}
|
||||
if name != payload.Sheets[i].Name {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
|
||||
continue
|
||||
}
|
||||
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
out.ByIndex[i] = style
|
||||
out.ByName[name] = style
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1030,182 +1054,337 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
|
||||
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
|
||||
// are validated even after one fails, and every issue is returned in the
|
||||
// slice: eval traces show agents fixing --styles errors one round trip per
|
||||
// error (border side, then row_sizes.type, then size…) because only the
|
||||
// first was ever reported.
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
|
||||
payload := &workbookCreateStylePayload{}
|
||||
var err error
|
||||
var probs []error
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["row_sizes"]; ok {
|
||||
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["col_sizes"]; ok {
|
||||
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["cell_merges"]; ok {
|
||||
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
var errsHere []error
|
||||
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["freeze"]; ok {
|
||||
freeze, err := parseWorkbookCreateFreezeOp(raw, path+".freeze")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
} else {
|
||||
payload.Freeze = freeze
|
||||
}
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
|
||||
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
|
||||
if len(probs) > 0 {
|
||||
return nil, probs
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 && payload.Freeze == nil {
|
||||
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze", path)}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
|
||||
// parseWorkbookCreateFreezeOp parses a {rows, cols} freeze section. At least
|
||||
// one dimension must be positive — an all-zero freeze is a no-op the caller
|
||||
// almost certainly didn't mean.
|
||||
func parseWorkbookCreateFreezeOp(raw interface{}, path string) (*workbookCreateFreezeOp, error) {
|
||||
obj, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an object like {\"rows\":1} or {\"rows\":1,\"cols\":2}", path)
|
||||
}
|
||||
out := &workbookCreateFreezeOp{}
|
||||
for k, v := range obj {
|
||||
n, isNum := v.(float64)
|
||||
if !isNum || n != float64(int(n)) || n < 0 {
|
||||
return nil, common.ValidationErrorf("%s.%s must be a non-negative integer", path, k)
|
||||
}
|
||||
switch k {
|
||||
case "rows":
|
||||
out.Rows = int(n)
|
||||
case "cols", "columns":
|
||||
out.Cols = int(n)
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported field (want rows/cols)", path, k)
|
||||
}
|
||||
}
|
||||
if out.Rows == 0 && out.Cols == 0 {
|
||||
return nil, common.ValidationErrorf("%s must freeze at least one dimension (rows or cols > 0)", path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// joinStyleValidationErrors folds the issues collected across one --styles
|
||||
// parse into a single typed error that lists them all, so the caller can fix
|
||||
// the whole payload in one retry instead of one error per round trip.
|
||||
func joinStyleValidationErrors(probs []error) error {
|
||||
switch len(probs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return probs[0]
|
||||
}
|
||||
const maxShown = 8
|
||||
msgs := make([]string, 0, len(probs))
|
||||
for _, e := range probs {
|
||||
if p, ok := errs.ProblemOf(e); ok {
|
||||
msgs = append(msgs, p.Message)
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, e.Error())
|
||||
}
|
||||
suffix := ""
|
||||
if len(msgs) > maxShown {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
|
||||
msgs = msgs[:maxShown]
|
||||
}
|
||||
return common.ValidationErrorf("--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix)
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
|
||||
}
|
||||
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
|
||||
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
|
||||
}
|
||||
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateMergeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
|
||||
}
|
||||
mergeType = strings.TrimSpace(v)
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
|
||||
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
|
||||
// A bare range string means {range: s, merge_type: all} — the only
|
||||
// possible reading (07-20 eval hit).
|
||||
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
|
||||
raw = map[string]interface{}{"range": strings.TrimSpace(s)}
|
||||
}
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
|
||||
}
|
||||
mergeType = normalizeMergeType(strings.TrimSpace(v))
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
|
||||
}
|
||||
|
||||
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
|
||||
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
|
||||
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
|
||||
// the caller's enum check to reject.
|
||||
func normalizeMergeType(v string) string {
|
||||
lower := strings.ToLower(v)
|
||||
lower = strings.TrimPrefix(lower, "merge_")
|
||||
switch lower {
|
||||
case "all", "rows", "columns":
|
||||
return lower
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateResizeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
// resizeOpExample renders a complete valid op for the dimension, inlined on
|
||||
// every type/size error: eval traces show the field errors chaining (type
|
||||
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
|
||||
// trip, because no error ever showed a whole valid op at once.
|
||||
func resizeOpExample(dimension string) string {
|
||||
if dimension == "column" {
|
||||
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
|
||||
}
|
||||
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType != "" {
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
|
||||
}
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
size := 0
|
||||
if raw, ok := op["size"]; ok {
|
||||
n, ok := util.ToFloat64(raw)
|
||||
if !ok || n <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
|
||||
}
|
||||
return ops, nil
|
||||
// size is the canonical dimension key (uniform across row_sizes and
|
||||
// col_sizes — the array name already carries the dimension). The Excel-
|
||||
// vocabulary alias (height on rows, width on columns) is accepted
|
||||
// silently; the WRONG dimension's word is a targeted error, never a
|
||||
// silent rewrite.
|
||||
alias, wrongDim := "height", "width"
|
||||
if dimension == "column" {
|
||||
alias, wrongDim = "width", "height"
|
||||
}
|
||||
if _, has := op[wrongDim]; has {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.%s does not apply to this array (the array name carries the dimension); use size, e.g. %s", path, wrongDim, resizeOpExample(dimension))
|
||||
}
|
||||
sizeRaw, hasSize := op["size"]
|
||||
if aliasRaw, hasAlias := op[alias]; hasAlias {
|
||||
if hasSize {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s: give either size or %s, not both", path, alias)
|
||||
}
|
||||
sizeRaw, hasSize = aliasRaw, true
|
||||
}
|
||||
size := 0
|
||||
if hasSize {
|
||||
n, ok := util.ToFloat64(sizeRaw)
|
||||
if !ok || n <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
// type is optional ceremony when a pixel size is given: {range, size}
|
||||
// means a pixel resize, exactly as --width/--height without --type does
|
||||
// on the flag path. Explicit standard/auto still needs type.
|
||||
if resizeType == "" {
|
||||
if size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s needs size (px) or type (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
resizeType = "pixel"
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size", alias); err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
|
||||
}
|
||||
|
||||
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
|
||||
@@ -1245,6 +1424,9 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := foldBorderFamilyAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeCellStyleAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1259,15 +1441,26 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
|
||||
}
|
||||
expandBorderAllShorthand(m)
|
||||
if err := validateWorkbookBorderStyles(m, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["border_styles"] = m
|
||||
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
|
||||
return nil, common.ValidationErrorf("%s is for styles only; put content in --values or use --sheets for typed cell objects", path)
|
||||
return nil, common.ValidationErrorf("%s.%s is a content field — a styles spec carries no cell content; write values/formulas via +cells-set or +table-put", path, k)
|
||||
default:
|
||||
if !workbookCreateCellStyleField(k) {
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported style field", path, k)
|
||||
// Universal rejection with did-you-mean + the full field list:
|
||||
// this is the mechanism that absorbs the infinite tail of
|
||||
// spelling permutations at a fixed one-retry cost — silent
|
||||
// aliases are reserved for high-frequency words from real
|
||||
// external vocabularies (see the style_vocab.go contract).
|
||||
msg := fmt.Sprintf("%s.%s is not a supported style field", path, k)
|
||||
if match := suggest.Closest(strings.ToLower(k), workbookCreateCellStyleFieldList, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
msg += "; supported: " + strings.Join(workbookCreateCellStyleFieldList, ", ")
|
||||
return nil, common.ValidationErrorf("%s", msg)
|
||||
}
|
||||
cellStyle[k] = v
|
||||
}
|
||||
@@ -1278,6 +1471,14 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// workbookCreateCellStyleFieldList is the canonical style vocabulary plus the
|
||||
// two border carriers, in display order for the unknown-field hint.
|
||||
var workbookCreateCellStyleFieldList = []string{
|
||||
"font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap", "border", "border_styles",
|
||||
}
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
@@ -1299,7 +1500,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
|
||||
switch side {
|
||||
case "top", "bottom", "left", "right":
|
||||
default:
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
|
||||
}
|
||||
spec, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -1516,7 +1717,7 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
if styles == nil {
|
||||
return nil
|
||||
}
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes))
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes)+2)
|
||||
for _, op := range styles.CellMerges {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
|
||||
}
|
||||
@@ -1526,6 +1727,14 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
for _, op := range styles.ColSizes {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||||
}
|
||||
if styles.Freeze != nil {
|
||||
if styles.Freeze.Rows > 0 {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "freeze_rows", Size: styles.Freeze.Rows})
|
||||
}
|
||||
if styles.Freeze.Cols > 0 {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "freeze_cols", Size: styles.Freeze.Cols})
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
@@ -1564,6 +1773,18 @@ func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCr
|
||||
input["resize_width"] = block
|
||||
}
|
||||
return input, "resize_range"
|
||||
case "freeze_rows", "freeze_cols":
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "freeze",
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if op.Kind == "freeze_rows" {
|
||||
input["freeze_rows"] = op.Size
|
||||
} else {
|
||||
input["freeze_columns"] = op.Size
|
||||
}
|
||||
return input, "modify_sheet_structure"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user