From ac06eaa0f4a77a602a9dffe0c324cdb6462b0875 Mon Sep 17 00:00:00 2001 From: liujinkun2025 <77097548+liujinkun2025@users.noreply.github.com> Date: Mon, 25 May 2026 17:28:45 +0800 Subject: [PATCH] fix(wiki): rename +node-get --token to --node-token, keep alias (#1074) Per issue #1049 (third point), wiki +node-get used --token while sibling commands (+node-delete / +node-copy / +move) use --node-token. The inconsistency forced humans and AI agents to remember which adjacent command takes which flag. Make --node-token the canonical flag and keep --token as a hidden, deprecated alias so existing scripts continue to work. pflag's MarkDeprecated prints "Flag --token has been deprecated, use --node-token instead" to stderr on use, guiding callers to migrate. Conflict between the two with different values is rejected upfront. Skills docs (lark-wiki, lark-base) updated to prefer --node-token. Change-Id: I3415a98f079613c0b1a0b989cf54a09cbb8986fb --- shortcuts/wiki/wiki_node_get.go | 56 ++++- shortcuts/wiki/wiki_node_get_test.go | 206 +++++++++++++++++- skills/lark-base/SKILL.md | 6 +- .../references/lark-wiki-node-get.md | 7 +- 4 files changed, 255 insertions(+), 20 deletions(-) diff --git a/shortcuts/wiki/wiki_node_get.go b/shortcuts/wiki/wiki_node_get.go index d89d3b86..b7c2d6b9 100644 --- a/shortcuts/wiki/wiki_node_get.go +++ b/shortcuts/wiki/wiki_node_get.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" ) // wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the @@ -57,14 +58,26 @@ var WikiNodeGet = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ - {Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true}, - {Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum}, + // --node-token is the canonical flag, matching sibling wiki commands + // (+node-delete / +node-copy / +move). --token is the original name + // and is kept as a hidden deprecated alias for backward compatibility; + // MarkDeprecated (registered in PostMount) prints a stderr warning + // when --token is used. + {Name: "node-token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them"}, + {Name: "token", Desc: "DEPRECATED: use --node-token", Hidden: true}, + {Name: "obj-type", Desc: "obj_type when --node-token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum}, {Name: "space-id", Desc: "optional: assert the resolved node lives in this space"}, }, Tips: []string{ - "--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/ or https://feishu.cn/docx/.", + "--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/ or https://feishu.cn/docx/.", "For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.", "Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.", + "--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.", + }, + PostMount: func(cmd *cobra.Command) { + // cobra's MarkDeprecated prints "Flag --token has been deprecated, use --node-token instead" + // to stderr on use, and hides the flag from --help (matching the Hidden: true marker above). + _ = cmd.Flags().MarkDeprecated("token", "use --node-token instead") }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { _, err := readWikiNodeGetSpec(runtime) @@ -142,20 +155,45 @@ func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} { } func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) { - return parseWikiNodeGetSpec( + rawToken, err := resolveWikiNodeGetRawToken( + runtime.Str("node-token"), runtime.Str("token"), + ) + if err != nil { + return wikiNodeGetSpec{}, err + } + return parseWikiNodeGetSpec( + rawToken, runtime.Str("obj-type"), runtime.Str("space-id"), ) } +// resolveWikiNodeGetRawToken picks between the canonical --node-token and the +// deprecated --token alias. Both empty is fine (parseWikiNodeGetSpec will +// surface the required-flag error). Both set with different values is rejected +// upfront so callers fix the obvious bug rather than silently picking one. +func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) { + canonical := strings.TrimSpace(nodeToken) + legacy := strings.TrimSpace(legacyToken) + switch { + case canonical != "" && legacy != "" && canonical != legacy: + return "", output.ErrValidation( + "--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)") + case canonical != "": + return nodeToken, nil + default: + return legacyToken, nil + } +} + // parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a // URL when needed, picks the obj_type (URL path > explicit flag > none for // node_tokens), and validates the token shape. func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) { tokenInput := strings.TrimSpace(rawToken) if tokenInput == "" { - return wikiNodeGetSpec{}, output.ErrValidation("--token is required") + return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required") } spec := wikiNodeGetSpec{ @@ -166,12 +204,12 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS if strings.Contains(tokenInput, "://") { u, err := url.Parse(tokenInput) if err != nil || u.Path == "" { - return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput) + return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput) } token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path) if !ok { return wikiNodeGetSpec{}, output.ErrValidation( - "unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token", + "unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token", u.Path, ) } @@ -192,7 +230,7 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS } } else if strings.ContainsAny(tokenInput, "/?#") { return wikiNodeGetSpec{}, output.ErrValidation( - "--token must be a raw token or a full URL; partial paths are not accepted: %q", + "--node-token must be a raw token or a full URL; partial paths are not accepted: %q", tokenInput, ) } else { @@ -223,7 +261,7 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS } } - if err := validateOptionalResourceName(spec.Token, "--token"); err != nil { + if err := validateOptionalResourceName(spec.Token, "--node-token"); err != nil { return wikiNodeGetSpec{}, err } if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil { diff --git a/shortcuts/wiki/wiki_node_get_test.go b/shortcuts/wiki/wiki_node_get_test.go index ea1a2fd4..a07ea26e 100644 --- a/shortcuts/wiki/wiki_node_get_test.go +++ b/shortcuts/wiki/wiki_node_get_test.go @@ -4,6 +4,7 @@ package wiki import ( + "bytes" "encoding/json" "net/http" "reflect" @@ -12,6 +13,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" + "github.com/spf13/cobra" ) func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) { @@ -98,7 +100,7 @@ func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) { t.Parallel() _, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "") - if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") { + if err == nil || !strings.Contains(err.Error(), "unsupported --node-token URL path") { t.Fatalf("expected unsupported URL path error, got %v", err) } } @@ -115,11 +117,61 @@ func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) { func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) { t.Parallel() - if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") { + if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--node-token is required") { t.Fatalf("expected required-token error, got %v", err) } } +func TestResolveWikiNodeGetRawTokenPrefersNodeToken(t *testing.T) { + t.Parallel() + + got, err := resolveWikiNodeGetRawToken("wikcnNEW", "") + if err != nil || got != "wikcnNEW" { + t.Fatalf("resolve(node-token only) = (%q, %v), want (wikcnNEW, nil)", got, err) + } +} + +func TestResolveWikiNodeGetRawTokenAcceptsLegacyToken(t *testing.T) { + t.Parallel() + + got, err := resolveWikiNodeGetRawToken("", "wikcnLEGACY") + if err != nil || got != "wikcnLEGACY" { + t.Fatalf("resolve(legacy only) = (%q, %v), want (wikcnLEGACY, nil)", got, err) + } +} + +func TestResolveWikiNodeGetRawTokenAcceptsBothWhenEqual(t *testing.T) { + t.Parallel() + + // Same value on both flags is harmless (e.g. a script doubled the input + // while migrating to --node-token) — prefer the canonical one and don't + // surface a conflict error. + got, err := resolveWikiNodeGetRawToken("wikcnSAME", "wikcnSAME") + if err != nil || got != "wikcnSAME" { + t.Fatalf("resolve(both same) = (%q, %v), want (wikcnSAME, nil)", got, err) + } +} + +func TestResolveWikiNodeGetRawTokenRejectsConflict(t *testing.T) { + t.Parallel() + + _, err := resolveWikiNodeGetRawToken("wikcnNEW", "wikcnOLD") + if err == nil || !strings.Contains(err.Error(), "both set with different values") { + t.Fatalf("expected conflict error, got %v", err) + } +} + +func TestResolveWikiNodeGetRawTokenEmptyDefersToParser(t *testing.T) { + t.Parallel() + + // Both empty is not an error here — the caller (parseWikiNodeGetSpec) is + // where the required-flag check lives and produces the user-facing message. + got, err := resolveWikiNodeGetRawToken("", "") + if err != nil || got != "" { + t.Fatalf("resolve(empty) = (%q, %v), want ('', nil)", got, err) + } +} + func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) { t.Parallel() @@ -204,7 +256,7 @@ func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) { err := mountAndRunWiki(t, WikiNodeGet, []string{ "+node-get", - "--token", "https://feishu.cn/docx/docxXYZ", + "--node-token", "https://feishu.cn/docx/docxXYZ", "--as", "bot", }, factory, stdout) if err != nil { @@ -245,6 +297,150 @@ func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) { } } +func TestWikiNodeGetMountedAcceptsNodeTokenFlag(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_123", + "node_token": "wikcnABC", + "obj_token": "docxXYZ", + "obj_type": "docx", + "node_type": "origin", + "title": "Via Node-Token", + }, + }, + "msg": "success", + }, + } + var capturedQuery string + stub.OnMatch = func(req *http.Request) { + capturedQuery = req.URL.RawQuery + } + reg.Register(stub) + + // Mount inline (rather than using mountAndRunWiki) so we can redirect the + // subcommand's pflag output and assert that no deprecation warning leaks + // when the canonical --node-token is used. The deprecation message comes + // from pflag, not cobra, so SetErr on the cobra root is NOT enough — pflag + // writes to FlagSet.Output(), which we redirect via Flags().SetOutput. + var flagOut bytes.Buffer + parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut) + parent.SetArgs([]string{ + "+node-get", + "--node-token", "https://feishu.cn/docx/docxXYZ", + "--as", "bot", + }) + stdout.Reset() + if err := parent.Execute(); err != nil { + t.Fatalf("parent.Execute() error = %v", err) + } + + if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") { + t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery) + } + + data := decodeWikiEnvelope(t, stdout) + if data["title"] != "Via Node-Token" { + t.Fatalf("title = %#v, want Via Node-Token", data["title"]) + } + if got := flagOut.String(); strings.Contains(got, "deprecated") { + t.Fatalf("pflag output unexpectedly contains deprecation warning when using --node-token: %q", got) + } +} + +// mountWikiNodeGetWithFlagOut mounts +node-get on a fresh parent and redirects +// the subcommand's pflag output to w so tests can capture cobra/pflag-level +// deprecation messages (which bypass the runtime IO stderr exposed by +// TestFactory). +func mountWikiNodeGetWithFlagOut(t *testing.T, factory *cmdutil.Factory, w *bytes.Buffer) *cobra.Command { + t.Helper() + parent := &cobra.Command{Use: "wiki"} + WikiNodeGet.Mount(parent, factory) + parent.SilenceErrors = true + parent.SilenceUsage = true + parent.SetErr(w) + for _, child := range parent.Commands() { + if child.Use == WikiNodeGet.Command { + child.Flags().SetOutput(w) + return parent + } + } + t.Fatalf("mountWikiNodeGetWithFlagOut: subcommand %q not registered on parent", WikiNodeGet.Command) + return nil +} + +func TestWikiNodeGetMountedLegacyTokenFlagWarnsButWorks(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_123", + "node_token": "wikcnABC", + "obj_token": "docxXYZ", + "obj_type": "docx", + "node_type": "origin", + "title": "Legacy Token Path", + }, + }, + "msg": "success", + }, + }) + + var flagOut bytes.Buffer + parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut) + parent.SetArgs([]string{ + "+node-get", + "--token", "wikcnABC", + "--as", "bot", + }) + stdout.Reset() + if err := parent.Execute(); err != nil { + t.Fatalf("parent.Execute() error = %v", err) + } + + data := decodeWikiEnvelope(t, stdout) + if data["title"] != "Legacy Token Path" { + t.Fatalf("title = %#v, want Legacy Token Path", data["title"]) + } + // pflag MarkDeprecated prints "Flag --token has been deprecated, use --node-token instead". + got := flagOut.String() + if !strings.Contains(got, "deprecated") || !strings.Contains(got, "--node-token") { + t.Fatalf("pflag output = %q, want a deprecation warning pointing to --node-token", got) + } +} + +func TestWikiNodeGetMountedRejectsConflictingTokenFlags(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + // reg is unused: conflict is caught in Validate before any HTTP call. + factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + + err := mountAndRunWiki(t, WikiNodeGet, []string{ + "+node-get", + "--node-token", "wikcnNEW", + "--token", "wikcnOLD", + "--as", "bot", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "both set with different values") { + t.Fatalf("expected conflict error, got %v", err) + } +} + func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) @@ -272,7 +468,7 @@ func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) { err := mountAndRunWiki(t, WikiNodeGet, []string{ "+node-get", - "--token", "wikcnABC", + "--node-token", "wikcnABC", "--as", "bot", }, factory, stdout) if err != nil { @@ -311,7 +507,7 @@ func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) { err := mountAndRunWiki(t, WikiNodeGet, []string{ "+node-get", - "--token", "wikcnABC", + "--node-token", "wikcnABC", "--space-id", "space_expected", "--as", "bot", }, factory, stdout) diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 87549b19..a451d312 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -40,7 +40,7 @@ metadata: 1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。 2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。 -3. 如果输入是 Wiki 链接或 Wiki token,并且用户想读取/操作其中的 Base,先执行 `lark-cli wiki +node-get --token `;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。 +3. 如果输入是 Wiki 链接或 Wiki token,并且用户想读取/操作其中的 Base,先执行 `lark-cli wiki +node-get --node-token `;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。(旧的 `--token` flag 仍可用,但已 deprecated,会在 stderr 打印迁移提示。) 4. 定位到命令后,先读该命令对应的 reference,再执行命令。 5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。 6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`。 @@ -266,7 +266,7 @@ metadata: Wiki Base fast path: ```bash -BASE_TOKEN="$(lark-cli wiki +node-get --as user --token "" --jq '.data | select(.obj_type == "bitable") | .obj_token')" +BASE_TOKEN="$(lark-cli wiki +node-get --as user --node-token "" --jq '.data | select(.obj_type == "bitable") | .obj_token')" ``` | `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 | @@ -352,7 +352,7 @@ lark-cli auth login --domain base | `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` | | `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) | | `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 | -| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --token ` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` | +| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --node-token ` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` | | `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` | | formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 | | `ignored_fields` / `READONLY` | 只读字段被当成可写字段,常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 | diff --git a/skills/lark-wiki/references/lark-wiki-node-get.md b/skills/lark-wiki/references/lark-wiki-node-get.md index dd8b88b1..3db7c329 100644 --- a/skills/lark-wiki/references/lark-wiki-node-get.md +++ b/skills/lark-wiki/references/lark-wiki-node-get.md @@ -6,7 +6,7 @@ Get a wiki node's details by `node_token`, `obj_token`, or a Lark URL. Use this ```bash lark-cli wiki +node-get \ - --token \ + --node-token \ [--obj-type ] \ [--space-id ] \ [--format json|pretty|table|csv|ndjson] \ @@ -17,8 +17,9 @@ lark-cli wiki +node-get \ | Flag | Type | Required | Default | Description | |------|------|----------|---------|-------------| -| `--token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/` or `https://feishu.cn/docx/`) | -| `--obj-type` | enum | No | — | Needed when `--token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) | +| `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/` or `https://feishu.cn/docx/`). Matches the `--node-token` naming used by sibling `+node-delete` / `+node-copy` / `+move`. | +| `--token` | string | — (deprecated) | — | Deprecated original name; still accepted for backward compatibility but emits a `Flag --token has been deprecated, use --node-token instead` warning on stderr. New scripts should use `--node-token`. | +| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) | | `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space | | `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` | | `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |