Compare commits

...

5 Commits

Author SHA1 Message Date
fongwave
ad89a71603 fix: preserve table copy auth recovery 2026-08-02 15:50:17 +08:00
fongwave
ac00cdaa9d fix: align table copy recovery with API errors 2026-08-02 15:50:17 +08:00
fongwave
08fc63963f fix: preserve table copy task state 2026-08-02 15:50:17 +08:00
fongwave
0aaf7687e1 test: cover Base table copy edge cases 2026-08-02 15:50:17 +08:00
fongwave
646514cdd0 feat: add Base table copy shortcuts 2026-08-02 15:50:17 +08:00
16 changed files with 2463 additions and 8 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ func Shortcuts() []common.Shortcut {
BaseTableCreate,
BaseTableUpdate,
BaseTableDelete,
BaseTableCopy,
BaseTableCopyStatus,
BaseFieldList,
BaseFieldGet,
BaseFieldCreate,

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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` 或明确不可访问错误不要循环换身份重试。

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

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

View File

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