mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
5 Commits
main
...
codex/base
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad89a71603 | ||
|
|
ac00cdaa9d | ||
|
|
08fc63963f | ||
|
|
0aaf7687e1 | ||
|
|
646514cdd0 |
35
internal/errclass/codemeta_base.go
Normal file
35
internal/errclass/codemeta_base.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
var baseCodeMeta = map[int]CodeMeta{
|
||||
// Copy Table domain errors (technical design chapter 18.2).
|
||||
800020304: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied},
|
||||
800010102: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition},
|
||||
800080105: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800040819: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict},
|
||||
800070003: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
800100112: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown},
|
||||
800100113: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown},
|
||||
800040114: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},
|
||||
800070115: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
800010109: {Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument},
|
||||
800030110: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound},
|
||||
800070111: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
|
||||
// Shared RPC errors used by Copy Table (technical design chapter 18.3).
|
||||
800040802: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800040803: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800020812: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied},
|
||||
800040832: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800040817: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800080821: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied},
|
||||
800070831: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
}
|
||||
|
||||
func init() {
|
||||
mergeCodeMeta(baseCodeMeta, "base")
|
||||
}
|
||||
51
internal/errclass/codemeta_base_test.go
Normal file
51
internal/errclass/codemeta_base_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMetaBaseTableCopyCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
code int
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
retryable bool
|
||||
}{
|
||||
// Copy Table domain errors documented in chapter 18.2.
|
||||
{code: 800020304, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied},
|
||||
{code: 800010102, category: errs.CategoryValidation, subtype: errs.SubtypeFailedPrecondition},
|
||||
{code: 800080105, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800040819, category: errs.CategoryAPI, subtype: errs.SubtypeConflict},
|
||||
{code: 800070003, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
{code: 800100112, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown},
|
||||
{code: 800100113, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown},
|
||||
{code: 800040114, category: errs.CategoryAPI, subtype: errs.SubtypeConflict, retryable: true},
|
||||
{code: 800070115, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
{code: 800010109, category: errs.CategoryValidation, subtype: errs.SubtypeInvalidArgument},
|
||||
{code: 800030110, category: errs.CategoryAPI, subtype: errs.SubtypeNotFound},
|
||||
{code: 800070111, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
|
||||
// Shared RPC errors used by Copy Table, documented in chapter 18.3.
|
||||
{code: 800040802, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800040803, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800020812, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied},
|
||||
{code: 800040832, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800040817, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800080821, category: errs.CategoryPolicy, subtype: errs.SubtypeAccessDenied},
|
||||
{code: 800070831, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprint(test.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(test.code)
|
||||
if !ok || meta.Category != test.category || meta.Subtype != test.subtype || meta.Retryable != test.retryable {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %#v, %v", test.code, meta, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type Stub struct {
|
||||
RawBody []byte // raw bytes (takes precedence over Body when non-nil)
|
||||
ContentType string // override Content-Type header (default: application/json)
|
||||
Headers http.Header // optional full response headers (takes precedence over ContentType)
|
||||
Error error // optional transport error returned after OnMatch
|
||||
matched bool
|
||||
|
||||
// BodyFilter (optional): match only when the captured request body satisfies
|
||||
@@ -93,6 +94,9 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if matched.OnMatch != nil {
|
||||
matched.OnMatch(req)
|
||||
}
|
||||
if matched.Error != nil {
|
||||
return nil, matched.Error
|
||||
}
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package httpmock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -112,3 +113,21 @@ func TestRegistry_CustomStatus(t *testing.T) {
|
||||
t.Errorf("want status 500, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_TransportError(t *testing.T) {
|
||||
wantErr := errors.New("connection reset")
|
||||
reg := &Registry{}
|
||||
reg.Register(&Stub{
|
||||
Method: "POST",
|
||||
URL: "/transport-error",
|
||||
Error: wantErr,
|
||||
})
|
||||
|
||||
client := NewClient(reg)
|
||||
req, _ := http.NewRequest("POST", "https://example.com/transport-error", nil)
|
||||
_, err := client.Do(req)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want transport error %v", err, wantErr)
|
||||
}
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
want := []string{
|
||||
"+url-resolve", "+title-resolve",
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", "+table-copy", "+table-copy-status",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
|
||||
|
||||
@@ -5,6 +5,7 @@ package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -386,6 +387,10 @@ func baseV3Path(parts ...string) string {
|
||||
}
|
||||
|
||||
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
return baseV3RawContext(runtime.Ctx(), runtime, method, path, params, data)
|
||||
}
|
||||
|
||||
func baseV3RawContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
for k, v := range params {
|
||||
switch val := v.(type) {
|
||||
@@ -409,7 +414,7 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
|
||||
}
|
||||
h := make(http.Header)
|
||||
h.Set("X-App-Id", runtime.Config.AppID)
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
|
||||
resp, err := runtime.DoAPIWithContext(ctx, req, larkcore.WithHeaders(h))
|
||||
if err != nil {
|
||||
return nil, baseAPIBoundaryError(err, "API call failed")
|
||||
}
|
||||
@@ -504,6 +509,11 @@ func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[
|
||||
return handleBaseAPIResult(result, err, "API call failed")
|
||||
}
|
||||
|
||||
func baseV3CallContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
result, err := baseV3RawContext(ctx, runtime, method, path, params, data)
|
||||
return handleBaseAPIResult(result, err, "API call failed")
|
||||
}
|
||||
|
||||
func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) {
|
||||
result, err := baseV3Raw(runtime, method, path, params, data)
|
||||
return handleBaseAPIResultAny(result, err, "API call failed")
|
||||
|
||||
@@ -20,6 +20,8 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseTableCreate,
|
||||
BaseTableUpdate,
|
||||
BaseTableDelete,
|
||||
BaseTableCopy,
|
||||
BaseTableCopyStatus,
|
||||
BaseFieldList,
|
||||
BaseFieldGet,
|
||||
BaseFieldCreate,
|
||||
|
||||
119
shortcuts/base/table_copy.go
Normal file
119
shortcuts/base/table_copy.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
tableCopyRangeSchema = "schema"
|
||||
tableCopyRangeAll = "all"
|
||||
tableCopyScope = "base:table:create"
|
||||
tableCopyTimeoutMax = 30 * time.Minute
|
||||
tableCopyTaskIDMax = 1024
|
||||
)
|
||||
|
||||
var BaseTableCopy = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+table-copy",
|
||||
Description: "Copy a table by ID or name; structure only by default",
|
||||
Risk: "write",
|
||||
Scopes: []string{tableCopyScope},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "name", Desc: "target table name", Required: true},
|
||||
{Name: "range", Default: tableCopyRangeSchema, Desc: "copy range; defaults to schema, use all only to include records", Enum: []string{tableCopyRangeSchema, tableCopyRangeAll}},
|
||||
{Name: "wait", Type: "bool", Desc: "wait for an all-range copy task to finish"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +table-copy --base-token <base_token> --table-id "Tasks" --name "Tasks copy"`,
|
||||
"table-id accepts a table ID or name in the current Base.",
|
||||
"The default copies schema only; use --range all only when records must also be copied.",
|
||||
"Use --wait with --range all to wait locally; otherwise continue with the returned next_command.",
|
||||
},
|
||||
DryRun: dryRunTableCopy,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
cmd.Flags().Duration("timeout", 5*time.Minute, "maximum time to wait for an asynchronous copy task (max 30m)")
|
||||
},
|
||||
Validate: validateTableCopy,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableCopy(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTableCopyStatus = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+table-copy-status",
|
||||
Description: "Get one table copy task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{tableCopyScope},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "task-id", Desc: "opaque table copy task ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use the opaque task_id returned by base +table-copy; this command queries status once.",
|
||||
"If state is init or process, run the returned next_command later.",
|
||||
},
|
||||
DryRun: dryRunTableCopyStatus,
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
taskID := runtime.Str("task-id")
|
||||
if strings.TrimSpace(taskID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id cannot be blank").WithParam("--task-id")
|
||||
}
|
||||
if len(taskID) > tableCopyTaskIDMax {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id must not exceed %d bytes", tableCopyTaskIDMax).WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableCopyStatus(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func validateTableCopy(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("table-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table-id cannot be blank").WithParam("--table-id")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name cannot be blank").WithParam("--name")
|
||||
}
|
||||
|
||||
rangeValue := runtime.Str("range")
|
||||
wait := runtime.Bool("wait")
|
||||
timeoutChanged := runtime.Changed("timeout")
|
||||
if rangeValue == tableCopyRangeSchema {
|
||||
if wait {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wait requires --range all").WithParam("--wait")
|
||||
}
|
||||
if timeoutChanged {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --range all and --wait").WithParam("--timeout")
|
||||
}
|
||||
}
|
||||
if timeoutChanged && !wait {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --wait").WithParam("--timeout")
|
||||
}
|
||||
timeout, err := tableCopyTimeout(runtime)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", err).WithParam("--timeout").WithCause(err)
|
||||
}
|
||||
if wait && (timeout <= 0 || timeout > tableCopyTimeoutMax) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout must be greater than 0 and at most 30m").WithParam("--timeout")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableCopyTimeout(runtime *common.RuntimeContext) (time.Duration, error) {
|
||||
return runtime.Cmd.Flags().GetDuration("timeout")
|
||||
}
|
||||
399
shortcuts/base/table_copy_ops.go
Normal file
399
shortcuts/base/table_copy_ops.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
tableCopyStateInit = "init"
|
||||
tableCopyStateProcess = "process"
|
||||
tableCopyStateSuccess = "success"
|
||||
)
|
||||
|
||||
type tableCopyTable struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type tableCopySubmitResult struct {
|
||||
Table tableCopyTable
|
||||
TaskID string
|
||||
State string
|
||||
}
|
||||
|
||||
type tableCopyStatus struct {
|
||||
TableID string
|
||||
State string
|
||||
}
|
||||
|
||||
type tableCopyOutput struct {
|
||||
Table tableCopyTable `json:"table"`
|
||||
Range string `json:"range,omitempty"`
|
||||
State string `json:"state"`
|
||||
Completed bool `json:"completed"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
TimedOut bool `json:"timed_out,omitempty"`
|
||||
NextAction string `json:"next_action,omitempty"`
|
||||
NextCommand string `json:"next_command,omitempty"`
|
||||
}
|
||||
|
||||
func dryRunTableCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
baseToken := runtime.Str("base-token")
|
||||
rangeValue := runtime.Str("range")
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/copy").
|
||||
Desc("[1] Submit table copy").
|
||||
Body(map[string]interface{}{
|
||||
"name": runtime.Str("name"),
|
||||
"range": rangeValue,
|
||||
}).
|
||||
Set("base_token", baseToken).
|
||||
Set("table_id", runtime.Str("table-id"))
|
||||
if runtime.Bool("wait") {
|
||||
dry.POST("/open-apis/base/v3/bases/:base_token/copy_table_state").
|
||||
Desc("[2] Poll with 3s exponential backoff, capped at 30s").
|
||||
Body(map[string]interface{}{"task_id": "<task_id_from_step_1>"})
|
||||
timeout, _ := tableCopyTimeout(runtime)
|
||||
dry.Set("wait", true).Set("timeout", timeout.String())
|
||||
} else {
|
||||
dry.Set("wait", false)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func dryRunTableCopyStatus(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy_table_state").
|
||||
Body(map[string]interface{}{"task_id": runtime.Str("task-id")}).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func executeTableCopy(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableCopyWithClock(ctx, runtime, realTableCopyClock{})
|
||||
}
|
||||
|
||||
func executeTableCopyWithClock(ctx context.Context, runtime *common.RuntimeContext, clock tableCopyClock) error {
|
||||
rangeValue := runtime.Str("range")
|
||||
submit, err := submitTableCopy(runtime, rangeValue)
|
||||
if err != nil {
|
||||
return tableCopySubmissionError(err)
|
||||
}
|
||||
if rangeValue == tableCopyRangeSchema {
|
||||
if submit.State != tableCopyStateSuccess {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "schema table copy returned non-success state %q", submit.State)
|
||||
}
|
||||
runtime.Out(tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: submit.State,
|
||||
Completed: true,
|
||||
}, nil)
|
||||
tableCopyProgressf(runtime, "Table copy completed: success")
|
||||
return nil
|
||||
}
|
||||
if submit.State == tableCopyStateSuccess {
|
||||
runtime.Out(tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: submit.State,
|
||||
Completed: true,
|
||||
TaskID: submit.TaskID,
|
||||
}, nil)
|
||||
tableCopyProgressf(runtime, "Table copy completed: success")
|
||||
return nil
|
||||
}
|
||||
if submit.TaskID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "all-range table copy response missing task_id")
|
||||
}
|
||||
if runtime.Bool("wait") {
|
||||
tableCopyProgressf(runtime, "Table copy submitted: %s, task_id=%s", submit.State, submit.TaskID)
|
||||
timeout, timeoutErr := tableCopyTimeout(runtime)
|
||||
if timeoutErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", timeoutErr).WithParam("--timeout").WithCause(timeoutErr)
|
||||
}
|
||||
stopSpinner := runtime.StartSpinner("Waiting for table copy")
|
||||
status, timedOut, pollErr := pollTableCopy(ctx, timeout, clock, func(ctx context.Context) (tableCopyStatus, error) {
|
||||
status, err := queryTableCopyStatus(ctx, runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
if err != nil {
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
tableCopyProgressf(runtime, "Table copy status query error: %s/%s", problem.Category, problem.Subtype)
|
||||
} else {
|
||||
tableCopyProgressf(runtime, "Table copy status query error")
|
||||
}
|
||||
return tableCopyStatus{}, err
|
||||
}
|
||||
tableCopyProgressf(runtime, "Table copy status: %s", status.State)
|
||||
return status, nil
|
||||
})
|
||||
stopSpinner()
|
||||
if pollErr != nil {
|
||||
recoveryState := status.State
|
||||
if recoveryState == "" {
|
||||
recoveryState = submit.State
|
||||
}
|
||||
recovery := tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: recoveryState,
|
||||
Completed: false,
|
||||
TaskID: submit.TaskID,
|
||||
}
|
||||
if tableCopyWaitCanContinue(pollErr) {
|
||||
recovery.NextAction = "poll_status"
|
||||
recovery.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
}
|
||||
recoveryErr := runtime.OutPartialFailure(recovery, nil)
|
||||
var partialFailure *output.PartialFailureError
|
||||
if !errors.As(recoveryErr, &partialFailure) {
|
||||
return recoveryErr
|
||||
}
|
||||
return tableCopyWaitError(pollErr)
|
||||
}
|
||||
if timedOut && status.State == "" {
|
||||
// No status query completed before the deadline. The submit response
|
||||
// is still the last known task state, so preserve it.
|
||||
status.State = submit.State
|
||||
}
|
||||
out := tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: status.State,
|
||||
Completed: status.State == tableCopyStateSuccess,
|
||||
TaskID: submit.TaskID,
|
||||
TimedOut: timedOut,
|
||||
}
|
||||
if !out.Completed {
|
||||
out.NextAction = "poll_status"
|
||||
out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
tableCopyProgressf(runtime, "Table copy is not complete; use next_command from stdout to continue")
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
out := tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: submit.State,
|
||||
Completed: submit.State == tableCopyStateSuccess,
|
||||
TaskID: submit.TaskID,
|
||||
}
|
||||
if !out.Completed {
|
||||
out.NextAction = "poll_status"
|
||||
out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
tableCopyProgressf(runtime, "Table copy is running asynchronously; use next_command from stdout to continue")
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableCopySubmissionError(err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork {
|
||||
return err
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeNetworkTimeout && problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
return err
|
||||
}
|
||||
problem.Message = "table copy submission outcome is unknown because the response was not received"
|
||||
problem.Hint = "Do not retry the copy automatically. Manually confirm whether the target table was created before deciding the next action."
|
||||
problem.Retryable = false
|
||||
return err
|
||||
}
|
||||
|
||||
func tableCopyWaitCanContinue(err error) bool {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
switch problem.Category {
|
||||
case errs.CategoryAuthentication, errs.CategoryAuthorization:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return tableCopyPollErrorRetryable(err)
|
||||
}
|
||||
|
||||
func tableCopyWaitError(err error) error {
|
||||
if !tableCopyWaitCanContinue(err) {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
hint := "The copy task was already submitted; do not submit it again. Read task_id from the submit output and continue with lark-cli base +table-copy-status using the same identity."
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "table copy status polling was canceled").WithHint("%s", hint).WithCause(err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "table copy status polling timed out").WithHint("%s", hint).WithCause(err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
if problem.Hint == "" {
|
||||
problem.Hint = hint
|
||||
} else {
|
||||
problem.Hint += " " + hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithHint("%s", hint).WithCause(err)
|
||||
}
|
||||
|
||||
func executeTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
taskID := runtime.Str("task-id")
|
||||
status, err := queryTableCopyStatus(ctx, runtime, baseToken, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := tableCopyOutput{
|
||||
Table: tableCopyTable{ID: status.TableID},
|
||||
State: status.State,
|
||||
Completed: status.State == tableCopyStateSuccess,
|
||||
TaskID: taskID,
|
||||
}
|
||||
if !out.Completed {
|
||||
out.NextAction = "poll_status"
|
||||
out.NextCommand = tableCopyNextCommand(runtime, baseToken, taskID)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
tableCopyProgressf(runtime, "Table copy status: %s", status.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func submitTableCopy(runtime *common.RuntimeContext, rangeValue string) (tableCopySubmitResult, error) {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableRef := runtime.Str("table-id")
|
||||
body := map[string]interface{}{
|
||||
"name": runtime.Str("name"),
|
||||
"range": rangeValue,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableRef, "copy"), nil, body)
|
||||
if err != nil {
|
||||
return tableCopySubmitResult{}, err
|
||||
}
|
||||
return projectTableCopySubmit(data)
|
||||
}
|
||||
|
||||
func projectTableCopySubmit(data map[string]interface{}) (tableCopySubmitResult, error) {
|
||||
tableData := common.GetMap(data, "table")
|
||||
result := tableCopySubmitResult{
|
||||
Table: tableCopyTable{
|
||||
ID: strings.TrimSpace(common.GetString(tableData, "id")),
|
||||
Name: common.GetString(tableData, "name"),
|
||||
},
|
||||
TaskID: strings.TrimSpace(common.GetString(data, "task_id")),
|
||||
State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))),
|
||||
}
|
||||
if result.Table.ID == "" {
|
||||
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response missing table.id")
|
||||
}
|
||||
if len(result.TaskID) > tableCopyTaskIDMax {
|
||||
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response task_id exceeds %d bytes", tableCopyTaskIDMax)
|
||||
}
|
||||
switch result.State {
|
||||
case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess:
|
||||
return result, nil
|
||||
default:
|
||||
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response has invalid state %q", result.State)
|
||||
}
|
||||
}
|
||||
|
||||
func queryTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext, baseToken, taskID string) (tableCopyStatus, error) {
|
||||
data, err := baseV3CallContext(
|
||||
ctx,
|
||||
runtime,
|
||||
"POST",
|
||||
baseV3Path("bases", baseToken, "copy_table_state"),
|
||||
nil,
|
||||
map[string]interface{}{"task_id": taskID},
|
||||
)
|
||||
if err != nil {
|
||||
return tableCopyStatus{}, tableCopyStatusError(err)
|
||||
}
|
||||
return projectTableCopyStatus(data)
|
||||
}
|
||||
|
||||
func tableCopyStatusError(err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Code != 800010109 {
|
||||
return err
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
validationErr.WithParam("--task-id")
|
||||
return err
|
||||
}
|
||||
classified := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", problem.Message).
|
||||
WithParam("--task-id").
|
||||
WithCode(problem.Code).
|
||||
WithCause(err)
|
||||
if problem.Hint != "" {
|
||||
classified.WithHint("%s", problem.Hint)
|
||||
}
|
||||
if problem.LogID != "" {
|
||||
classified.WithLogID(problem.LogID)
|
||||
}
|
||||
return classified
|
||||
}
|
||||
|
||||
func projectTableCopyStatus(data map[string]interface{}) (tableCopyStatus, error) {
|
||||
status := tableCopyStatus{
|
||||
TableID: strings.TrimSpace(common.GetString(data, "table_id")),
|
||||
State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))),
|
||||
}
|
||||
if status.TableID == "" {
|
||||
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response missing table_id")
|
||||
}
|
||||
switch status.State {
|
||||
case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess:
|
||||
return status, nil
|
||||
case "failed":
|
||||
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status returned state=failed in a success envelope; the API must return task failures through the top-level error protocol")
|
||||
default:
|
||||
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response has invalid state %q", status.State)
|
||||
}
|
||||
}
|
||||
|
||||
func tableCopyNextCommand(runtime *common.RuntimeContext, baseToken, taskID string) string {
|
||||
parts := []string{"lark-cli"}
|
||||
if runtime.Cmd.Flags().Lookup("profile") != nil && runtime.Changed("profile") {
|
||||
profile, _ := runtime.Cmd.Flags().GetString("profile")
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
parts = append(parts, "--profile", tableCopyShellArg(profile))
|
||||
}
|
||||
}
|
||||
parts = append(parts,
|
||||
"base", "+table-copy-status",
|
||||
"--base-token", tableCopyShellArg(baseToken),
|
||||
"--task-id", tableCopyShellArg(taskID),
|
||||
"--as", string(runtime.As()),
|
||||
)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func tableCopyShellArg(value string) string {
|
||||
if value != "" && strings.IndexFunc(value, func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') && !strings.ContainsRune("._~-", r)
|
||||
}) == -1 {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
func tableCopyProgressf(runtime *common.RuntimeContext, format string, args ...interface{}) {
|
||||
if runtime == nil || runtime.IO() == nil || runtime.IO().ErrOut == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, format+"\n", args...)
|
||||
}
|
||||
139
shortcuts/base/table_copy_poll.go
Normal file
139
shortcuts/base/table_copy_poll.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
tableCopyPollInitial = 3 * time.Second
|
||||
tableCopyPollMax = 30 * time.Second
|
||||
)
|
||||
|
||||
type tableCopyTimer interface {
|
||||
C() <-chan time.Time
|
||||
Stop() bool
|
||||
}
|
||||
|
||||
type tableCopyClock interface {
|
||||
Now() time.Time
|
||||
NewTimer(time.Duration) tableCopyTimer
|
||||
}
|
||||
|
||||
type tableCopyStatusFetcher func(context.Context) (tableCopyStatus, error)
|
||||
|
||||
type realTableCopyClock struct{}
|
||||
|
||||
func (realTableCopyClock) Now() time.Time { return time.Now() }
|
||||
|
||||
func (realTableCopyClock) NewTimer(duration time.Duration) tableCopyTimer {
|
||||
return realTableCopyTimer{Timer: time.NewTimer(duration)}
|
||||
}
|
||||
|
||||
type realTableCopyTimer struct {
|
||||
*time.Timer
|
||||
}
|
||||
|
||||
func (t realTableCopyTimer) C() <-chan time.Time { return t.Timer.C }
|
||||
|
||||
func pollTableCopy(
|
||||
ctx context.Context,
|
||||
timeout time.Duration,
|
||||
clock tableCopyClock,
|
||||
fetch tableCopyStatusFetcher,
|
||||
) (tableCopyStatus, bool, error) {
|
||||
deadline := clock.Now().Add(timeout)
|
||||
delay := tableCopyPollInitial
|
||||
var lastStatus tableCopyStatus
|
||||
var lastErr error
|
||||
hasStatus := false
|
||||
|
||||
for {
|
||||
remaining := deadline.Sub(clock.Now())
|
||||
if remaining <= 0 {
|
||||
if !hasStatus && lastErr != nil {
|
||||
return tableCopyStatus{}, false, lastErr
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
if delay > remaining {
|
||||
delay = remaining
|
||||
}
|
||||
|
||||
timer := clock.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-timer.C():
|
||||
}
|
||||
|
||||
if !clock.Now().Before(deadline) {
|
||||
if !hasStatus && lastErr != nil {
|
||||
return tableCopyStatus{}, false, lastErr
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
requestBudget := deadline.Sub(clock.Now())
|
||||
if requestBudget <= 0 {
|
||||
if !hasStatus && lastErr != nil {
|
||||
return tableCopyStatus{}, false, lastErr
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
fetchCtx, cancelFetch := context.WithTimeout(ctx, requestBudget)
|
||||
status, err := fetch(fetchCtx)
|
||||
cancelFetch()
|
||||
if ctx.Err() != nil {
|
||||
return lastStatus, false, ctx.Err()
|
||||
}
|
||||
if !clock.Now().Before(deadline) {
|
||||
if !hasStatus && err != nil {
|
||||
return tableCopyStatus{}, false, err
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
if !tableCopyPollErrorRetryable(err) {
|
||||
return lastStatus, false, err
|
||||
}
|
||||
lastErr = err
|
||||
} else {
|
||||
lastStatus = status
|
||||
hasStatus = true
|
||||
switch status.State {
|
||||
case tableCopyStateSuccess:
|
||||
return status, false, nil
|
||||
case tableCopyStateInit, tableCopyStateProcess:
|
||||
default:
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status has invalid state %q", status.State)
|
||||
}
|
||||
}
|
||||
|
||||
delay *= 2
|
||||
if delay > tableCopyPollMax {
|
||||
delay = tableCopyPollMax
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableCopyPollErrorRetryable(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if problem.Category == errs.CategoryNetwork {
|
||||
switch problem.Subtype {
|
||||
case errs.SubtypeNetworkTimeout, errs.SubtypeNetworkTransport, errs.SubtypeNetworkServer:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return problem.Category == errs.CategoryAPI && problem.Retryable
|
||||
}
|
||||
1411
shortcuts/base/table_copy_test.go
Normal file
1411
shortcuts/base/table_copy_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -450,6 +450,12 @@ func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interfa
|
||||
// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating
|
||||
// the identity → token logic across the generic and shortcut API paths.
|
||||
func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
return ctx.DoAPIWithContext(ctx.ctx, req, opts...)
|
||||
}
|
||||
|
||||
// DoAPIWithContext executes a raw Lark SDK request using callCtx for request
|
||||
// cancellation and deadlines while preserving the shortcut's resolved identity.
|
||||
func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -457,7 +463,7 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
|
||||
return ac.DoSDKRequest(callCtx, req, ctx.As(), opts...)
|
||||
}
|
||||
|
||||
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.3
|
||||
version: 1.2.4
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive,认证/授权转 lark-shared。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -54,6 +54,7 @@ metadata:
|
||||
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
|
||||
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 |
|
||||
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
|
||||
| 复制 Base 内单张数据表 | `+table-copy` / `+table-copy-status` | 默认只复制结构;只有用户明确要求复制全表、数据、行或记录时才传 `--range all`;异步任务按返回的 `task_id` 查询或续等 |
|
||||
| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 |
|
||||
| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) |
|
||||
| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) |
|
||||
@@ -79,6 +80,7 @@ metadata:
|
||||
- `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除;具体资源内容仍走 table/dashboard/workflow 命令。
|
||||
- 新建 Base 时,强烈推荐一次性执行 `lark-cli base +base-create --name "<base>" --table-name "<table>" --fields '<field-json-array>'`,同时配置新 Base 里唯一一个初始数据表的 name 和 schema;使用 `--fields` 前先读 [lark-base-field-json.md](references/lark-base-field-json.md) 或复用 `+field-create` 的字段 JSON 形状,不要猜字段属性。
|
||||
- `+base-create` 不传 `--table-name` 和 `--fields` 时,会创建一个默认 schema 的初始数据表。
|
||||
- `+table-copy` 的安全默认值是只复制表结构;用户没有明确要求记录时省略 `--range`,明确要求包含记录时才传 `--range all`。`--table-id` 可直接使用当前 Base 中的表 ID 或表名。
|
||||
- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。
|
||||
- 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。
|
||||
- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
|
||||
@@ -89,6 +91,7 @@ metadata:
|
||||
## 身份与权限降级
|
||||
|
||||
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`。
|
||||
- `+table-copy --wait` 提交成功后会在 stderr 打印完整 `task_id`;若进程被 Ctrl-C 终止,可用该 ID 和原身份执行 `+table-copy-status` 续查,不要重新提交复制。
|
||||
- user 身份报 scope/授权不足,或错误中包含 `missing_scopes` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
|
||||
- user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次;bot 仍失败就停止重试并按权限错误处理。
|
||||
- `91403` 或明确不可访问错误不要循环换身份重试。
|
||||
|
||||
86
tests/cli_e2e/base/base_table_copy_dryrun_test.go
Normal file
86
tests/cli_e2e/base/base_table_copy_dryrun_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseTableCopyDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("schema default with table name", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+table-copy",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "Source Tasks",
|
||||
"--name", "Copied Tasks",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/Source%20Tasks/copy", clie2e.DryRunGet(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "Copied Tasks", clie2e.DryRunGet(out, "api.0.body.name").String(), out)
|
||||
require.Equal(t, "schema", clie2e.DryRunGet(out, "api.0.body.range").String(), out)
|
||||
require.False(t, clie2e.DryRunGet(out, "api.1").Exists(), out)
|
||||
})
|
||||
|
||||
t.Run("all with wait", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+table-copy",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_source",
|
||||
"--name", "Copied Tasks",
|
||||
"--range", "all",
|
||||
"--wait",
|
||||
"--timeout", "300s",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "all", clie2e.DryRunGet(out, "api.0.body.range").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/copy_table_state", clie2e.DryRunGet(out, "api.1.url").String(), out)
|
||||
require.Equal(t, "<task_id_from_step_1>", clie2e.DryRunGet(out, "api.1.body.task_id").String(), out)
|
||||
require.True(t, clie2e.DryRunGet(out, "wait").Bool(), out)
|
||||
require.Equal(t, "5m0s", clie2e.DryRunGet(out, "timeout").String(), out)
|
||||
})
|
||||
|
||||
t.Run("status", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+table-copy-status",
|
||||
"--base-token", "app_x",
|
||||
"--task-id", "ct1.token",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
require.Equal(t, "/open-apis/base/v3/bases/app_x/copy_table_state", clie2e.DryRunGet(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "ct1.token", clie2e.DryRunGet(out, "api.0.body.task_id").String(), out)
|
||||
})
|
||||
}
|
||||
167
tests/cli_e2e/base/base_table_copy_workflow_test.go
Normal file
167
tests/cli_e2e/base/base_table_copy_workflow_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBaseTableCopyWorkflow(t *testing.T) {
|
||||
if os.Getenv("LARK_CLI_E2E_BASE_TABLE_COPY_READY") != "1" {
|
||||
t.Skip("set LARK_CLI_E2E_BASE_TABLE_COPY_READY=1 after the table-copy OpenAPI is deployed")
|
||||
}
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-table-copy-"+clie2e.GenerateSuffix())
|
||||
sourceTableID, _, _ := createTableWithRetry(
|
||||
t,
|
||||
parentT,
|
||||
ctx,
|
||||
baseToken,
|
||||
"Copy source "+clie2e.GenerateSuffix(),
|
||||
`[{"name":"Name","type":"text"}]`,
|
||||
`{"name":"Main","type":"grid"}`,
|
||||
)
|
||||
|
||||
seed, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+record-batch-create",
|
||||
"--base-token", baseToken,
|
||||
"--table-id", sourceTableID,
|
||||
"--json", `{"fields":["Name"],"rows":[["copied record"]]}`,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
seed.AssertExitCode(t, 0)
|
||||
seed.AssertStdoutStatus(t, true)
|
||||
|
||||
cleanupCopiedTable := func(tableID string) {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, tableID)
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := cleanupContext()
|
||||
defer cleanupCancel()
|
||||
result, cleanupErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
if cleanupErr != nil || result == nil || result.ExitCode != 0 {
|
||||
reportCleanupFailure(parentT, "delete copied table "+tableID, result, cleanupErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
countRecords := func(tableID string) int64 {
|
||||
t.Helper()
|
||||
result, runErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+record-list", "--base-token", baseToken, "--table-id", tableID, "--limit", "10"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, runErr)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
return gjson.Get(result.Stdout, "data.record_id_list.#").Int()
|
||||
}
|
||||
|
||||
t.Run("schema default", func(t *testing.T) {
|
||||
result, runErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+table-copy",
|
||||
"--base-token", baseToken,
|
||||
"--table-id", sourceTableID,
|
||||
"--name", "Schema copy " + clie2e.GenerateSuffix(),
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, runErr)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, "schema", gjson.Get(result.Stdout, "data.range").String(), result.Stdout)
|
||||
require.Equal(t, "success", gjson.Get(result.Stdout, "data.state").String(), result.Stdout)
|
||||
targetID := gjson.Get(result.Stdout, "data.table.id").String()
|
||||
cleanupCopiedTable(targetID)
|
||||
require.Equal(t, int64(0), countRecords(targetID))
|
||||
})
|
||||
|
||||
t.Run("all no-wait and status", func(t *testing.T) {
|
||||
result, runErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+table-copy",
|
||||
"--base-token", baseToken,
|
||||
"--table-id", sourceTableID,
|
||||
"--name", "Async copy " + clie2e.GenerateSuffix(),
|
||||
"--range", "all",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, runErr)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
targetID := gjson.Get(result.Stdout, "data.table.id").String()
|
||||
taskID := gjson.Get(result.Stdout, "data.task_id").String()
|
||||
cleanupCopiedTable(targetID)
|
||||
require.NotEmpty(t, taskID, result.Stdout)
|
||||
|
||||
var lastStatus string
|
||||
waitErr := clie2e.WaitForCondition(ctx, clie2e.WaitOptions{
|
||||
Timeout: 2 * time.Minute,
|
||||
Interval: 3 * time.Second,
|
||||
TimeoutError: func() error {
|
||||
return fmt.Errorf("table copy still %s", lastStatus)
|
||||
},
|
||||
}, func() (bool, error) {
|
||||
status, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+table-copy-status", "--base-token", baseToken, "--task-id", taskID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
if statusErr != nil {
|
||||
return false, statusErr
|
||||
}
|
||||
if status.ExitCode != 0 {
|
||||
return false, fmt.Errorf("status failed: stdout=%s stderr=%s", status.Stdout, status.Stderr)
|
||||
}
|
||||
lastStatus = gjson.Get(status.Stdout, "data.state").String()
|
||||
if lastStatus == "failed" {
|
||||
return false, fmt.Errorf("copy task failed: %s", status.Stdout)
|
||||
}
|
||||
return lastStatus == "success", nil
|
||||
})
|
||||
require.NoError(t, waitErr)
|
||||
require.Equal(t, int64(1), countRecords(targetID))
|
||||
})
|
||||
|
||||
t.Run("all wait", func(t *testing.T) {
|
||||
result, runErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+table-copy",
|
||||
"--base-token", baseToken,
|
||||
"--table-id", sourceTableID,
|
||||
"--name", "Wait copy " + clie2e.GenerateSuffix(),
|
||||
"--range", "all",
|
||||
"--wait",
|
||||
"--timeout", "2m",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, runErr)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, "success", gjson.Get(result.Stdout, "data.state").String(), result.Stdout)
|
||||
targetID := gjson.Get(result.Stdout, "data.table.id").String()
|
||||
cleanupCopiedTable(targetID)
|
||||
require.Equal(t, int64(1), countRecords(targetID))
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
# Base CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 87 leaf commands
|
||||
- Covered: 28
|
||||
- Coverage: 32.2%
|
||||
- Denominator: 89 leaf commands
|
||||
- Covered: 30
|
||||
- Coverage: 33.7%
|
||||
|
||||
## Summary
|
||||
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
|
||||
@@ -17,8 +17,10 @@
|
||||
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
|
||||
- TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint.
|
||||
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
|
||||
- TestBaseTableCopyDryRun: proves `+table-copy` and `+table-copy-status` request shapes, including the schema-safe default, table-name path escaping, explicit all+wait orchestration, Cobra duration parsing, and the opaque task ID body.
|
||||
- TestBaseTableCopyWorkflow: feature-gated by `LARK_CLI_E2E_BASE_TABLE_COPY_READY=1` until the OpenAPI is deployed; creates a source table and record, proves schema-only copy, all no-wait plus status, all wait, record inclusion, and cleanup.
|
||||
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
|
||||
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
- Blocked area: table-copy live integration remains deployment-gated; dashboard, field, most record operations, most form operations, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -82,6 +84,8 @@
|
||||
| ✓ | base +role-list | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/list as bot | `--base-token` | |
|
||||
| ✓ | base +role-update | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/update as bot | `--base-token`; `--role-id`; `--json` | |
|
||||
| ✓ | base +table-create | shortcut | base/helpers_test.go::createTableWithRetry | `--base-token`; `--name`; optional `--fields`; optional `--view` | helper asserts table id |
|
||||
| ✓ | base +table-copy | shortcut | base_table_copy_dryrun_test.go::TestBaseTableCopyDryRun; base_table_copy_workflow_test.go::TestBaseTableCopyWorkflow | `--table-id` ID/name; default `--range schema`; explicit `--range all --wait --timeout` | dry-run covered; live workflow is deployment-gated and not yet verified |
|
||||
| ✓ | base +table-copy-status | shortcut | base_table_copy_dryrun_test.go::TestBaseTableCopyDryRun/status; base_table_copy_workflow_test.go::TestBaseTableCopyWorkflow/all no-wait and status | opaque `--task-id` | dry-run covered; live polling is deployment-gated and not yet verified |
|
||||
| ✕ | base +table-delete | shortcut | | none | cleanup only |
|
||||
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
|
||||
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
|
||||
|
||||
Reference in New Issue
Block a user