mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
12 Commits
feat/chart
...
v1.0.73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40840915c7 | ||
|
|
fb57e17905 | ||
|
|
4cdfa2fcda | ||
|
|
3c2cc273f7 | ||
|
|
b52677269e | ||
|
|
78390f8ea1 | ||
|
|
d6cebd6723 | ||
|
|
79adf89beb | ||
|
|
9dd355a52d | ||
|
|
7b989948c4 | ||
|
|
6ff10229fd | ||
|
|
21cff2e2dd |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,33 @@
|
||||
|
||||
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
|
||||
@@ -1552,6 +1579,7 @@ 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
|
||||
|
||||
107
events/application/menu.go
Normal file
107
events/application/menu.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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 != ""
|
||||
}
|
||||
227
events/application/menu_test.go
Normal file
227
events/application/menu_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// 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
|
||||
}
|
||||
31
events/application/register.go
Normal file
31
events/application/register.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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,6 +5,7 @@
|
||||
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"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
application.Keys(),
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
@@ -18,75 +14,12 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "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.
|
||||
// "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.
|
||||
func RequireConfirmation(action string) error {
|
||||
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, "'", `'\''`) + "'"
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
@@ -35,11 +35,8 @@ 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)
|
||||
}
|
||||
// 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.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -64,8 +61,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the retry line lives in
|
||||
// the free-text hint only; the typed protocol stays action-only.
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
@@ -81,46 +78,3 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,12 +19,18 @@ 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")
|
||||
@@ -34,12 +40,11 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
body, err := commentBody(*eventPath)
|
||||
diags, err := auditEvent(*eventPath, *kind)
|
||||
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)))
|
||||
}
|
||||
@@ -47,32 +52,44 @@ 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) (string, error) {
|
||||
func commentBody(path string) (commentContent, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return payload.Comment.Body, nil
|
||||
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
|
||||
case payload.Review != nil:
|
||||
return payload.Review.Body, nil
|
||||
return commentContent{Body: payload.Review.Body}, nil
|
||||
default:
|
||||
return "", nil
|
||||
return commentContent{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
@@ -32,11 +34,92 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
if got.Body != "clean comment" || got.Path != "" {
|
||||
t.Fatalf("comment content = %#v", 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,10 +6,11 @@ package diff
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
|
||||
@@ -122,8 +123,7 @@ func writeFile(t *testing.T, repo, rel, content string) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
@@ -131,8 +131,7 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func gitOutput(t *testing.T, repo string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v", args, err)
|
||||
|
||||
@@ -6,10 +6,11 @@ package publiccontent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
@@ -23,9 +24,10 @@ 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 = "example-public-key"
|
||||
api_`+`key = "`+providerValue+`"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
@@ -199,13 +201,14 @@ 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":"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"}`,
|
||||
`{"access_` + `token":"` + providerValue + `"}`,
|
||||
`{"client_` + `secret": "` + providerValue + `"}`,
|
||||
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
|
||||
`{"github` + `Token":"` + providerValue + `"}`,
|
||||
`{"vendorApi` + `Key":"` + providerValue + `"}`,
|
||||
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
@@ -215,14 +218,7 @@ 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{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -306,8 +302,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
if count != 2 {
|
||||
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,12 +334,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -358,15 +354,11 @@ func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.
|
||||
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" {
|
||||
count++
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
@@ -374,7 +366,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" + "IAIOSFODNN7EXAMPX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -391,7 +383,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
if strings.Contains(item.Excerpt, accessKey) {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
@@ -432,7 +424,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -448,15 +440,11 @@ func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(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" {
|
||||
count++
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
@@ -489,12 +477,13 @@ 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: 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",
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
@@ -506,13 +495,7 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -621,7 +604,8 @@ 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")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
@@ -685,10 +669,11 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
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")
|
||||
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")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
@@ -855,8 +840,7 @@ 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 := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
@@ -865,8 +849,7 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
return ScanCommentAtPath(kind, "", body)
|
||||
}
|
||||
|
||||
func ScanCommentAtPath(kind, path, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
return scanText(kind, "comment", body, false)
|
||||
if path == "" {
|
||||
path = kind
|
||||
}
|
||||
return scanText(path, "comment", body, isDetectorRuleFile(path))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
@@ -17,3 +20,60 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
88
internal/qualitygate/publiccontent/credential.go
Normal file
88
internal/qualitygate/publiccontent/credential.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// 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*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\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*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\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,33 +383,63 @@ func anglePlaceholderIdentifier(value string) bool {
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
return providerCredentialIdentifier(value)
|
||||
}
|
||||
|
||||
func providerCredentialIdentifier(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
switch {
|
||||
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")):
|
||||
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):
|
||||
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,15 +47,30 @@ 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 _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
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
|
||||
}
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
@@ -64,7 +79,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(match[0])))
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
@@ -123,21 +138,43 @@ 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, value, ok := normalizedCredentialAssignment(match)
|
||||
name, _, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
@@ -288,7 +325,7 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
@@ -323,52 +360,8 @@ 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, "${{"), "}}"))
|
||||
}
|
||||
@@ -488,17 +481,20 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
if !sourceCodeFile(file) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
@@ -518,17 +514,16 @@ func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
|
||||
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
rest := strings.TrimSpace(line[matchStart+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
@@ -536,7 +531,12 @@ func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
|
||||
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
|
||||
if parsed == nil {
|
||||
return rhs, true
|
||||
}
|
||||
return credentialAssignmentValue(parsed), 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", ".ts", ".tsx":
|
||||
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -593,6 +593,7 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeStringExpressionLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
@@ -685,6 +686,18 @@ 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":
|
||||
@@ -753,7 +766,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
|
||||
@@ -980,6 +993,7 @@ func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"p%40ss",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
|
||||
@@ -251,26 +251,22 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(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" {
|
||||
count++
|
||||
t.Fatalf("readable credential words should not be findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
|
||||
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
|
||||
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
|
||||
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
|
||||
paddedToken := base64PaddedFixture(paddedTokenPrefix)
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
@@ -294,17 +290,25 @@ 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) {
|
||||
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"
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
jsonToken := providerValue
|
||||
jsonSecret := providerValue
|
||||
jsonKey := providerValue
|
||||
jsonTenantToken := providerValue
|
||||
jsonAppSecret := providerValue
|
||||
jsonPrefixedKey := providerValue
|
||||
jsonTenantCamelToken := providerValue
|
||||
jsonGithubToken := providerValue
|
||||
jsonVendorKey := providerValue
|
||||
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_` + `token":"` + jsonToken + `"}`,
|
||||
`{"client_` + `secret": "` + jsonSecret + `"}`,
|
||||
@@ -334,12 +338,13 @@ 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: 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",
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -347,13 +352,7 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -364,85 +363,77 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(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" {
|
||||
count++
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
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"
|
||||
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++
|
||||
}
|
||||
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},
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
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++
|
||||
}
|
||||
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},
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(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" {
|
||||
count++
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -593,18 +584,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
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++
|
||||
}
|
||||
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},
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +639,7 @@ 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"`,
|
||||
@@ -821,26 +813,36 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(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"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
t.Fatalf("token field names alone should not produce findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
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)
|
||||
@@ -848,8 +850,114 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
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"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -927,6 +1035,22 @@ 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=***`,
|
||||
@@ -941,22 +1065,18 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
func TestScanFileAllowsPartiallyMaskedCredentialValues(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" {
|
||||
count++
|
||||
t.Fatalf("partially masked values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
@@ -972,6 +1092,7 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
@@ -980,32 +1101,47 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
text: `const clientSecret: string = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "typescript numeric password",
|
||||
name: "typescript terminated secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
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 + `"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
text: `self.client_secret: str = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
text: `self.client_secret: str | None = "` + providerValue + `"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -1018,24 +1154,154 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`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"))
|
||||
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 != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
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 + `"`,
|
||||
}, "\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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,9 +1382,10 @@ 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":"real-client-secret-value"}`,
|
||||
`{"client_token":"` + githubToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1152,9 +1419,10 @@ 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": "real-client-secret-value" }`,
|
||||
`{ "block_token": "` + githubToken + `" }`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1368,39 +1636,43 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(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" {
|
||||
count++
|
||||
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
|
||||
}
|
||||
}
|
||||
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"
|
||||
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++
|
||||
}
|
||||
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},
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,6 +14,7 @@ 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,7 +203,8 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
publicDoc := "api_" + "key = \"" + providerValue + "\"\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)
|
||||
@@ -599,7 +600,8 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
|
||||
cmd := gitcmd.Command(repo, commandArgs...)
|
||||
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 {
|
||||
|
||||
55
internal/testutil/gitcmd/gitcmd.go
Normal file
55
internal/testutil/gitcmd/gitcmd.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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))
|
||||
}
|
||||
47
internal/testutil/gitcmd/gitcmd_test.go
Normal file
47
internal/testutil/gitcmd/gitcmd_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,12 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
|
||||
|
||||
func runGit(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
commandArgs := []string{
|
||||
"-c", "maintenance.autoDetach=false",
|
||||
"-c", "gc.autoDetach=false",
|
||||
}
|
||||
commandArgs = append(commandArgs, args...)
|
||||
cmd := exec.Command("git", commandArgs...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -43,6 +48,14 @@ 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.72",
|
||||
"version": "1.0.73",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
469
shortcuts/apps/apps_automation_skill_contract_test.go
Normal file
469
shortcuts/apps/apps_automation_skill_contract_test.go
Normal file
@@ -0,0 +1,469 @@
|
||||
// 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,7 +8,6 @@ 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 {
|
||||
@@ -74,32 +73,3 @@ 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,57 +57,3 @@ 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,10 +17,11 @@ import (
|
||||
var AppsGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+get",
|
||||
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
|
||||
Description: "Get a single app's detail by app ID or meta token (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'",
|
||||
},
|
||||
@@ -28,7 +29,7 @@ var AppsGet = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "app-id", Desc: "app ID or meta token", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -40,7 +41,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, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
Desc("Get app detail (returns app_id, meta_token, 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"))
|
||||
@@ -54,6 +55,9 @@ 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,7 +14,6 @@ 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"
|
||||
)
|
||||
@@ -38,9 +37,13 @@ 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 {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
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")
|
||||
@@ -73,9 +76,11 @@ 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 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")
|
||||
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>"})
|
||||
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
@@ -123,16 +128,7 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
out, err := runHTMLPublishTOS(ctx, rctx, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -264,25 +260,7 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
|
||||
return tarball, nil
|
||||
}
|
||||
|
||||
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 →
|
||||
// runHTMLPublishTOS handles the 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,7 +5,6 @@ package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -23,20 +22,6 @@ 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()
|
||||
@@ -46,71 +31,19 @@ func writeAppsSampleSite(t *testing.T) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
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 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"})
|
||||
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), "/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 应该被拦
|
||||
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
@@ -121,13 +54,9 @@ func TestRunHTMLPublish_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 TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
// 目录含 index.html 应该正常走完
|
||||
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
@@ -135,57 +64,49 @@ func TestRunHTMLPublish_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)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called when index.html present")
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
// 单文件形态:文件名不是 index.html 也要拦
|
||||
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), 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 TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
// 单文件形态:文件名恰好就是 index.html → 放行
|
||||
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called for single index.html")
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
// 把上限调到 100 字节验证拦截,defer 恢复原值避免污染其它测试。
|
||||
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -194,8 +115,7 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
@@ -206,9 +126,6 @@ func TestRunHTMLPublish_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) {
|
||||
@@ -264,8 +181,17 @@ 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/upload_and_release_html_code") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
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, "index.html") {
|
||||
t.Fatalf("dry-run missing file list: %s", got)
|
||||
@@ -500,9 +426,7 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
|
||||
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
@@ -510,9 +434,6 @@ 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) {
|
||||
@@ -555,8 +476,7 @@ 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)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
@@ -567,13 +487,9 @@ 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 TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
@@ -585,12 +501,12 @@ func TestRunHTMLPublish_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)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,15 +74,18 @@ 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 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},
|
||||
// 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},
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
@@ -122,9 +125,13 @@ 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 {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
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)
|
||||
@@ -334,11 +341,19 @@ 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 string) error {
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", email)
|
||||
}
|
||||
|
||||
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
|
||||
@@ -400,13 +415,16 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
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))
|
||||
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 err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if !policy.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))
|
||||
}
|
||||
@@ -436,26 +454,38 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
return base
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
CommitAuthorName string `json:"commit_author_name"`
|
||||
CommitAuthorEmail string `json:"commit_author_email"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
}
|
||||
return env.Data.RepositoryURL, nil
|
||||
return credentialInitResult{
|
||||
RepositoryURL: env.Data.RepositoryURL,
|
||||
CommitAuthorName: env.Data.CommitAuthorName,
|
||||
CommitAuthorEmail: env.Data.CommitAuthorEmail,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
|
||||
@@ -527,7 +557,10 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
appType, err := queryAppType(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
policy := policyForAppType(appType)
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
@@ -595,16 +628,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
initLogf(rctx, "Issuing repository credentials for %s...", appID)
|
||||
repoURL, err := issueCredentials(ctx, rctx, appID)
|
||||
cred, err := issueCredentials(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRepoURLScheme(repoURL); err != nil {
|
||||
if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
|
||||
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
@@ -612,9 +645,10 @@ 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; only sets
|
||||
// repo-local defaults when none is configured (existing identity is kept).
|
||||
if err := ensureGitIdentity(ctx, dir); err != nil {
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -643,7 +677,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
"repository_url": redactURLCredentials(cred.RepositoryURL),
|
||||
"branch": defaultInitBranch,
|
||||
"clone_path": dir,
|
||||
"scaffold": scaffold,
|
||||
@@ -721,10 +755,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) (string, error) {
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
return credentialInitResult{}, 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 != "" {
|
||||
@@ -732,11 +766,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
return credentialInitResult{}, 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 parseRepoURLFromEnvelope(stdout)
|
||||
return parseCredentialInitEnvelope(stdout)
|
||||
}
|
||||
|
||||
// commitAndPushIfDirty commits and pushes only when the working tree has
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -110,18 +111,24 @@ func TestDefaultCloneDir(t *testing.T) {
|
||||
// --- pure-function tests ---
|
||||
|
||||
func TestParseRepoURL(t *testing.T) {
|
||||
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
|
||||
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"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "http://u:t@h/app_x.git" {
|
||||
t.Errorf("got %q", url)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 := parseRepoURLFromEnvelope(in); err == nil {
|
||||
if _, err := parseCredentialInitEnvelope(in); err == nil {
|
||||
t.Errorf("expected error for %q", in)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +156,22 @@ 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 + `"}}`}
|
||||
}
|
||||
@@ -313,7 +336,8 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||||
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -354,7 +378,8 @@ 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, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
@@ -423,7 +448,8 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
"git status": {}, // clean tree after scaffold -> no commit/push
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -472,7 +498,8 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -542,7 +569,8 @@ 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, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -616,7 +644,8 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
dir := relCloneDir(t)
|
||||
|
||||
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
|
||||
@@ -722,7 +751,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) {
|
||||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -732,12 +761,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr, reg
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -766,7 +795,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
|
||||
factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
|
||||
stubAppType(reg2, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -829,7 +859,8 @@ 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, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -870,7 +901,8 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A src/app.ts\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -890,7 +922,8 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -910,7 +943,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -929,8 +963,7 @@ 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 := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd := gitcmd.Command(dir, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
|
||||
@@ -946,6 +979,7 @@ 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)
|
||||
@@ -1067,6 +1101,7 @@ 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)
|
||||
|
||||
@@ -1289,7 +1324,8 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
|
||||
"env-pull": envPullOK("/abs/app_x/.env.local"),
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -1327,7 +1363,8 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
@@ -1366,7 +1403,8 @@ 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, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
}
|
||||
@@ -1413,7 +1451,8 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
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)
|
||||
}
|
||||
@@ -1705,13 +1744,15 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPolicyForAppType(t *testing.T) {
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
|
||||
t.Errorf("policy for %q = %+v, want zero value", at, p)
|
||||
}
|
||||
}
|
||||
@@ -1757,7 +1798,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 {
|
||||
@@ -1774,7 +1815,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 {
|
||||
@@ -1790,7 +1831,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,22 +13,25 @@ import (
|
||||
)
|
||||
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// 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))
|
||||
// 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))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
|
||||
return ""
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app object")
|
||||
}
|
||||
appType, _ := appRaw["app_type"].(string)
|
||||
return strings.ToLower(appType)
|
||||
if strings.TrimSpace(appType) == "" {
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
|
||||
}
|
||||
return strings.ToLower(appType), nil
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ func TestQueryAppType_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_test")
|
||||
result, err := queryAppType(context.Background(), rt, "app_test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != "modern_html" {
|
||||
t.Errorf("queryAppType = %q, want modern_html", result)
|
||||
}
|
||||
@@ -65,7 +68,10 @@ func TestQueryAppType_FullStack(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_fs")
|
||||
result, err := queryAppType(context.Background(), rt, "app_fs")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != "full_stack" {
|
||||
t.Errorf("queryAppType = %q, want full_stack", result)
|
||||
}
|
||||
@@ -87,7 +93,10 @@ func TestQueryAppType_Html(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_html")
|
||||
result, err := queryAppType(context.Background(), rt, "app_html")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != "html" {
|
||||
t.Errorf("queryAppType = %q, want html", result)
|
||||
}
|
||||
@@ -102,9 +111,9 @@ func TestQueryAppType_APIError(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_bad")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty on error", result)
|
||||
_, err := queryAppType(context.Background(), rt, "app_bad")
|
||||
if err == nil {
|
||||
t.Error("expected error on API failure")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +128,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_no")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app object missing", result)
|
||||
_, err := queryAppType(context.Background(), rt, "app_no")
|
||||
if err == nil {
|
||||
t.Error("expected error when app object missing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +150,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_empty")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
|
||||
_, err := queryAppType(context.Background(), rt, "app_empty")
|
||||
if err == nil {
|
||||
t.Error("expected error when app_type is empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,13 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
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,9 +30,13 @@ 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 {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
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,6 +41,21 @@ 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,6 +75,7 @@ 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))
|
||||
},
|
||||
@@ -90,6 +91,12 @@ 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
|
||||
}
|
||||
@@ -461,11 +468,13 @@ 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"),
|
||||
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"),
|
||||
}
|
||||
if issued.AppID == "" {
|
||||
issued.AppID = appID
|
||||
|
||||
@@ -87,6 +87,7 @@ 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,7 +129,13 @@ 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}
|
||||
result := &InitResult{
|
||||
AppID: appID,
|
||||
GitHTTPURL: url,
|
||||
Refreshed: previous != nil,
|
||||
CommitAuthorName: issued.CommitAuthorName,
|
||||
CommitAuthorEmail: issued.CommitAuthorEmail,
|
||||
}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
|
||||
@@ -51,18 +51,22 @@ type CredentialRecord struct {
|
||||
}
|
||||
|
||||
type IssuedCredential struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
}
|
||||
|
||||
type InitResult struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
}
|
||||
|
||||
type RemoveResult struct {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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 ""
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// 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,9 +469,6 @@ 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"])
|
||||
}
|
||||
@@ -577,8 +574,9 @@ 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)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -587,8 +585,9 @@ 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)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -597,7 +596,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").
|
||||
|
||||
790
shortcuts/calendar/calendar_room_check.go
Normal file
790
shortcuts/calendar/calendar_room_check.go
Normal file
@@ -0,0 +1,790 @@
|
||||
// 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,3 +3368,952 @@ 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,6 +36,7 @@ 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)
|
||||
@@ -219,6 +220,50 @@ 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
|
||||
@@ -246,6 +291,33 @@ 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").
|
||||
@@ -278,7 +350,7 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return d
|
||||
}
|
||||
|
||||
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarID, eventID := calendarUpdateIDs(runtime)
|
||||
if eventID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
@@ -289,6 +361,12 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
|
||||
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,11 +14,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
permissionGrantPermHint = "可管理权限"
|
||||
PermissionGrantGranted = "granted"
|
||||
PermissionGrantSkipped = "skipped"
|
||||
PermissionGrantFailed = "failed"
|
||||
permissionGrantPerm = "full_access"
|
||||
)
|
||||
|
||||
// AutoGrantCurrentUserDrivePermission grants full_access on a newly created
|
||||
@@ -121,7 +120,7 @@ func buildPermissionGrantResult(status, userOpenID, message, reason string) map[
|
||||
}
|
||||
|
||||
func permissionGrantPermMessage() string {
|
||||
return permissionGrantPerm + " (" + permissionGrantPermHint + ")"
|
||||
return permissionGrantPerm
|
||||
}
|
||||
|
||||
func permissionGrantPermType(resourceType string) string {
|
||||
|
||||
@@ -31,6 +31,14 @@ 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,11 +173,9 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
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"])
|
||||
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(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-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
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("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``",
|
||||
"`任务``群聊卡片`",
|
||||
"`多维表格``多维表格``OKR`",
|
||||
"`Task``Chat card`",
|
||||
"`Base``Base``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|缺陷"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|error"},
|
||||
{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|缺陷",
|
||||
"keyword": "bug|error",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
|
||||
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 docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ 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(),
|
||||
@@ -34,8 +36,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 !strings.Contains(flag.Desc, "@reference-map.json") {
|
||||
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
|
||||
if flag.Desc != wantDesc {
|
||||
t.Fatalf("reference-map help = %q, want English description %q", flag.Desc, wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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\":\"文本信息\"}]'", err).WithParam("--content")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"Example 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,6 +90,7 @@ 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"`
|
||||
}
|
||||
@@ -108,6 +109,10 @@ 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) {
|
||||
@@ -178,7 +183,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.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -108,6 +108,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
@@ -117,6 +118,10 @@ 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.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
|
||||
dry := DriveUpload.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
@@ -1100,7 +1100,8 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
PostUploadNote string `json:"post_upload_note"`
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
@@ -1123,6 +1124,10 @@ 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,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -71,3 +72,18 @@ 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,10 +156,26 @@ 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
|
||||
},
|
||||
|
||||
@@ -223,24 +223,6 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-create-basic",
|
||||
sc: ChartCreateBasic,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-type", "column", "--data-range", "A1:C10", "--title", "Sales", "--data-labels", "value", "--anchor-cell", "F2"},
|
||||
subInput: `{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales","data-labels":"value","anchor-cell":"F2"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-config-update",
|
||||
sc: ChartConfigUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--title", "Updated", "--data-labels", "category", "--data-label-position", "top"},
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1","title":"Updated","data-labels":"category","data-label-position":"top"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-data-update",
|
||||
sc: ChartDataUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "'Sheet1'!A1:M6", "--data-direction", "column", "--dim1-index", "1", "--dim2-indexes", "4,8"},
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"'Sheet1'!A1:M6","data-direction":"column","dim1-index":1,"dim2-indexes":"4,8"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+pivot-create",
|
||||
sc: PivotCreate,
|
||||
@@ -442,22 +424,6 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
subInput: `{}`,
|
||||
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+chart-data-update invalid dim1 index",
|
||||
shortcut: ChartDataUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "0"},
|
||||
subShortcut: "+chart-data-update",
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":0}`,
|
||||
wantContains: "--dim1-index must be a positive 1-based index",
|
||||
},
|
||||
{
|
||||
name: "+chart-data-update dim1 and dim2 conflict",
|
||||
shortcut: ChartDataUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "2", "--dim2-indexes", "2,3"},
|
||||
subShortcut: "+chart-data-update",
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":2,"dim2-indexes":"2,3"}`,
|
||||
wantContains: "--dim2-indexes must not contain the dim1 index 2",
|
||||
},
|
||||
{
|
||||
name: "+float-image-create both image-token and image-uri",
|
||||
shortcut: FloatImageCreate,
|
||||
@@ -696,18 +662,6 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
|
||||
"--chart-id is required",
|
||||
},
|
||||
{
|
||||
"+chart-data-update missing --chart-id",
|
||||
"+chart-data-update",
|
||||
`{"sheet-id":"sh1","data-range":"A1:C4"}`,
|
||||
"--chart-id is required",
|
||||
},
|
||||
{
|
||||
"+chart-data-update missing --data-range",
|
||||
"+chart-data-update",
|
||||
`{"sheet-id":"sh1","chart-id":"c1"}`,
|
||||
"--data-range is required",
|
||||
},
|
||||
{
|
||||
"+filter-create missing --range",
|
||||
"+filter-create",
|
||||
|
||||
@@ -168,12 +168,9 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
|
||||
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
|
||||
"+chart-create-basic": {"manage_chart_object", chartCreateBasicInput},
|
||||
"+chart-config-update": {"manage_chart_object", chartConfigUpdateInput},
|
||||
"+chart-data-update": {"manage_chart_object", chartDataUpdateInput},
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
|
||||
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
|
||||
|
||||
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
|
||||
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1277,14 +1277,13 @@
|
||||
"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)",
|
||||
"desc": "Comma-separated info categories to include",
|
||||
"enum": [
|
||||
"value",
|
||||
"formula",
|
||||
"style",
|
||||
"comment",
|
||||
"data_validation",
|
||||
"truncation"
|
||||
"data_validation"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1292,16 +1291,9 @@
|
||||
"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.",
|
||||
"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": "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",
|
||||
@@ -1408,16 +1400,9 @@
|
||||
"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.",
|
||||
"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": "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",
|
||||
@@ -1480,21 +1465,6 @@
|
||||
"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",
|
||||
@@ -3249,559 +3219,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+chart-create-basic": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"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": "chart-type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Chart type",
|
||||
"enum": [
|
||||
"column",
|
||||
"bar",
|
||||
"line",
|
||||
"area",
|
||||
"pie",
|
||||
"scatter",
|
||||
"combo",
|
||||
"radar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "data-range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"
|
||||
},
|
||||
{
|
||||
"name": "data-direction",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Data series direction; column uses the first column as categories, row uses the first row",
|
||||
"default": "column",
|
||||
"enum": [
|
||||
"column",
|
||||
"row"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Chart title"
|
||||
},
|
||||
{
|
||||
"name": "subtitle",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Chart subtitle"
|
||||
},
|
||||
{
|
||||
"name": "legend-position",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Legend position; hidden removes the legend",
|
||||
"enum": [
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"hidden"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "x-axis-title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "X-axis title"
|
||||
},
|
||||
{
|
||||
"name": "y-axis-title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Left Y-axis title"
|
||||
},
|
||||
{
|
||||
"name": "secondary-y-axis-title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Right Y-axis title"
|
||||
},
|
||||
{
|
||||
"name": "x-axis-label-angle",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "X-axis label angle",
|
||||
"enum": [
|
||||
"-90",
|
||||
"-45",
|
||||
"0",
|
||||
"45",
|
||||
"90"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "y-axis-label-angle",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Left Y-axis label angle",
|
||||
"enum": [
|
||||
"-90",
|
||||
"-45",
|
||||
"0",
|
||||
"45",
|
||||
"90"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "data-labels",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
|
||||
"enum": [
|
||||
"none",
|
||||
"value",
|
||||
"percentage",
|
||||
"value_percentage",
|
||||
"category_percentage",
|
||||
"category",
|
||||
"series"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "data-label-position",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Data label position",
|
||||
"enum": [
|
||||
"auto",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"center",
|
||||
"inside",
|
||||
"outside"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stack",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Stacking mode",
|
||||
"enum": [
|
||||
"none",
|
||||
"normal",
|
||||
"percent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stacked",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Compatibility alias for --stack normal",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"name": "smooth",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
|
||||
},
|
||||
{
|
||||
"name": "color-palette",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
|
||||
"enum": [
|
||||
"brandColorSeries@v2",
|
||||
"rainbowColorSeries@v2",
|
||||
"complementaryColorSeries@v2",
|
||||
"converseColorSeries@v2",
|
||||
"primaryColorSeries@v2",
|
||||
"singleColorSeries-B-@v2",
|
||||
"singleColorSeries-W-@v2",
|
||||
"singleColorSeries-G-@v2",
|
||||
"singleColorSeries-Y-@v2",
|
||||
"singleColorSeries-O-@v2",
|
||||
"singleColorSeries-R-@v2",
|
||||
"singleColorSeries-D-@v2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "colors",
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
|
||||
},
|
||||
{
|
||||
"name": "anchor-cell",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Optional chart anchor cell such as F2; defaults to the right of the data range"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Optional chart width; must be paired with --height"
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Optional chart height; must be paired with --width"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request template; no side effects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+chart-config-update": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"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": "chart-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target chart reference_id"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Chart title"
|
||||
},
|
||||
{
|
||||
"name": "subtitle",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Chart subtitle"
|
||||
},
|
||||
{
|
||||
"name": "legend-position",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Legend position; hidden removes the legend",
|
||||
"enum": [
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"hidden"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "x-axis-title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "X-axis title"
|
||||
},
|
||||
{
|
||||
"name": "y-axis-title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Left Y-axis title"
|
||||
},
|
||||
{
|
||||
"name": "secondary-y-axis-title",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Right Y-axis title"
|
||||
},
|
||||
{
|
||||
"name": "x-axis-label-angle",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "X-axis label angle",
|
||||
"enum": [
|
||||
"-90",
|
||||
"-45",
|
||||
"0",
|
||||
"45",
|
||||
"90"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "y-axis-label-angle",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Left Y-axis label angle",
|
||||
"enum": [
|
||||
"-90",
|
||||
"-45",
|
||||
"0",
|
||||
"45",
|
||||
"90"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "data-labels",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
|
||||
"enum": [
|
||||
"none",
|
||||
"value",
|
||||
"percentage",
|
||||
"value_percentage",
|
||||
"category_percentage",
|
||||
"category",
|
||||
"series"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "data-label-position",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Data label position",
|
||||
"enum": [
|
||||
"auto",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"center",
|
||||
"inside",
|
||||
"outside"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stack",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Stacking mode",
|
||||
"enum": [
|
||||
"none",
|
||||
"normal",
|
||||
"percent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stacked",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Compatibility alias for --stack normal",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"name": "smooth",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
|
||||
},
|
||||
{
|
||||
"name": "color-palette",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
|
||||
"enum": [
|
||||
"brandColorSeries@v2",
|
||||
"rainbowColorSeries@v2",
|
||||
"complementaryColorSeries@v2",
|
||||
"converseColorSeries@v2",
|
||||
"primaryColorSeries@v2",
|
||||
"singleColorSeries-B-@v2",
|
||||
"singleColorSeries-W-@v2",
|
||||
"singleColorSeries-G-@v2",
|
||||
"singleColorSeries-Y-@v2",
|
||||
"singleColorSeries-O-@v2",
|
||||
"singleColorSeries-R-@v2",
|
||||
"singleColorSeries-D-@v2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "colors",
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request template; no side effects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+chart-data-update": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"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": "chart-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target chart reference_id"
|
||||
},
|
||||
{
|
||||
"name": "data-range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"
|
||||
},
|
||||
{
|
||||
"name": "data-direction",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Data series direction; defaults to the existing chart direction when omitted",
|
||||
"enum": [
|
||||
"column",
|
||||
"row"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dim1-index",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "1-based category/X-axis dimension index within the data range; defaults to the first dimension"
|
||||
},
|
||||
{
|
||||
"name": "dim2-indexes",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request template; no side effects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+chart-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -3896,7 +3313,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole",
|
||||
"desc": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,9 +75,8 @@ 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. `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: "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: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -200,32 +199,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
|
||||
},
|
||||
},
|
||||
"+chart-config-update": {
|
||||
Risk: "write",
|
||||
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: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
|
||||
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
|
||||
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
|
||||
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
|
||||
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
|
||||
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
|
||||
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
|
||||
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
|
||||
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
|
||||
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
|
||||
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
|
||||
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
|
||||
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
|
||||
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
|
||||
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
"+chart-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -237,52 +210,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
"+chart-create-basic": {
|
||||
Risk: "write",
|
||||
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: "chart-type", Kind: "own", Type: "string", Required: "required", Desc: "Chart type", Enum: []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}},
|
||||
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"},
|
||||
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; column uses the first column as categories, row uses the first row", Default: "column", Enum: []string{"column", "row"}},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
|
||||
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
|
||||
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
|
||||
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
|
||||
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
|
||||
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
|
||||
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
|
||||
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
|
||||
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
|
||||
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
|
||||
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
|
||||
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
|
||||
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
|
||||
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
|
||||
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
|
||||
{Name: "anchor-cell", Kind: "own", Type: "string", Required: "optional", Desc: "Optional chart anchor cell such as F2; defaults to the right of the data range"},
|
||||
{Name: "width", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart width; must be paired with --height"},
|
||||
{Name: "height", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart height; must be paired with --width"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
"+chart-data-update": {
|
||||
Risk: "write",
|
||||
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: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"},
|
||||
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; defaults to the existing chart direction when omitted", Enum: []string{"column", "row"}},
|
||||
{Name: "dim1-index", Kind: "own", Type: "int", Required: "optional", Desc: "1-based category/X-axis dimension index within the data range; defaults to the first dimension"},
|
||||
{Name: "dim2-indexes", Kind: "own", Type: "string", Required: "optional", Desc: "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
"+chart-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
@@ -314,7 +241,7 @@ 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: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole", Input: []string{"file", "stdin"}},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -390,8 +317,7 @@ 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: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). 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: "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: "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"},
|
||||
@@ -1057,8 +983,6 @@ 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"},
|
||||
},
|
||||
|
||||
@@ -38,95 +38,9 @@ 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"},
|
||||
"+cols-resize": {"cols": "range"},
|
||||
"+rows-resize": {"rows": "range"},
|
||||
"+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",
|
||||
},
|
||||
"+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
|
||||
@@ -153,14 +67,6 @@ 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}).
|
||||
@@ -233,16 +139,6 @@ 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
|
||||
|
||||
@@ -284,9 +284,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--col-size", "A:D",
|
||||
"--cols", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--col-size"`)
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
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,158 +294,3 @@ 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("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,7 +7,6 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -85,13 +84,6 @@ 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()
|
||||
@@ -111,19 +103,10 @@ 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 that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
}, "", " ")
|
||||
}
|
||||
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]
|
||||
}
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
@@ -131,133 +114,14 @@ 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, name, flags).
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, 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 / anyOf 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 / anyOf 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
|
||||
}
|
||||
}
|
||||
}
|
||||
if branches, ok := m["anyOf"].([]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 / anyOf 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)
|
||||
}
|
||||
}
|
||||
if branches, ok := m["anyOf"].([]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
|
||||
}
|
||||
|
||||
@@ -407,13 +407,6 @@ 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
|
||||
@@ -424,92 +417,6 @@ 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)
|
||||
@@ -626,11 +533,8 @@ func normalizeCellStyleAliases(style map[string]interface{}, path string) error
|
||||
// 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.
|
||||
// It also expands the border "all" shorthand and intercepts border_styles
|
||||
// mis-nested inside cell_styles — both server-rejected shapes that eval
|
||||
// traces show surviving CLI validation and costing a full network round
|
||||
// trip. Structure is checked leniently to match the pass-through contract:
|
||||
// any element that isn't the expected shape is skipped, not rejected.
|
||||
// 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{})
|
||||
@@ -642,18 +546,10 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
|
||||
expandBorderAllShorthand(bs)
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, misNested := st["border_styles"]; misNested {
|
||||
return common.ValidationErrorf(
|
||||
"%s[%d][%d].cell_styles.border_styles is not valid — border_styles is a top-level cell field, a sibling of cell_styles; move it up one level",
|
||||
path, r, c)
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -662,29 +558,8 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandBorderAllShorthand rewrites the "all" side shorthand — habitual from
|
||||
// Excel / openpyxl vocabulary, rejected by the backend — into the four
|
||||
// explicit sides, in place. An explicitly set side wins over the shorthand.
|
||||
// Applied on both the typed --cells path and the --styles path, so batch
|
||||
// sub-ops get the same rewrite as standalone calls.
|
||||
func expandBorderAllShorthand(border map[string]interface{}) {
|
||||
all, ok := border["all"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, side := range []string{"top", "bottom", "left", "right"} {
|
||||
if _, exists := border[side]; !exists {
|
||||
border[side] = all
|
||||
}
|
||||
}
|
||||
delete(border, "all")
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects), expanding the "all" side shorthand the
|
||||
// same as the typed --cells and --styles paths so +cells-set-style /
|
||||
// +cells-batch-set-style don't ship {"all":…} for the backend to reject.
|
||||
// Returns nil when the flag is empty.
|
||||
// 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
|
||||
@@ -697,7 +572,6 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
expandBorderAllShorthand(m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,6 @@ var BatchUpdate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write: always pass --yes (or --dry-run to preview) — without it the call exits 10 asking for confirmation.",
|
||||
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
|
||||
"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).",
|
||||
},
|
||||
@@ -161,10 +160,6 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-batch-set-style"),
|
||||
Tips: []string{
|
||||
`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
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var chartHexColorPattern = regexp.MustCompile(`^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$`)
|
||||
|
||||
var chartSemanticConfigFlags = []string{
|
||||
"title",
|
||||
"subtitle",
|
||||
"legend-position",
|
||||
"x-axis-title",
|
||||
"y-axis-title",
|
||||
"secondary-y-axis-title",
|
||||
"x-axis-label-angle",
|
||||
"y-axis-label-angle",
|
||||
"data-labels",
|
||||
"data-label-position",
|
||||
"stack",
|
||||
"color-palette",
|
||||
}
|
||||
|
||||
// ChartCreateBasic creates a complete server-side chart snapshot from a chart
|
||||
// type and a rectangular source range. The CLI only forwards semantic input;
|
||||
// it deliberately does not own or duplicate the full chart snapshot template.
|
||||
var ChartCreateBasic = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+chart-create-basic",
|
||||
Description: "Create a basic chart from a chart type and data range; the server builds and validates the full snapshot.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+chart-create-basic"),
|
||||
PostMount: configureChartSemanticCommand,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = chartCreateBasicInput(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := chartCreateBasicInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := chartCreateBasicInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ChartConfigUpdate updates the common chart settings that repeatedly caused
|
||||
// full-snapshot retries in eval traces. Advanced per-series and marker styling
|
||||
// remains on +chart-update --properties.
|
||||
var ChartConfigUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+chart-config-update",
|
||||
Description: "Update common chart titles, axes, legend, labels, stacking, smoothing, or chart-level colors without sending a snapshot.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+chart-config-update"),
|
||||
PostMount: configureChartSemanticCommand,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = chartConfigUpdateInput(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ChartDataUpdate rebinds an existing chart to a new source range. The server
|
||||
// reads the current snapshot, rebuilds its data mapping, and preserves the
|
||||
// chart's layout and visual configuration.
|
||||
var ChartDataUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+chart-data-update",
|
||||
Description: "Update an existing chart's data range or direction while preserving its layout and visual configuration.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+chart-data-update"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = chartDataUpdateInput(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := chartDataUpdateInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := chartDataUpdateInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chartType := strings.TrimSpace(rt.Str("chart-type"))
|
||||
if chartType == "" {
|
||||
return nil, sheetsValidationForFlag("chart-type", "--chart-type is required")
|
||||
}
|
||||
dataRange := strings.TrimSpace(rt.Str("data-range"))
|
||||
if dataRange == "" {
|
||||
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
|
||||
}
|
||||
direction := rt.Str("data-direction")
|
||||
if direction == "" {
|
||||
direction = "column"
|
||||
}
|
||||
normalizedDataRange, dimensionCount, dataPointCount, err := normalizeBasicChartDataRanges(dataRange, direction)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dimensionCount < 2 || dataPointCount < 2 {
|
||||
return nil, sheetsValidationForFlag("data-range", "--data-range must provide at least 2 data points and 2 dimensions")
|
||||
}
|
||||
if chartType == "combo" && dimensionCount < 3 {
|
||||
return nil, sheetsValidationForFlag("data-range", "combo chart requires at least 3 rows or columns along --data-direction")
|
||||
}
|
||||
|
||||
basic := map[string]interface{}{
|
||||
"chart_type": chartType,
|
||||
"data_range": normalizedDataRange,
|
||||
}
|
||||
if rt.Changed("data-direction") {
|
||||
basic["data_direction"] = rt.Str("data-direction")
|
||||
}
|
||||
if err := validateChartColorFlags(rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateChartSemanticEnums(rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addChartSemanticConfig(rt, basic)
|
||||
|
||||
if rt.Changed("anchor-cell") {
|
||||
anchor := strings.TrimSpace(rt.Str("anchor-cell"))
|
||||
_, row, ok := splitCellRef(anchor)
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("anchor-cell", "--anchor-cell must be a single A1 cell such as F2")
|
||||
}
|
||||
colEnd := 0
|
||||
for colEnd < len(anchor) && ((anchor[colEnd] >= 'A' && anchor[colEnd] <= 'Z') || (anchor[colEnd] >= 'a' && anchor[colEnd] <= 'z')) {
|
||||
colEnd++
|
||||
}
|
||||
basic["position"] = map[string]interface{}{"row": row, "col": strings.ToUpper(anchor[:colEnd])}
|
||||
}
|
||||
widthChanged := rt.Changed("width")
|
||||
heightChanged := rt.Changed("height")
|
||||
if widthChanged != heightChanged {
|
||||
return nil, common.ValidationErrorf("--width and --height must be provided together").WithParams(
|
||||
sheetsInvalidParam("width", "must be paired with --height"),
|
||||
sheetsInvalidParam("height", "must be paired with --width"),
|
||||
)
|
||||
}
|
||||
if widthChanged {
|
||||
if rt.Int("width") < 10 || rt.Int("height") < 10 {
|
||||
return nil, common.ValidationErrorf("--width and --height must be at least 10")
|
||||
}
|
||||
basic["size"] = map[string]interface{}{"width": rt.Int("width"), "height": rt.Int("height")}
|
||||
}
|
||||
|
||||
input := map[string]interface{}{"excel_id": token, "operation": "create", "basic_chart": basic}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if err := validateInputAgainstSchema(rt, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func chartConfigUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chartID := strings.TrimSpace(rt.Str("chart-id"))
|
||||
if chartID == "" {
|
||||
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if err := validateChartColorFlags(rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateChartSemanticEnums(rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addChartSemanticConfig(rt, updates)
|
||||
if len(updates) == 0 {
|
||||
return nil, common.ValidationErrorf("at least one chart configuration flag is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "update",
|
||||
"chart_id": chartID,
|
||||
"config_updates": updates,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if err := validateInputAgainstSchema(rt, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func chartDataUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chartID := strings.TrimSpace(rt.Str("chart-id"))
|
||||
if chartID == "" {
|
||||
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
|
||||
}
|
||||
dataRange := strings.TrimSpace(rt.Str("data-range"))
|
||||
if dataRange == "" {
|
||||
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
|
||||
}
|
||||
ranges, err := splitChartDataRanges(dataRange)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
|
||||
}
|
||||
explicitSheet := ""
|
||||
for _, value := range ranges {
|
||||
item, parseErr := parseChartDataRange(value)
|
||||
if parseErr != nil {
|
||||
return nil, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
|
||||
}
|
||||
if item.sheet != "" {
|
||||
if explicitSheet != "" && item.sheet != explicitSheet {
|
||||
return nil, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
|
||||
}
|
||||
explicitSheet = item.sheet
|
||||
}
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{"data_range": dataRange}
|
||||
if rt.Changed("data-direction") {
|
||||
updates["data_direction"] = rt.Str("data-direction")
|
||||
}
|
||||
dim1Index := 1
|
||||
if rt.Changed("dim1-index") {
|
||||
dim1Index = rt.Int("dim1-index")
|
||||
if dim1Index < 1 {
|
||||
return nil, sheetsValidationForFlag("dim1-index", "--dim1-index must be a positive 1-based index")
|
||||
}
|
||||
updates["dim1_index"] = dim1Index
|
||||
}
|
||||
if rt.Changed("dim2-indexes") {
|
||||
dim2Indexes, parseErr := parseChartDim2Indexes(rt.Str("dim2-indexes"))
|
||||
if parseErr != nil {
|
||||
return nil, sheetsValidationForFlag("dim2-indexes", "%v", parseErr)
|
||||
}
|
||||
for _, index := range dim2Indexes {
|
||||
if index == dim1Index {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"dim2-indexes",
|
||||
"--dim2-indexes must not contain the dim1 index %d",
|
||||
dim1Index,
|
||||
)
|
||||
}
|
||||
}
|
||||
updates["dim2_indexes"] = dim2Indexes
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "update",
|
||||
"chart_id": chartID,
|
||||
"data_updates": updates,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if err := validateInputAgainstSchema(rt, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func parseChartDim2Indexes(raw string) ([]int, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
indexes := make([]int, 0, len(parts))
|
||||
seen := make(map[int]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
return nil, common.ValidationErrorf("--dim2-indexes must be a comma-separated list of positive 1-based indexes")
|
||||
}
|
||||
index, err := strconv.Atoi(value)
|
||||
if err != nil || index < 1 {
|
||||
return nil, common.ValidationErrorf("--dim2-indexes must contain only positive 1-based indexes")
|
||||
}
|
||||
if _, exists := seen[index]; exists {
|
||||
return nil, common.ValidationErrorf("--dim2-indexes must not contain duplicate index %d", index)
|
||||
}
|
||||
seen[index] = struct{}{}
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
type chartDataRange struct {
|
||||
sheet string
|
||||
row, col int
|
||||
rowCount, colCount int
|
||||
}
|
||||
|
||||
func normalizeBasicChartDataRanges(dataRange, direction string) (normalized string, dimensionCount, dataPointCount int, err error) {
|
||||
ranges, err := splitChartDataRanges(dataRange)
|
||||
if err != nil {
|
||||
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
|
||||
}
|
||||
parsed := make([]chartDataRange, 0, len(ranges))
|
||||
for _, value := range ranges {
|
||||
item, parseErr := parseChartDataRange(value)
|
||||
if parseErr != nil {
|
||||
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
|
||||
}
|
||||
parsed = append(parsed, item)
|
||||
}
|
||||
first := parsed[0]
|
||||
explicitSheet := ""
|
||||
spans := make([][2]int, 0, len(parsed))
|
||||
aligned := true
|
||||
minRow, minCol := first.row, first.col
|
||||
maxRow, maxCol := first.row+first.rowCount, first.col+first.colCount
|
||||
for _, item := range parsed {
|
||||
if item.sheet != "" {
|
||||
if explicitSheet != "" && item.sheet != explicitSheet {
|
||||
return "", 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
|
||||
}
|
||||
explicitSheet = item.sheet
|
||||
}
|
||||
if direction == "row" {
|
||||
if item.col != first.col || item.colCount != first.colCount {
|
||||
aligned = false
|
||||
}
|
||||
dimensionCount += item.rowCount
|
||||
spans = append(spans, [2]int{item.row, item.row + item.rowCount})
|
||||
} else {
|
||||
if item.row != first.row || item.rowCount != first.rowCount {
|
||||
aligned = false
|
||||
}
|
||||
dimensionCount += item.colCount
|
||||
spans = append(spans, [2]int{item.col, item.col + item.colCount})
|
||||
}
|
||||
minRow = min(minRow, item.row)
|
||||
minCol = min(minCol, item.col)
|
||||
maxRow = max(maxRow, item.row+item.rowCount)
|
||||
maxCol = max(maxCol, item.col+item.colCount)
|
||||
}
|
||||
overlapping := false
|
||||
for i, current := range spans {
|
||||
for j := 0; j < i; j++ {
|
||||
if current[0] < spans[j][1] && spans[j][0] < current[1] {
|
||||
overlapping = true
|
||||
}
|
||||
}
|
||||
}
|
||||
normalized = strings.Join(ranges, ",")
|
||||
if len(ranges) > 1 && (!aligned || overlapping) {
|
||||
prefix := ""
|
||||
if explicitSheet != "" {
|
||||
prefix = explicitSheet + "!"
|
||||
}
|
||||
normalized = prefix + columnIndexToLetter(minCol) + strconv.Itoa(minRow+1) + ":" + columnIndexToLetter(maxCol-1) + strconv.Itoa(maxRow)
|
||||
dimensionCount = maxCol - minCol
|
||||
dataPointCount = maxRow - minRow
|
||||
if direction == "row" {
|
||||
dimensionCount, dataPointCount = dataPointCount, dimensionCount
|
||||
}
|
||||
return normalized, dimensionCount, dataPointCount, nil
|
||||
}
|
||||
if direction == "row" {
|
||||
dataPointCount = first.colCount
|
||||
} else {
|
||||
dataPointCount = first.rowCount
|
||||
}
|
||||
return normalized, dimensionCount, dataPointCount, nil
|
||||
}
|
||||
|
||||
func splitChartDataRanges(value string) ([]string, error) {
|
||||
var ranges []string
|
||||
start := 0
|
||||
inQuote := false
|
||||
for i := 0; i <= len(value); i++ {
|
||||
if i < len(value) && value[i] == '\'' {
|
||||
if inQuote && i+1 < len(value) && value[i+1] == '\'' {
|
||||
i++
|
||||
} else {
|
||||
inQuote = !inQuote
|
||||
}
|
||||
}
|
||||
if i == len(value) || (value[i] == ',' && !inQuote) {
|
||||
part := strings.TrimSpace(value[start:i])
|
||||
if part == "" {
|
||||
return nil, common.ValidationErrorf("range list contains an empty item")
|
||||
}
|
||||
ranges = append(ranges, part)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if inQuote {
|
||||
return nil, common.ValidationErrorf("unterminated quoted sheet name")
|
||||
}
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
func parseChartDataRange(value string) (chartDataRange, error) {
|
||||
item := chartDataRange{}
|
||||
ref := strings.TrimSpace(value)
|
||||
if bang := strings.LastIndex(ref, "!"); bang >= 0 {
|
||||
item.sheet = strings.TrimSpace(ref[:bang])
|
||||
ref = strings.TrimSpace(ref[bang+1:])
|
||||
}
|
||||
parts := strings.SplitN(ref, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
|
||||
}
|
||||
startCol, startRow, startOK := splitCellRef(parts[0])
|
||||
endCol, endRow, endOK := splitCellRef(parts[1])
|
||||
if !startOK || !endOK || endCol < startCol || endRow < startRow {
|
||||
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
|
||||
}
|
||||
item.row, item.col = startRow, startCol
|
||||
item.rowCount, item.colCount = endRow-startRow+1, endCol-startCol+1
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func configureChartSemanticCommand(cmd *cobra.Command) {
|
||||
if cmd.Flags().Lookup("stacked") == nil {
|
||||
cmd.Flags().Bool("stacked", false, "compatibility alias for --stack normal")
|
||||
_ = cmd.Flags().MarkHidden("stacked")
|
||||
}
|
||||
originalArgs := cmd.Args
|
||||
cmd.Args = func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 1 && cmd.Flags().Changed("smooth") && (args[0] == "true" || args[0] == "false") {
|
||||
return cmd.Flags().Set("smooth", args[0])
|
||||
}
|
||||
return originalArgs(cmd, args)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
|
||||
message := err.Error()
|
||||
if strings.Contains(message, "unknown flag: --stacked") {
|
||||
return sheetsValidationForFlag("stacked", "--stacked is not supported; use --stack normal (or --stack percent for 100%% stacking)")
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func addChartSemanticConfig(rt flagView, out map[string]interface{}) {
|
||||
for _, flag := range chartSemanticConfigFlags {
|
||||
if !rt.Changed(flag) {
|
||||
continue
|
||||
}
|
||||
key := strings.ReplaceAll(flag, "-", "_")
|
||||
if flag == "x-axis-label-angle" || flag == "y-axis-label-angle" {
|
||||
out[key] = rt.Int(flag)
|
||||
} else if flag == "data-labels" && rt.Str(flag) == "category_percentage" {
|
||||
out[key] = "value_percentage"
|
||||
} else {
|
||||
out[key] = rt.Str(flag)
|
||||
}
|
||||
}
|
||||
if rt.Changed("stacked") {
|
||||
out["stack"] = "normal"
|
||||
}
|
||||
if rt.Changed("smooth") {
|
||||
out["smooth"] = rt.Bool("smooth")
|
||||
}
|
||||
if rt.Changed("colors") {
|
||||
out["colors"] = normalizedChartColors(rt)
|
||||
}
|
||||
}
|
||||
|
||||
func validateChartSemanticEnums(rt flagView) error {
|
||||
if rt.Changed("stack") && rt.Changed("stacked") {
|
||||
return common.ValidationErrorf("--stack and --stacked are mutually exclusive").WithParams(
|
||||
sheetsInvalidParam("stack", "cannot be used with --stacked"),
|
||||
sheetsInvalidParam("stacked", "cannot be used with --stack"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateChartColorFlags(rt flagView) error {
|
||||
if rt.Changed("color-palette") && rt.Changed("colors") {
|
||||
return common.ValidationErrorf("--color-palette and --colors are mutually exclusive").WithParams(
|
||||
sheetsInvalidParam("color-palette", "cannot be used with --colors"),
|
||||
sheetsInvalidParam("colors", "cannot be used with --color-palette"),
|
||||
)
|
||||
}
|
||||
if rt.Changed("colors") {
|
||||
colors := normalizedChartColors(rt)
|
||||
if len(colors) < 2 {
|
||||
return sheetsValidationForFlag("colors", "--colors must contain at least two hex colors")
|
||||
}
|
||||
for _, color := range colors {
|
||||
if !chartHexColorPattern.MatchString(color) {
|
||||
return sheetsValidationForFlag("colors", "--colors contains invalid hex color %q", color)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedChartColors(rt flagView) []string {
|
||||
raw := rt.StrSlice("colors")
|
||||
colors := make([]string, len(raw))
|
||||
for i := range raw {
|
||||
colors[i] = strings.TrimSpace(raw[i])
|
||||
}
|
||||
return colors
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChartCreateBasic_AllTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
types := []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}
|
||||
for _, chartType := range types {
|
||||
chartType := chartType
|
||||
t.Run(chartType, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rangeValue := "A1:C4"
|
||||
if chartType == "combo" {
|
||||
rangeValue = "A1:D4"
|
||||
}
|
||||
body := parseDryRunBody(t, ChartCreateBasic, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-type", chartType,
|
||||
"--data-range", rangeValue,
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
if input["operation"] != "create" {
|
||||
t.Fatalf("operation = %v, want create", input["operation"])
|
||||
}
|
||||
if _, ok := input["properties"]; ok {
|
||||
t.Fatal("semantic create must not send properties")
|
||||
}
|
||||
basic, _ := input["basic_chart"].(map[string]interface{})
|
||||
if basic["chart_type"] != chartType || basic["data_range"] != rangeValue {
|
||||
t.Fatalf("basic_chart = %#v", basic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartCreateBasic_ConfigAndPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartCreateBasic, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-type", "line",
|
||||
"--data-range", "A1:C4",
|
||||
"--anchor-cell", "f2",
|
||||
"--width", "640",
|
||||
"--height", "360",
|
||||
"--title", "Trend",
|
||||
"--legend-position", "bottom",
|
||||
"--smooth=false",
|
||||
"--data-direction", "row",
|
||||
"--color-palette", "brandColorSeries@v2",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
basic, _ := input["basic_chart"].(map[string]interface{})
|
||||
position, _ := basic["position"].(map[string]interface{})
|
||||
size, _ := basic["size"].(map[string]interface{})
|
||||
if position["col"] != "F" || position["row"] != float64(1) {
|
||||
t.Errorf("position = %#v, want F2 as zero-based row 1", position)
|
||||
}
|
||||
if size["width"] != float64(640) || size["height"] != float64(360) {
|
||||
t.Errorf("size = %#v", size)
|
||||
}
|
||||
if basic["title"] != "Trend" || basic["legend_position"] != "bottom" || basic["smooth"] != false ||
|
||||
basic["data_direction"] != "row" || basic["color_palette"] != "brandColorSeries@v2" {
|
||||
t.Errorf("semantic config = %#v", basic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartCreateBasic_MultipleAlignedRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
rangeValue := "'Data, 2026'!A1:A10,'Data, 2026'!K1:L10"
|
||||
body := parseDryRunBody(t, ChartCreateBasic, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-type", "line",
|
||||
"--data-range", rangeValue,
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
basic := input["basic_chart"].(map[string]interface{})
|
||||
if basic["data_range"] != rangeValue {
|
||||
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], rangeValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartCreateBasic_MergesMisalignedOrOverlappingRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{name: "separated rows", input: "'Sheet1'!A1:M1,'Sheet1'!A3:M3", expected: "'Sheet1'!A1:M3"},
|
||||
{name: "overlapping columns", input: "A1:B10,B1:C10", expected: "A1:C10"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartCreateBasic, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-type", "line",
|
||||
"--data-range", tt.input,
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
basic := input["basic_chart"].(map[string]interface{})
|
||||
if basic["data_range"] != tt.expected {
|
||||
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartCreateBasic_RejectsCrossSheetRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-type", "line",
|
||||
"--data-range", "'A'!A1:A10,'B'!C1:D10",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected cross-sheet ranges to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartSemanticShortcuts_InBatchUpdate(t *testing.T) {
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales"}},
|
||||
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"line","data-range":"E1:G10","title":"Trend"}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops := input["operations"].([]interface{})
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations len = %d, want 2", len(ops))
|
||||
}
|
||||
for i, op := range ops {
|
||||
item := op.(map[string]interface{})
|
||||
if item["tool_name"] != "manage_chart_object" {
|
||||
t.Fatalf("operations[%d].tool_name = %v", i, item["tool_name"])
|
||||
}
|
||||
chartInput := item["input"].(map[string]interface{})
|
||||
if chartInput["operation"] != "create" {
|
||||
t.Fatalf("operations[%d].input.operation = %v", i, chartInput["operation"])
|
||||
}
|
||||
if _, ok := chartInput["basic_chart"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("operations[%d].input.basic_chart = %#v", i, chartInput["basic_chart"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartConfigUpdate_PartialFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartConfigUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-id", "chart-1",
|
||||
"--y-axis-title", "Revenue",
|
||||
"--stack", "percent",
|
||||
"--smooth=false",
|
||||
"--colors", "#112233,#445566",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
|
||||
t.Fatalf("input = %#v", input)
|
||||
}
|
||||
if _, ok := input["properties"]; ok {
|
||||
t.Fatal("semantic update must not send properties")
|
||||
}
|
||||
updates, _ := input["config_updates"].(map[string]interface{})
|
||||
if updates["y_axis_title"] != "Revenue" || updates["stack"] != "percent" || updates["smooth"] != false {
|
||||
t.Errorf("config_updates = %#v", updates)
|
||||
}
|
||||
colors, _ := updates["colors"].([]interface{})
|
||||
if len(colors) != 2 || colors[0] != "#112233" || colors[1] != "#445566" {
|
||||
t.Errorf("config_updates.colors = %#v", updates["colors"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartConfigUpdate_SpacedSmoothBool(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartConfigUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-id", "chart-1",
|
||||
"--smooth", "false",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
updates := input["config_updates"].(map[string]interface{})
|
||||
if updates["smooth"] != false {
|
||||
t.Fatalf("config_updates.smooth = %v, want false", updates["smooth"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartSemanticShortcuts_CompatibleAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartConfigUpdate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--stacked",
|
||||
})
|
||||
updates := decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
|
||||
if updates["stack"] != "normal" {
|
||||
t.Fatalf("--stacked normalized stack = %v, want normal", updates["stack"])
|
||||
}
|
||||
body = parseDryRunBody(t, ChartConfigUpdate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-labels", "category_percentage",
|
||||
})
|
||||
updates = decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
|
||||
if updates["data_labels"] != "value_percentage" {
|
||||
t.Fatalf("data-labels normalized value = %v, want value_percentage", updates["data_labels"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartSemanticShortcuts_CompatibleAliasesInBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+chart-config-update","input":{"sheet_id":"sh1","chart_id":"chart-1","stacked":true,"data_labels":"category_percentage","smooth":false}}]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops := input["operations"].([]interface{})
|
||||
chartInput := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
updates := chartInput["config_updates"].(map[string]interface{})
|
||||
if updates["stack"] != "normal" || updates["data_labels"] != "value_percentage" || updates["smooth"] != false {
|
||||
t.Fatalf("batch config_updates = %#v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartDataUpdate_PreservesSnapshotServerSide(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartDataUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-id", "chart-1",
|
||||
"--data-range", "'Sheet1'!A1:M6",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
|
||||
t.Fatalf("input = %#v", input)
|
||||
}
|
||||
if _, ok := input["properties"]; ok {
|
||||
t.Fatal("semantic data update must not send properties")
|
||||
}
|
||||
updates, _ := input["data_updates"].(map[string]interface{})
|
||||
if updates["data_range"] != "'Sheet1'!A1:M6" {
|
||||
t.Errorf("data_updates = %#v", updates)
|
||||
}
|
||||
if _, ok := updates["data_direction"]; ok {
|
||||
t.Errorf("omitted --data-direction must preserve the server-side direction: %#v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartDataUpdate_ExplicitDirectionAndMultipleRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartDataUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-id", "chart-1",
|
||||
"--data-range", "'Sheet1'!A1:A10,'Sheet1'!K1:L10",
|
||||
"--data-direction", "column",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
updates := input["data_updates"].(map[string]interface{})
|
||||
if updates["data_range"] != "'Sheet1'!A1:A10,'Sheet1'!K1:L10" || updates["data_direction"] != "column" {
|
||||
t.Errorf("data_updates = %#v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartDataUpdate_ExplicitSeriesIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ChartDataUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-id", "chart-1",
|
||||
"--data-range", "'Sheet1'!A1:M6",
|
||||
"--dim1-index", "1",
|
||||
"--dim2-indexes", "4, 8",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_chart_object")
|
||||
updates := input["data_updates"].(map[string]interface{})
|
||||
if updates["dim1_index"] != float64(1) {
|
||||
t.Errorf("data_updates.dim1_index = %#v", updates["dim1_index"])
|
||||
}
|
||||
indexes, _ := updates["dim2_indexes"].([]interface{})
|
||||
if len(indexes) != 2 || indexes[0] != float64(4) || indexes[1] != float64(8) {
|
||||
t.Errorf("data_updates.dim2_indexes = %#v", updates["dim2_indexes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartSemanticShortcuts_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "unsupported type", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "donut", "--data-range", "A1:C4"}},
|
||||
{name: "invalid semantic enum", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--legend-position", "diagonal"}},
|
||||
{name: "range too small", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:A4"}},
|
||||
{name: "combo needs two series", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "combo", "--data-range", "A1:B4"}},
|
||||
{name: "invalid direction", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--data-direction", "horizontal"}},
|
||||
{name: "colors need two values", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--colors", "#112233"}},
|
||||
{name: "palette and colors are exclusive", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--color-palette", "brandColorSeries@v2", "--colors", "#112233,#445566"}},
|
||||
{name: "size must be paired", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--width", "640"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, tt.args)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_, _, err := runShortcutCapturingErr(t, ChartConfigUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-id", testSheetID,
|
||||
"--chart-id", "chart-1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected config update with no changed field to fail")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--url", testURL, "--sheet-id", testSheetID, "--data-range", "A1:C4"},
|
||||
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1"},
|
||||
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--data-direction", "horizontal"},
|
||||
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim1-index", "0"},
|
||||
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "2,2"},
|
||||
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "1,2"},
|
||||
} {
|
||||
_, _, err = runShortcutCapturingErr(t, ChartDataUpdate, args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected chart data update validation error for args %#v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
|
||||
"high-risk-write — always preview with --dry-run; clear is not undoable.",
|
||||
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
|
||||
},
|
||||
}
|
||||
@@ -266,13 +266,9 @@ var ColsResize = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
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"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
}
|
||||
|
||||
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
|
||||
|
||||
@@ -69,7 +69,8 @@ var CellsGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitReadResult(runtime, out)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -87,19 +88,17 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
|
||||
// read cap. Pin cell_limit very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["cell_limit"] = unboundedReadLimit
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
|
||||
// tool's switches:
|
||||
// tool's two coarse 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
|
||||
@@ -120,9 +119,6 @@ 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
|
||||
@@ -169,7 +165,8 @@ var CsvGet = common.Shortcut{
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
return emitReadResult(runtime, out)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -186,7 +183,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, ok := maxCharsInput(runtime); ok {
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
|
||||
@@ -34,22 +34,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
|
||||
},
|
||||
},
|
||||
{
|
||||
// --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
|
||||
|
||||
@@ -128,11 +128,7 @@ var DimInsert = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-insert"),
|
||||
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),
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -296,10 +292,7 @@ var DimFreeze = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-freeze"),
|
||||
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),
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
|
||||
@@ -88,7 +88,6 @@ 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.",
|
||||
@@ -242,11 +241,6 @@ 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
|
||||
@@ -265,19 +259,7 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
|
||||
Sheets []tableSheetIn `json:"sheets"`
|
||||
}
|
||||
if err := dec.Decode(&wire); err != nil {
|
||||
// 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`)")
|
||||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
// Reject trailing non-whitespace after the first JSON value: json.Decoder
|
||||
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
|
||||
@@ -1226,7 +1208,8 @@ var TableGet = common.Shortcut{
|
||||
}
|
||||
sheets = append(sheets, spec)
|
||||
}
|
||||
return emitReadResult(runtime, map[string]interface{}{"sheets": sheets})
|
||||
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
|
||||
return nil
|
||||
},
|
||||
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.",
|
||||
@@ -1371,18 +1354,11 @@ 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
|
||||
@@ -1457,38 +1433,9 @@ 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.
|
||||
//
|
||||
|
||||
@@ -405,11 +405,7 @@ var SheetCopy = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+sheet-copy"),
|
||||
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),
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -969,11 +965,7 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
|
||||
if len(items) != 1 {
|
||||
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
|
||||
}
|
||||
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
}
|
||||
|
||||
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
|
||||
@@ -996,28 +988,21 @@ 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) == "" {
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
|
||||
continue
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
|
||||
}
|
||||
if name != 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
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
|
||||
}
|
||||
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.ByIndex[i] = style
|
||||
out.ByName[name] = style
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1045,268 +1030,182 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
|
||||
payload := &workbookCreateStylePayload{}
|
||||
var probs []error
|
||||
var err error
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
var errsHere []error
|
||||
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
probs = append(probs, errsHere...)
|
||||
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw, ok := item["row_sizes"]; ok {
|
||||
var errsHere []error
|
||||
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
probs = append(probs, errsHere...)
|
||||
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw, ok := item["col_sizes"]; ok {
|
||||
var errsHere []error
|
||||
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
probs = append(probs, errsHere...)
|
||||
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw, ok := item["cell_merges"]; ok {
|
||||
var errsHere []error
|
||||
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if len(probs) > 0 {
|
||||
return nil, probs
|
||||
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
|
||||
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)}
|
||||
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
|
||||
}
|
||||
return payload, 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) {
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
}
|
||||
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
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))
|
||||
if err != nil {
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, op)
|
||||
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})
|
||||
}
|
||||
return ops, probs
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
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) {
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
}
|
||||
ops := make([]workbookCreateMergeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
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))
|
||||
if err != nil {
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
|
||||
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)
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
mergeType = normalizeMergeType(strings.TrimSpace(v))
|
||||
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})
|
||||
}
|
||||
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
|
||||
return ops, 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) {
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
}
|
||||
ops := make([]workbookCreateResizeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
|
||||
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))
|
||||
if err != nil {
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
return nil, 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"
|
||||
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)
|
||||
}
|
||||
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"
|
||||
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)
|
||||
}
|
||||
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 == "" {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type is required (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
|
||||
}
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
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 workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
size = int(n)
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
|
||||
}
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
|
||||
}
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
|
||||
}
|
||||
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})
|
||||
}
|
||||
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"); err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
|
||||
@@ -1360,7 +1259,6 @@ 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
|
||||
}
|
||||
@@ -1401,7 +1299,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; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
|
||||
}
|
||||
spec, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
|
||||
@@ -48,11 +48,7 @@ var CellsSet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set"),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
|
||||
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
|
||||
},
|
||||
Validate: validateViaInput(cellsSetInput),
|
||||
Validate: validateViaInput(cellsSetInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -128,11 +124,7 @@ var CellsSetStyle = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set-style"),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
|
||||
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
|
||||
},
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet read → file offload ───────────────────────────────────
|
||||
//
|
||||
// Shared plumbing for +cells-get / +csv-get / +table-get behind the
|
||||
// --output-path flag: when a caller redirects a read to a file, the char cap
|
||||
// should default to unlimited so the whole sheet lands on disk instead of being
|
||||
// clipped by the stdout-oriented max_chars safety cap.
|
||||
|
||||
// readOutputPath returns the trimmed --output-path flag value ("" when unset).
|
||||
func readOutputPath(runtime *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(runtime.Str("output-path"))
|
||||
}
|
||||
|
||||
// maxCharsInput resolves the max_chars value to send to the underlying read
|
||||
// tool. With --output-path set the cap is lifted (unbounded sentinel) so the
|
||||
// full result is written to the file; otherwise the --max-chars value binds.
|
||||
// The second return is false when nothing should be sent (max-chars <= 0), in
|
||||
// which case the tool's own default applies. Note the tool truncates at ~50000
|
||||
// even when max_chars is omitted, so callers that want an explicit cap should
|
||||
// pass a positive default.
|
||||
func maxCharsInput(runtime *common.RuntimeContext) (int, bool) {
|
||||
if readOutputPath(runtime) != "" {
|
||||
return unboundedReadLimit, true
|
||||
}
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// emitReadResult delivers a read shortcut's result. When --output-path is set it
|
||||
// writes the data payload to that path as pretty JSON and prints a small
|
||||
// confirmation envelope to stdout (path + byte count); otherwise it prints the
|
||||
// full result envelope to stdout as usual.
|
||||
func emitReadResult(runtime *common.RuntimeContext, out interface{}) error {
|
||||
path := readOutputPath(runtime)
|
||||
if path == "" {
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(path)
|
||||
if err != nil {
|
||||
resolved = path
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"output_path": resolved,
|
||||
"bytes_written": len(b),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -84,7 +83,7 @@ func callTool(
|
||||
code, _ := util.ToFloat64(envelope["code"])
|
||||
if code != 0 {
|
||||
msg, _ := envelope["msg"].(string)
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), flattenToolErrorMsg(msg)).
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
|
||||
WithCode(int(code))
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
@@ -101,47 +100,6 @@ func callTool(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// flattenToolErrorMsg unwraps the nested-escaped-JSON error payload some
|
||||
// sheet-ai tools put in msg — batch_update in particular wraps its result as
|
||||
// {"error":"{\"message\":\"batch_update: N succeeded, M failed\",
|
||||
// \"failures\":[…]}","errorType":…,"data":{…}} — into one readable line
|
||||
// naming each failed operation. Eval traces show agents (and even the eval
|
||||
// aggregator) failing to extract the real cause from the double-escaped
|
||||
// form. Anything that doesn't match the nested shape passes through
|
||||
// untouched.
|
||||
func flattenToolErrorMsg(msg string) string {
|
||||
trimmed := strings.TrimSpace(msg)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return msg
|
||||
}
|
||||
var outer struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal([]byte(trimmed), &outer) != nil || strings.TrimSpace(outer.Error) == "" {
|
||||
return msg
|
||||
}
|
||||
inner := strings.TrimSpace(outer.Error)
|
||||
var detail struct {
|
||||
Message string `json:"message"`
|
||||
Failures []struct {
|
||||
Index int `json:"index"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Error string `json:"error"`
|
||||
} `json:"failures"`
|
||||
}
|
||||
if strings.HasPrefix(inner, "{") && json.Unmarshal([]byte(inner), &detail) == nil && detail.Message != "" {
|
||||
if len(detail.Failures) == 0 {
|
||||
return detail.Message
|
||||
}
|
||||
parts := make([]string, 0, len(detail.Failures))
|
||||
for _, f := range detail.Failures {
|
||||
parts = append(parts, fmt.Sprintf("operations[%d] (%s): %s", f.Index, f.ToolName, f.Error))
|
||||
}
|
||||
return detail.Message + " — " + strings.Join(parts, "; ")
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
|
||||
// The wire-format body (with input serialized to a JSON string) is preserved
|
||||
// for fidelity, and a decoded tool_input map is surfaced alongside so humans
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFlattenToolErrorMsg pins the unwrap of batch_update's double-escaped
|
||||
// error payload (the exact shape from eval V2U038/V2U013 traces) and the
|
||||
// pass-through of everything else.
|
||||
func TestFlattenToolErrorMsg(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("batch failures flatten to one line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `{"error":"{\"message\":\"batch_update: 0 succeeded, 1 failed\",\"succeeded\":0,\"failed\":1,\"failures\":[{\"index\":0,\"tool_name\":\"manage_chart_object\",\"error\":\"invalid snapshot.data.dim1.serie.index: 0, must be >= 1 (index is 1-based)\",\"errorType\":\"param_error\"}]}","errorType":"param_error","data":{"total":2,"succeeded":0,"failed":1}}`
|
||||
got := flattenToolErrorMsg(msg)
|
||||
for _, want := range []string{
|
||||
"batch_update: 0 succeeded, 1 failed",
|
||||
"operations[0] (manage_chart_object): invalid snapshot.data.dim1.serie.index",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("flattened msg should contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `\"`) {
|
||||
t.Errorf("flattened msg must not carry escaped JSON, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain-string inner error unwraps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := flattenToolErrorMsg(`{"error":"sheet \"s\" not found","errorType":"param_error"}`)
|
||||
if got != `sheet "s" not found` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-JSON msg passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `cell at row 0, col 1 is inside a merged region (top-left: A1)`
|
||||
if got := flattenToolErrorMsg(msg); got != msg {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON without error field passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `{"detail":"x"}`
|
||||
if got := flattenToolErrorMsg(msg); got != msg {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -35,11 +35,6 @@ func Shortcuts() []common.Shortcut {
|
||||
if hasFlag(all[i].Flags, "spreadsheet-token") {
|
||||
all[i].PostMount = withTokenAlias(all[i].PostMount)
|
||||
}
|
||||
// +chart-create grows --print-example (minimal per-type --properties
|
||||
// templates) — the biggest --print-schema consumer in eval traces.
|
||||
if all[i].Command == "+chart-create" {
|
||||
all[i].PostMount = withChartPrintExample(all[i].PostMount)
|
||||
}
|
||||
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
|
||||
// flags inlined, enum vocabulary normalization) ride the same
|
||||
// PostMount composition, so no other domain's behavior shifts.
|
||||
@@ -151,7 +146,6 @@ func shortcutList() []common.Shortcut {
|
||||
|
||||
// Object CRUD (3 per skill)
|
||||
ChartCreate, ChartUpdate, ChartDelete,
|
||||
ChartCreateBasic, ChartConfigUpdate, ChartDataUpdate,
|
||||
PivotCreate, PivotUpdate, PivotDelete,
|
||||
CondFormatCreate, CondFormatUpdate, CondFormatDelete,
|
||||
FilterCreate, FilterUpdate, FilterDelete,
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTablePut_StylesErrorsAggregate pins the one-retry contract for
|
||||
// --styles: every issue across sections and ops is reported in a single
|
||||
// error (eval V2U032 burned three round trips fixing a border side, then
|
||||
// row_sizes.type, then size — each surfaced only after the previous fix).
|
||||
func TestTablePut_StylesErrorsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"s",
|
||||
"cell_styles":[{"range":"A1:A1","border_styles":{"horizontal":{"style":"solid"}}}],
|
||||
"row_sizes":[{"range":"1:1","type":"custom"}],
|
||||
"col_sizes":[{"range":"A:A","type":"pixel"}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--styles has 3 issues")
|
||||
for _, want := range []string{
|
||||
"border_styles.horizontal is not a valid side",
|
||||
`row_sizes[0].type "custom" is invalid`,
|
||||
"col_sizes[0].type pixel requires size",
|
||||
} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Errorf("aggregated message should contain %q, got %q", want, ve.Message)
|
||||
}
|
||||
}
|
||||
// D2: each type/size error inlines a complete valid op.
|
||||
if !strings.Contains(ve.Message, `{"range":"2:10","type":"pixel","size":32}`) {
|
||||
t.Errorf("row_sizes error should inline a full valid example, got %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, `{"range":"A:C","type":"pixel","size":120}`) {
|
||||
t.Errorf("col_sizes error should inline a full valid example, got %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_StylesBorderAllExpands verifies the "all" shorthand is
|
||||
// rewritten to four explicit sides instead of being rejected (or worse,
|
||||
// passed through for the server to reject, as happened on the typed-cells
|
||||
// path in eval V2U013/V2U021).
|
||||
func TestTablePut_StylesBorderAllExpands(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"s","cell_styles":[{"range":"A1:A1","border_styles":{"all":{"style":"solid","weight":"thin"}}}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
|
||||
}
|
||||
// table-put's dry-run body carries the tool input as an escaped JSON
|
||||
// string, so match the escaped key form.
|
||||
for _, side := range []string{`\"top\"`, `\"bottom\"`, `\"left\"`, `\"right\"`} {
|
||||
if !strings.Contains(stdout, side) {
|
||||
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, `\"all\"`) {
|
||||
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_BorderAllAndMisNestedBorder covers the typed --cells path:
|
||||
// the "all" shorthand expands CLI-side, and border_styles mis-nested inside
|
||||
// cell_styles is intercepted with a move-it prescription instead of a
|
||||
// server-side 900015206.
|
||||
func TestCellsSet_BorderAllAndMisNestedBorder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("border all expands", func(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":"x","border_styles":{"all":{"style":"solid"}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand and pass, got: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, `"all"`) || !strings.Contains(stdout, `"top"`) {
|
||||
t.Errorf("dry-run body should carry expanded sides, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mis-nested border_styles intercepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x","cell_styles":{"font_weight":"bold","border_styles":{"top":{"style":"solid"}}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "cell_styles.border_styles is not valid")
|
||||
if !strings.Contains(ve.Message, "sibling of cell_styles") {
|
||||
t.Errorf("message should prescribe moving it up one level, got %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_BorderAllExpands covers the --border-styles flag path
|
||||
// (+cells-set-style / +cells-batch-set-style go through borderStylesFromFlag,
|
||||
// not the typed --cells or --styles walkers): the "all" shorthand must expand
|
||||
// CLI-side here too, or the backend rejects {"all":…}.
|
||||
func TestCellsSetStyle_BorderAllExpands(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--border-styles", `{"all":{"style":"solid","weight":"thin"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
|
||||
}
|
||||
for _, side := range []string{`"top"`, `"bottom"`, `"left"`, `"right"`} {
|
||||
if !strings.Contains(stdout, side) {
|
||||
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, `"all"`) {
|
||||
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsMerge_RawAPIVocabularyNormalizes pins MERGE_ALL → all (the raw
|
||||
// OpenAPI enum agents copy from Lark API docs) via the enum alias table.
|
||||
func TestCellsMerge_RawAPIVocabularyNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-merge")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:B2",
|
||||
"--merge-type", "MERGE_ALL",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MERGE_ALL should normalize to all and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, `"all"`) {
|
||||
t.Errorf("dry-run body should carry the normalized merge type, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_WordWrapBooleanNormalizes pins --word-wrap true →
|
||||
// auto-wrap (eval V2U029).
|
||||
func TestCellsSetStyle_WordWrapBooleanNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--word-wrap", "true",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--word-wrap true should normalize to auto-wrap, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "auto-wrap") {
|
||||
t.Errorf("dry-run body should carry auto-wrap, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnderscoreFlagFormsParse pins the wire-vocabulary underscore rewrite:
|
||||
// --sheet_name / --border_styles parse as their hyphen forms (agents copy
|
||||
// field names out of JSON payloads where underscores are canonical).
|
||||
func TestUnderscoreFlagFormsParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet_name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--font_weight", "bold",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("underscore flag forms should parse as hyphen forms, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "bold") {
|
||||
t.Errorf("dry-run body should carry the style, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_UnderscoreFlagName pins --flag-name border_styles
|
||||
// resolving the border-styles schema (eval V2U013 burned a retry on this).
|
||||
func TestPrintFlagSchema_UnderscoreFlagName(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+cells-set-style")
|
||||
out, err := print("border_styles")
|
||||
if err != nil {
|
||||
t.Fatalf("underscore flag-name should resolve the hyphen schema, got: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected schema output")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_DottedPathSlices pins the schema sub-path slicing
|
||||
// contract on the real embedded chart schema: a dotted --flag-name returns
|
||||
// just that subtree, and a path miss lists the keys actually available.
|
||||
func TestPrintFlagSchema_DottedPathSlices(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+chart-create")
|
||||
|
||||
t.Run("slices a nested subtree", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
out, err := print("properties.snapshot.plotArea.axes")
|
||||
if err != nil {
|
||||
t.Fatalf("dotted path should slice, got: %v", err)
|
||||
}
|
||||
full, err2 := print("properties")
|
||||
if err2 != nil {
|
||||
t.Fatalf("full dump: %v", err2)
|
||||
}
|
||||
if len(out) == 0 || len(out) >= len(full) {
|
||||
t.Errorf("slice should be non-empty and smaller than the full schema (%d vs %d bytes)", len(out), len(full))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path miss lists available keys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := print("properties.snapshot.nosuchkey")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown path segment")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "available keys:") {
|
||||
t.Errorf("error should list available keys, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -118,7 +118,7 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new presentation.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ var WikiNodeCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime))
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.")
|
||||
dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new wiki node.")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
|
||||
@@ -635,7 +635,7 @@ func TestWikiNodeCreateBotAutoGrantSuccess(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 wiki node." {
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new wiki node." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-apps
|
||||
version: 1.0.0
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、本地全栈开发、云端生成迭代、创意设计(UI mockup / 可交互原型 / 线框图 / 落地页 / 仪表盘 / 幻灯片 deck / 视觉探索)、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或要设计 / design / mockup / prototype / wireframe / 做 PPT / deck / 视觉探索,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,7 +10,7 @@ metadata:
|
||||
|
||||
# apps (v1)
|
||||
|
||||
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
|
||||
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有两条开发路径:**本地开发**(拉源码本地写)/ **云端会话**(妙搭 AI 生成)。
|
||||
|
||||
## 身份与授权
|
||||
|
||||
@@ -32,16 +32,18 @@ lark-cli auth login --domain apps
|
||||
| 找已有 app_id、按名字过滤应用 | `+list --keyword <name>` | [`lark-apps-list.md`](references/lark-apps-list.md) |
|
||||
| 查单个应用详情(类型、名称、发布状态等) | `+get --app-id <app_id>` | [`lark-apps-get.md`](references/lark-apps-get.md) |
|
||||
| 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) |
|
||||
| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
|
||||
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md);修改源码还须遵守下方「平台资源与应用源码边界」 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
|
||||
| HTML 应用 / 创意模式 — 写 HTML 页面/网站、静态页、PPT/deck、落地页、仪表盘、UI mockup、原型、线框图、视觉探索 | 加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) | [`creative-design/creative-design.md`](creative-design/creative-design.md) |
|
||||
| 旧版存量 HTML 应用(无 Git 管理)继续上传已有静态产物 | `+html-publish`(仅兼容旧链路;新建 html / 创意模式 / creative-design 产物不得使用) | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
|
||||
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
|
||||
| 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) |
|
||||
| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) |
|
||||
| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) |
|
||||
| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) |
|
||||
| 逐条执行 SQL(SELECT / DML / DDL);建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
|
||||
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
|
||||
| **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
| **部署/上线应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push,再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
|
||||
| 创意模式(html)应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) |
|
||||
| 管理 `app_...` 应用内角色、角色成员,或查询用户匹配角色 | `+role-list/get/create/update/delete`, `+role-member-list/add/remove`, `+role-match-list` | [`lark-apps-role.md`](references/lark-apps-role.md) |
|
||||
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) |
|
||||
@@ -63,9 +65,9 @@ lark-cli auth login --domain apps
|
||||
|
||||
| 信号 | 判定 |
|
||||
|---|---|
|
||||
| 静态展示 / 单页 / PPT/demo / 无后端状态 | `app_type=html`,跳过本地/云端轴,开发完按 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(含"未提部署→先问是否发布") |
|
||||
| 静态展示 / 单页 / PPT/deck / demo / 落地页 / 仪表盘 / UI mockup / 可交互原型 / 线框图 / 视觉探索 / 无后端状态 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
|
||||
| 登录 / 数据库 / 持久化 / 多人协作 / 增删改查 / 报名 / 投票 / 站会 / OKR / 泛称"系统·工具" | `app_type=full_stack` |
|
||||
| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地全栈,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
|
||||
| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地开发,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
|
||||
| 让妙搭 AI 云端生成 / 对话式 / 自己不碰代码 | 云端会话,读 [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 未表达"谁来写"偏好 | **必须先问**(本地代码开发 vs 云端 AI 生成);选定前不擅自选边、不暗示默认,不得以"需求不模糊"为由跳过提问直接 `+init` / `git clone` / `+session-create` / 首轮 `+chat` |
|
||||
| 修改已有 + 当前目录是 `.spark/meta.json` 项目 | 直接继续本地按意图路由,不必问也不必判云端 |
|
||||
@@ -75,16 +77,19 @@ lark-cli auth login --domain apps
|
||||
|
||||
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
|
||||
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`:进应用编辑/开发态、管理与继续开发应用的入口。发布成功后,连同发布态链接一并提供给用户(说明"管理 / 继续开发去这里");但它仅进编辑态,**不能**顶替发布态链接当分享链接。
|
||||
- 发布态链接来源:html → `+html-publish` 的 `data.url`;全栈 → `+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`。
|
||||
- **可见范围**:发布态链接(html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set`(`tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`(仅 full_stack 应用):进应用编辑/开发态、管理与继续开发应用的入口。创意模式(html)应用开发态和发布态是同一个链接,无需额外提供开发态链接。
|
||||
- 发布态链接来源:`+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`(html 和 full_stack 统一走 `+release-get`)。
|
||||
- html 应用的主链路是创意模式开发方式:按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 初始化仓库、在仓库内产出 HTML 及关联文件,并通过 git commit / git push / `+release-create` / `+release-get` 发布部署。任何 git 操作(clone / pull / push)报错时,先执行 `lark-cli apps +git-credential-init --app-id <app_id> --as user` 刷新本地 Git 凭证,再重试原 git 命令。如果刷新凭证也失败,**停止并向用户报告**:原始 git 错误、凭证刷新失败原因,以及是否可能是当前环境(操作系统、沙箱)限制导致(如 macOS Keychain 在沙箱中不可用、Linux 加密文件目录不可写等)。不要改走 `+html-publish`,也不要把 `+html-publish` 当作本地开发链路的 fallback。
|
||||
- 创意模式(html)应用的链接格式为 `https://{租户域名}/page/{meta_token}`,**开发态和发布态是同一个链接**(区别于 full_stack 应用两者分开)。此链接形似飞书文档链接。`+get --app-id <meta_token>` 可获取应用信息(含 `app_id`),`+get --app-id <app_id>` 可获取 `meta_token`。看到 `/page/xxx` 链接时,它是妙搭创意模式应用,不要当成飞书文档跳过。
|
||||
|
||||
## 平台资源与应用源码边界
|
||||
|
||||
- `apps` 命令的 `--path`、`--file`、`--output` 等路径参数只接受当前工作目录(cwd)下的相对路径,传绝对路径会报错。如果目标文件不在 cwd 下,先 `cd` 到目标目录再执行命令。
|
||||
- 图片、字体、音视频等资源型文件属于平台资源,不应提交到 git 仓库、引用本地路径或以 base64 内联到源码中。先通过 `lark-cli apps +file-upload --app-id <app_id> --file <local_path>` 上传到应用文件存储,拿到返回的远端 URL 后在代码中引用。上传返回的链接按 app 隔离,不同应用必须各自重新上传,不能跨应用复用同一链接。详情读 [`lark-apps-file.md`](references/lark-apps-file.md)。
|
||||
- `apps +role-*` 只管理平台角色资源;修改已初始化应用的源码(包括当前目录已经是应用项目)时,先查看工作区 `.agents/skills/`,完整读取与任务匹配的领域 skill,再按其路由读取所需 reference。角色鉴权或运行态角色管理读应用内 `authz-guide`,不能用本 skill 的平台命令参考推断运行时合同。
|
||||
- `lark-cli` 只用于开发过程中的平台资源核验或变更。应用运行时代码必须使用工程内领域 skill 规定的 SDK,禁止通过 `exec` 或子进程调用 `lark-cli`。
|
||||
- 平台回读出的当前资源 ID、名称和成员只用于事实核验,不自动构成业务策略;除非需求或应用内领域 skill 明确定义,禁止把当前样本硬编码成 allowlist、denylist、只读集合或权限规则。
|
||||
- 实现领域 SDK 时,以实际包导出的类型和应用内领域 reference 记录的入参、响应路径为准;禁止修改 ambient `.d.ts`、补造宽松类型或强制断言,让猜测的 SDK 结构仅在本地“编译通过”。
|
||||
- 实现领域 SDK 时,以实际包导出的类型和应用内领域 reference 记录的入参、响应路径为准;禁止修改 ambient `.d.ts`、补造宽松类型或强制断言,让猜测的 SDK 结构仅在本地"编译通过"。
|
||||
- typecheck/build 成功不等于合同正确。交付前逐项核对每个 SDK 调用的入参、响应取值路径和策略分支;涉及更新、删除等不同动作时,分别验证各自动作所需的完整状态,不能复用更弱的前置判断。
|
||||
- 源码任务交付前确认新增页面、Controller、Module 已接入真实 router/bootstrap,并运行项目现有 typecheck/build;只创建未接线文件不算完成。
|
||||
- `+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限;应用协作者/开发权限仍需使用妙搭 Web。自动化触发器请用 `+automation-*`(见「意图路由」)。
|
||||
@@ -93,6 +98,12 @@ lark-cli auth login --domain apps
|
||||
|
||||
`app_id` 必须是妙搭应用 ID(`app_` 开头)。`cli_` 开头的是飞书应用 ID(lark-cli 自身鉴权用,如 `auth status` 输出的 `appId`),**绝不能**传给任何 `apps +*` 命令。
|
||||
|
||||
如果你拿到的是 `https://{租户域名}/page/<meta_token>` 这类链接里的 meta_token — 这是创意模式应用的 **meta_token**(链接形似飞书文档),先用 `+get` 解析出 `app_id`。如果拿到的不是链接、也不是 `app_` 开头,可能是裸 meta_token,同样先用 `+get --app-id <token>` 尝试获取应用信息,能正常返回则说明是 meta_token:
|
||||
|
||||
```bash
|
||||
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
|
||||
```
|
||||
|
||||
按顺序尝试,不要一上来要求用户手填:
|
||||
|
||||
1. 用户给出 `app_xxx` 或妙搭链接(如 `/app/app_xxx`)时直接提取。
|
||||
@@ -107,4 +118,4 @@ lark-cli auth login --domain apps
|
||||
## 高影响动作:确认与预授权
|
||||
|
||||
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
|
||||
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+role-delete`、`+role-member-remove --all`、批量移除成员必须先确认 app、role、成员范围和后果,不能从泛化"直接做"推导出 `--yes`;命令式“删除/移除某对象”只确定操作目标,不等于用户已确认不可逆后果,未明确确认时应在说明影响后停下请求确认;③ `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。
|
||||
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+role-delete`、`+role-member-remove --all`、批量移除成员必须先确认 app、role、成员范围和后果,不能从泛化"直接做"推导出 `--yes`;命令式"删除/移除某对象"只确定操作目标,不等于用户已确认不可逆后果,未明确确认时应在说明影响后停下请求确认;③ `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。
|
||||
|
||||
BIN
skills/lark-apps/creative-design/agents/assets/vision-probe.png
Normal file
BIN
skills/lark-apps/creative-design/agents/assets/vision-probe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,71 @@
|
||||
# Fork verifier (read-only)
|
||||
|
||||
You are a **read-only** verification subagent spawned to check a design
|
||||
deliverable the main agent just built or edited. Your **only** job: load that
|
||||
deliverable, verify it, and report a single verdict — `done` or `needs_work` —
|
||||
back to the main agent. **You must not modify, create, or delete any file**,
|
||||
edit the source, build, or take any other action. You read, probe, and report —
|
||||
nothing else. Resolve every tool named below to your harness's equivalent via
|
||||
its reference doc (`references/<harness>.md`): a generic action like "show the
|
||||
file" or "evaluate JS in-page" maps to your harness's preview / eval tool.
|
||||
|
||||
## Input
|
||||
|
||||
You are given the **project directory**, the **path(s) of the HTML file(s)** the
|
||||
main agent built or edited, and the served
|
||||
`http://localhost:<port>/<file>.html` URL to load (always over HTTP —
|
||||
never `file://`). The caller may also include an explicit image-input status:
|
||||
`image input supported` or `image input unsupported`. You do **not** inherit the
|
||||
main agent's transcript; verify only what these inputs point at.
|
||||
|
||||
## What to do
|
||||
|
||||
1. Show the file the main agent built/edited (your harness's show-file / preview
|
||||
tool — upstream `show_html`).
|
||||
2. Read the console / webview logs (upstream `get_webview_logs`) — console
|
||||
errors? failed loads?
|
||||
3. Screenshot — layout / spacing / type / content look right? Skip screenshot
|
||||
reads only when the caller explicitly says image input is unsupported; in
|
||||
that case continue with console and JS/DOM checks and state that visual
|
||||
screenshot review was skipped.
|
||||
4. Evaluate JS in-page (upstream `eval_js`) to probe if something seems off. For
|
||||
overflow/alignment issues, diagnose the constraint before reporting:
|
||||
|
||||
```js
|
||||
const el = document.querySelector('...'); const p = el.parentElement;
|
||||
const pick = (e, cs) => ({rect: e.getBoundingClientRect(), boxSizing: cs.boxSizing, display: cs.display, position: cs.position, width: cs.width, height: cs.height, minHeight: cs.minHeight, flexDirection: cs.flexDirection});
|
||||
JSON.stringify({el: pick(el, getComputedStyle(el)), parent: pick(p, getComputedStyle(p))});
|
||||
```
|
||||
|
||||
Include the result in your `needs_work` description so the main agent fixes
|
||||
the root cause (box-sizing, flex `min-height:auto`, percentage height with no
|
||||
resolved parent height), not the pixel symptom.
|
||||
5. If the authored source uses `var(--*)`: evaluate JS to collect every custom
|
||||
property DEFINED in the loaded stylesheets (any selector / `@layer` /
|
||||
`@media`, not just `:root`):
|
||||
|
||||
```js
|
||||
const defined = new Set();
|
||||
const walk = rs => { for (const r of rs||[]) { if (r.style) for (const p of r.style) if (p.startsWith('--')) defined.add(p); try { walk(r.cssRules || r.styleSheet?.cssRules); } catch {} } };
|
||||
for (const ss of document.styleSheets) try { walk(ss.cssRules); } catch {}
|
||||
JSON.stringify([...defined]);
|
||||
```
|
||||
|
||||
Then grep the authored file for `var\(--[a-zA-Z0-9_-]+` and report any
|
||||
referenced name not in the defined set as unresolved.
|
||||
6. Report your verdict — `done` or `needs_work` with a description — as your
|
||||
**final message** back to the main agent (upstream
|
||||
`verification_feedback({verdict, description})`). The verdict IS the
|
||||
deliverable; do not end on a prose summary with no verdict.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Read-only, always.** Never write or edit files, build, serve, or run write
|
||||
scripts. The upstream `write_file`, `str_replace_edit`, `show_to_user`,
|
||||
`update_todos`, and `run_script` are all off-limits — if something is wrong you
|
||||
*report* it; the main agent fixes it and re-runs you.
|
||||
- **`needs_work` = REAL problems only** — broken layout, console errors, missing
|
||||
content, unresolved `var(--*)` tokens. Not nitpicks.
|
||||
- **The verdict is the only exit.** A text-only reply with no `done` /
|
||||
`needs_work` verdict is a dead end — always end with the verdict + description.
|
||||
- Always load over the served `http://localhost:…` URL, never `file://`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user