diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 234ac84f9b..4f46715657 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -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, diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 51d38f89c6..6cbefb6926 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -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"}`, }, }, { @@ -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 { diff --git a/shortcuts/base/table_create.go b/shortcuts/base/table_create.go index 2175080e14..c67943af69 100644 --- a/shortcuts/base/table_create.go +++ b/shortcuts/base/table_create.go @@ -9,10 +9,17 @@ 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(), @@ -20,7 +27,7 @@ var BaseTableCreate = common.Shortcut{ 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.", diff --git a/shortcuts/base/table_create_test.go b/shortcuts/base/table_create_test.go new file mode 100644 index 0000000000..baf5ec7b25 --- /dev/null +++ b/shortcuts/base/table_create_test.go @@ -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) + } +} diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index 586bc57287..67a815e3ec 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -7,6 +7,7 @@ import ( "context" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -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 } diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index d51bf8cc85..dc0bd18c5b 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -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) | diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index 578dee6012..87c7dd6f66 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -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 | diff --git a/tests/cli_e2e/base/helpers_test.go b/tests/cli_e2e/base/helpers_test.go index ddfca24cb5..4ee83bde1e 100644 --- a/tests/cli_e2e/base/helpers_test.go +++ b/tests/cli_e2e/base/helpers_test.go @@ -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) }