Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion shortcuts/base/base_dryrun_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ func TestDryRunTableOps(t *testing.T) {

rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_1", "name": "Orders"}, nil, nil)
assertDryRunContains(t, dryRunTableGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1")
assertDryRunContains(t, dryRunTableCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables")

// +table-create requires --fields, so the fieldless shape is unreachable
// through the command surface; see table_create_test.go for that contract.
tableCreateWithFieldsRT := newBaseTestRuntime(
map[string]string{"base-token": "app_x", "name": "Orders", "fields": `[{"name":"Title","type":"text"}]`},
nil,
Expand Down
17 changes: 12 additions & 5 deletions shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "table create fields",
shortcut: BaseTableCreate,
wantHelp: []string{
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
`field JSON array defining the table schema; must hold at least one field, e.g. [{"name":"Title","type":"text"}`,
},
},
{
Expand Down Expand Up @@ -1219,10 +1219,17 @@ func TestBaseFieldValidate(t *testing.T) {

func TestBaseTableValidate(t *testing.T) {
ctx := context.Background()
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid fields json should bypass CLI validate, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "view": `[1]`}, nil, nil)); err != nil {
// --fields carries the whole table schema, so it is parsed at validate time:
// an unusable schema must fail before the table exists, not after. The
// rejection has to stay machine-readable, so assert the typed metadata and
// the preserved parse cause rather than the message alone.
err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": "{"}, nil, nil))
assertInvalidArgumentValidation(t, err, "--fields", []string{"--fields"}, "invalid JSON array")
var syntaxErr *json.SyntaxError
if !errors.As(err, &syntaxErr) {
t.Fatalf("invalid fields json must preserve the json parse cause, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": `[{"name":"Name","type":"text"}]`, "view": `[1]`}, nil, nil)); err != nil {
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": `[{"name":"Name","type":"text"}]`, "view": `{"name":"Main"}`}, nil, nil)); err != nil {
Expand Down
11 changes: 9 additions & 2 deletions shortcuts/base/table_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,25 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)

// BaseTableCreate creates a table with an explicit schema. --fields is required
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
// Validate runs with cobra's standard "required flag(s)" error (which the
// dispatcher classifies as a typed *errs.ValidationError). validateTableCreate
// still rejects blank, non-array and empty-array values, because cobra accepts
// --fields "" and --fields "[]" — both of which would reach the API without a
// fields body and get the platform default schema instead of the caller's.
var BaseTableCreate = common.Shortcut{
Service: "base",
Command: "+table-create",
Description: "Create a table and optional fields/views",
Description: "Create a table with an explicit field schema, plus optional views",
Risk: "write",
Scopes: []string{"base:table:create", "base:field:read", "base:field:create", "base:field:update", "base:view:write_only"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "name", Desc: "table name", Required: true},
{Name: "view", Desc: "view JSON object/array for create"},
{Name: "fields", Desc: `field JSON array for create, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`},
{Name: "fields", Required: true, Desc: `field JSON array defining the table schema; must hold at least one field, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}]`},
},
Tips: []string{
"Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.",
Expand Down
99 changes: 99 additions & 0 deletions shortcuts/base/table_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"strings"
"testing"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)

func tableCreateFlag(t *testing.T, name string) common.Flag {
t.Helper()
for _, flag := range BaseTableCreate.Flags {
if flag.Name == name {
return flag
}
}
t.Fatalf("+table-create has no --%s flag", name)
return common.Flag{}
}

// The declaration itself is the contract: --fields must stay cobra-required so
// `--help` and the machine-readable schema both advertise it, not just Validate.
func TestBaseTableCreateDeclaresFieldsRequired(t *testing.T) {
if !tableCreateFlag(t, "fields").Required {
t.Fatal("--fields must be declared Required on +table-create")
}
}

// A missing flag fails inside cobra's ValidateRequiredFlags, which emits a plain
// error — the typed envelope is applied later by the dispatcher, whose
// classification of "required flag(s)" is pinned in cmd/root_test.go. So this
// test asserts the text contract the dispatcher keys on, and pins the layer
// boundary itself; asserting errs metadata here would test a promise this layer
// does not make.
func TestBaseTableCreateRejectsMissingFields(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

err := runShortcut(t, BaseTableCreate, []string{"+table-create", "--base-token", "app_x", "--name", "Orders"}, factory, stdout)
if err == nil {
t.Fatal("expected +table-create without --fields to fail")
}
if !strings.Contains(err.Error(), `required flag(s) "fields" not set`) {
t.Fatalf("err=%v, want cobra required-flag error for fields", err)
}
if _, typed := errs.ProblemOf(err); typed {
t.Fatal("cobra required-flag errors reach the dispatcher untyped; if that changed, assert the typed metadata here instead")
}
}

// cobra's MarkFlagRequired only checks that the flag was set, so blank and
// empty-array values still reach Validate. Both would otherwise create a table
// with the platform default schema instead of the caller's.
func TestBaseTableCreateRejectsBlankFields(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

err := runShortcut(t, BaseTableCreate, []string{"+table-create", "--base-token", "app_x", "--name", "Orders", "--fields", " "}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--fields", nil, "cannot be blank")
}

func TestBaseTableCreateRejectsEmptyFieldsArray(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

err := runShortcut(t, BaseTableCreate, []string{"+table-create", "--base-token", "app_x", "--name", "Orders", "--fields", "[]"}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--fields", nil, "at least one field")
}

func TestBaseTableCreateRejectsNonObjectFieldItem(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

err := runShortcut(t, BaseTableCreate, []string{"+table-create", "--base-token", "app_x", "--name", "Orders", "--fields", `["Title"]`}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--fields", nil, "must be an object")
}

// Validate runs ahead of the dry-run branch, so --dry-run cannot be used to
// preview an invocation the real call would reject.
func TestBaseTableCreateDryRunRejectsEmptyFieldsArray(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

err := runShortcut(t, BaseTableCreate, []string{"+table-create", "--base-token", "app_x", "--name", "Orders", "--fields", "[]", "--dry-run"}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--fields", nil, "at least one field")
if stdout.Len() != 0 {
t.Fatalf("rejected dry-run must not print a request preview, stdout=%s", stdout.String())
}
}

func TestBaseTableCreateValidateAcceptsFieldSchema(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{
"base-token": "app_x",
"name": "Orders",
"fields": `[{"name":"OrderNo","type":"text"}]`,
}, nil, nil)
if err := validateTableCreate(runtime); err != nil {
t.Fatalf("valid field schema rejected: %v", err)
}
}
21 changes: 21 additions & 0 deletions shortcuts/base/table_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"strings"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)

Expand Down Expand Up @@ -54,6 +55,26 @@ func dryRunTableDelete(_ context.Context, runtime *common.RuntimeContext) *commo
}

func validateTableCreate(runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("fields"))
if raw == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields is required and cannot be blank").
WithParam("--fields").
WithHint(`Pass the table schema as a JSON array, e.g. --fields '[{"name":"Title","type":"text"}]'. Read lark-base-field-json.md for the field JSON shape.`)
}
items, err := parseJSONArray(newParseCtx(runtime), raw, "fields")
if err != nil {
return err
}
if len(items) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields must define at least one field").
WithParam("--fields").
WithHint("An empty array is not a schema: the table would be created with the platform default schema instead of the one you asked for.")
}
for idx, item := range items {
if _, ok := item.(map[string]interface{}); !ok {
return baseValidationErrorf("--fields item %d must be an object", idx+1)
}
}
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion skills/lark-base/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ metadata:
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
| 查看 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 的列出、详情、创建、重命名和删除 |
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除;`+table-create` 必须传 `--fields` 一次性定义表结构,字段 JSON 读 [lark-base-field-json.md](references/lark-base-field-json.md) |
| 复制 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) |
Expand Down
2 changes: 1 addition & 1 deletion tests/cli_e2e/base/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
| ✓ | base +role-get | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/get as bot | `--base-token`; `--role-id` | |
| ✓ | 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-create | shortcut | base/helpers_test.go::createTableWithRetry | `--base-token`; `--name`; `--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 |
Expand Down
9 changes: 5 additions & 4 deletions tests/cli_e2e/base/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,11 @@ func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string
func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) {
t.Helper()

args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name}
if fieldsJSON != "" {
args = append(args, "--fields", fieldsJSON)
}
// +table-create requires --fields; an empty schema here would fail at the
// CLI surface with a validation error rather than exercising the API.
require.NotEmpty(t, fieldsJSON, "createTableWithRetry requires a field schema")

args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name, "--fields", fieldsJSON}
if viewJSON != "" {
args = append(args, "--view", viewJSON)
}
Expand Down
Loading