mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
56 Commits
feat/exter
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b449614be9 | ||
|
|
8e2f517827 | ||
|
|
f6353368d7 | ||
|
|
c4270566ba | ||
|
|
75e8063bf2 | ||
|
|
ae8b1a4cf3 | ||
|
|
01ae4cc521 | ||
|
|
99ac5d6906 | ||
|
|
fd97e65b55 | ||
|
|
4e2c6a2096 | ||
|
|
f619e34d38 | ||
|
|
3e1623c631 | ||
|
|
b2551fd70b | ||
|
|
50b1f54bfa | ||
|
|
f8ed143bde | ||
|
|
92c5b0f26d | ||
|
|
47f4fda1d3 | ||
|
|
003d0f42f8 | ||
|
|
7866801115 | ||
|
|
3624499bcb | ||
|
|
13d4350557 | ||
|
|
7946e5c81d | ||
|
|
5cf09ecfda | ||
|
|
41692b7041 | ||
|
|
b79827d60a | ||
|
|
0f35676a28 | ||
|
|
946964e093 | ||
|
|
daeab10755 | ||
|
|
cfe76ad56a | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 | ||
|
|
6402cb6a3a | ||
|
|
8491775659 | ||
|
|
f5e0d14ca9 | ||
|
|
240e523dbf | ||
|
|
9223754af1 | ||
|
|
3788b6f601 | ||
|
|
0e95848dd7 | ||
|
|
e41e36e1d9 | ||
|
|
a5032bbb55 | ||
|
|
d8f6154e2f | ||
|
|
d219d61be0 | ||
|
|
f79908483d | ||
|
|
52b5910fb1 | ||
|
|
0b59556207 | ||
|
|
91743bba99 | ||
|
|
ca7135f582 | ||
|
|
7b58ba1b1d | ||
|
|
765b097d44 | ||
|
|
4a5e2c519a | ||
|
|
67fc870582 | ||
|
|
af8e027269 | ||
|
|
2efadec335 | ||
|
|
5fb70d326a |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.81] - 2026-07-31
|
||||
|
||||
### Features
|
||||
|
||||
- support visible_rule for form questions (#1891)
|
||||
- **contact**: add bot search shortcut (#2083)
|
||||
- add SXSD schema validation to Slides lint (#2103)
|
||||
- **drive**: add comment-operation shortcuts (#1898)
|
||||
- **drive**: extend permission shortcuts for Miaoda (#2070)
|
||||
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
|
||||
- support source file preview artifacts (#2085)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
|
||||
- **base**: resolve Base URL block types accurately (#2099)
|
||||
- **drive**: use title for default download filename (#2089)
|
||||
- drop stale target version from root upgrade prompt (#2100)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: warn against container-default timezone in time conversion (#2104)
|
||||
- **calendar**: confirm scope before editing recurring events (#2119)
|
||||
- **base**: clarify form and file operation routing (#2110)
|
||||
|
||||
### Misc
|
||||
|
||||
- add protected public domain allowlists (#2111)
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
@@ -1722,6 +1751,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
|
||||
@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
// Deliberately no target version here: info.Latest comes from the on-disk
|
||||
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
|
||||
// failed refresh leaves the old value in place), so it can name a version
|
||||
// that is no longer the one npm would install. The version actually
|
||||
// installed is resolved live by the update subcommand, which prints
|
||||
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
|
||||
// that is where the user sees the real target. Keep going through the
|
||||
// update subcommand rather than calling RunNpmInstall directly, otherwise
|
||||
// that line disappears and the user approves a global install without ever
|
||||
// being told what gets installed.
|
||||
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
// The prompt must not name a target version: info.Latest comes from
|
||||
// the on-disk cache and can be stale, while the version actually
|
||||
// installed is resolved live by the update subcommand.
|
||||
if tc.wantPrompt {
|
||||
if strings.Contains(errBuf.String(), tc.latest) {
|
||||
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), build.Version) {
|
||||
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
|
||||
}
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// "drive.files.delete"). The hint is deliberately NOT a pre-built retry
|
||||
// command: argv cannot faithfully reproduce the original invocation (pipeline
|
||||
// producers, stdin bytes, redirections, inline env and the executable's real
|
||||
// path are all gone), POSIX quoting does not survive PowerShell/cmd.exe, and
|
||||
// echoing argv values can copy credentials or free-form payloads (--sql,
|
||||
// --json) into the error envelope and every log that captures it. Per the
|
||||
// lark-shared approval protocol, the caller that obtained the user's consent
|
||||
// appends --yes to its own saved argv array and re-executes.
|
||||
func RequireConfirmation(action string) error {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action)
|
||||
return err.WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
|
||||
}
|
||||
// The hint is the plain add-yes contract and nothing more: no pre-built
|
||||
// retry command may ride behind it (argv cannot faithfully reproduce the
|
||||
// invocation and may carry sensitive payloads — see RequireConfirmation).
|
||||
if cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
t.Errorf("Hint = %q, want exactly 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
// No fix_command field leaks into the envelope: the typed protocol stays
|
||||
// action-only.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,11 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
// All paths go through the caller's fileIO provider and its relative-to-cwd
|
||||
// policy — no absolute-path side door: a trust root defined by the process
|
||||
// environment (TMPDIR) is not a security boundary, and reading outside the
|
||||
// provider would break sidecar/custom-FileIO ownership. Out-of-tree content
|
||||
// reaches flags via stdin ("-").
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
|
||||
@@ -38,6 +38,10 @@ type Stub struct {
|
||||
// matches after the first hit. Each match appends to CapturedBodies.
|
||||
Reusable bool
|
||||
|
||||
// Optional (optional): when true, Verify does not require this stub to be
|
||||
// matched. Useful for negative assertions via OnMatch.
|
||||
Optional bool
|
||||
|
||||
// CapturedHeaders records the request headers of the matched request.
|
||||
// Populated after RoundTrip matches this stub.
|
||||
CapturedHeaders http.Header
|
||||
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
|
||||
if s.matched {
|
||||
continue
|
||||
}
|
||||
if s.Optional {
|
||||
continue
|
||||
}
|
||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||
continue
|
||||
|
||||
@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
|
||||
|
||||
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
|
||||
|
||||
## Public Domain Allowlists
|
||||
|
||||
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
|
||||
|
||||
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
|
||||
|
||||
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
|
||||
|
||||
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
|
||||
|
||||
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
|
||||
|
||||
## Semantic Blocker Policy
|
||||
|
||||
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:
|
||||
|
||||
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# Exact test-only hostnames. Keep sorted.
|
||||
abc.feishu.cn
|
||||
attacker.example.com
|
||||
bytedance.feishu.cn
|
||||
cdn.feishu.cn
|
||||
evil.example.com
|
||||
example.feishu.cn
|
||||
example.larkoffice.com
|
||||
example.larksuite.com
|
||||
feishu.cn
|
||||
feishu.doubao.com
|
||||
gateway.docker.internal
|
||||
host.containers.internal
|
||||
host.docker.internal
|
||||
host.lima.internal
|
||||
lf3-static.bytednsdoc.com
|
||||
meetings.feishu.cn
|
||||
meetings.larksuite.com
|
||||
p3-lark-file.byteimg.com
|
||||
passport.feishu.cn
|
||||
sample.feishu.cn
|
||||
x.feishu.cn
|
||||
xxx.feishu.cn
|
||||
xxx.larksuite.com
|
||||
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
# Exact public hostnames. Keep sorted.
|
||||
accounts.feishu.cn
|
||||
accounts.larksuite.com
|
||||
applink.feishu.cn
|
||||
applink.larksuite.com
|
||||
ark.ap-southeast.bytepluses.com
|
||||
github.com
|
||||
larkoffice.com
|
||||
lf-larkemail.bytetos.com
|
||||
mcp.feishu.cn
|
||||
mcp.larksuite.com
|
||||
open.feishu.cn
|
||||
open.larksuite.com
|
||||
registry.npmjs.org
|
||||
registry.npmmirror.com
|
||||
sf16-sg.tiktokcdn.com
|
||||
www.feishu.cn
|
||||
www.larksuite.com
|
||||
@@ -19,6 +19,9 @@ func SafeOutputPath(path string) (string, error) {
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
|
||||
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
|
||||
// load-bearing invariant. Out-of-tree content reaches flags via stdin ("-").
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
@@ -252,10 +252,11 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
// WHEN: SafeInputPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
// THEN: the strict validator rejects it — uploads / drive sync rely on
|
||||
// relative-only; out-of-tree content reaches flags via stdin ("-")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ lint/
|
||||
├── lintapi/ # shared types every domain returns
|
||||
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
||||
└── errscontract/ # first domain: typed-error contract guards
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── runner.go
|
||||
├── typecheck.go
|
||||
├── violation.go # local type aliases to lintapi
|
||||
@@ -30,16 +30,19 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
└── scan_test.go
|
||||
└── domaincontract/ # resolver ownership + approved public hostname policy
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── unapproved.go # Go AST/type-aware hostname extraction
|
||||
├── policy.go # exact public/fixture allowlist validation
|
||||
├── diff.go # added-line attribution
|
||||
└── *_test.go
|
||||
```
|
||||
|
||||
## Endpoint domain contract (`domaincontract`)
|
||||
|
||||
`domaincontract` is a syntax-level regression guard for the resolver-owned
|
||||
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
|
||||
files it rejects:
|
||||
`domaincontract` contains two complementary Go source guards.
|
||||
|
||||
The resolver-ownership guard rejects:
|
||||
|
||||
- string literals containing a resolver-owned host FQDN
|
||||
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
|
||||
@@ -59,17 +62,54 @@ parse-level guard). The forbidden-host list is bound to the resolver source by
|
||||
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
|
||||
the guard fails the lint module's tests.
|
||||
|
||||
This is not a general outbound-URL or data-flow analyzer. It does not inspect
|
||||
non-Go assets, hosts assembled from string fragments, SDK constructor option
|
||||
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
|
||||
remain the backstop for those cases.
|
||||
The approved-domain guard parses every Git-tracked Go file in full. In CI,
|
||||
unapproved-host findings are limited to values whose expressions intersect an
|
||||
added line; policy validation and unused-entry checks remain repository-wide.
|
||||
It rejects an exact hostname unless it is present in one of:
|
||||
|
||||
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
|
||||
- `internal/qualitygate/config/allowlists/public-domains.txt`, for production
|
||||
and test code; or
|
||||
- `internal/qualitygate/config/allowlists/fixture-domains.txt`, only for
|
||||
`*_test.go`, the repository-root `tests/`, and any `testdata/` (never
|
||||
`skills/`).
|
||||
|
||||
RFC 2606 example/test names are accepted independently of those lists. This
|
||||
includes the reserved `.test`, `.example`, `.invalid`, and `.localhost`
|
||||
namespaces and the exact names `example.com`, `example.net`, and `example.org`;
|
||||
they are safe placeholders rather than supported public endpoints.
|
||||
|
||||
High-confidence evidence is deliberately limited to static string expressions
|
||||
assigned to `host`, `hostname`, or `domain` semantic names (including common
|
||||
case/plural forms and collections), plus static strings whose entire value is
|
||||
an absolute `http`, `https`, `ws`, or `wss` URL. It supports Go literals,
|
||||
escapes, compile-time concatenation, constant references, grouped declarations,
|
||||
multi-value assignments, and multiline expressions. Bare domain-shaped strings
|
||||
without hostname semantics are not blocked.
|
||||
|
||||
Sequence values are scanned individually. For a hostname-semantic map, a key or
|
||||
value is evidence only when it is the sole hostname-shaped side of that entry;
|
||||
ambiguous string-to-string entries are not guessed. Struct fields use Go type
|
||||
information so known non-network `Host` / `Domain` fields do not become hostname
|
||||
evidence merely because an enum or command category contains a dot.
|
||||
|
||||
Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS,
|
||||
or public-suffix exceptions. Entries must be sorted and unique, use ASCII
|
||||
hostnames, and have a current in-scope use. See
|
||||
`internal/qualitygate/config/README.md` for admission and approval rules.
|
||||
|
||||
This is not a general outbound-URL or cross-language data-flow analyzer. It does
|
||||
not inspect non-Go assets or dynamically constructed values.
|
||||
|
||||
To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather
|
||||
than hardcoding the host elsewhere.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# from the repo root (one level above lint/)
|
||||
# PR-scoped scan from the repo root (one level above lint/)
|
||||
go run -C lint . --changed-from <base-revision> ..
|
||||
|
||||
# Full inventory (also reports historical unapproved hostnames)
|
||||
go run -C lint . ..
|
||||
```
|
||||
|
||||
@@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`:
|
||||
|
||||
import "github.com/larksuite/cli/lint/lintapi"
|
||||
|
||||
// ScanRepo walks root and returns every violation produced by this
|
||||
// domain's checks. Domains MUST return []lintapi.Violation so the
|
||||
// top-level dispatcher can aggregate uniformly.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
|
||||
type ScanOptions struct {
|
||||
ChangedFrom string
|
||||
}
|
||||
|
||||
// ScanRepoWithOptions walks root and returns every violation produced
|
||||
// by this domain's checks. Domains MUST return []lintapi.Violation so
|
||||
// the top-level dispatcher can aggregate uniformly.
|
||||
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... }
|
||||
```
|
||||
|
||||
3. Per-rule files are named `rule_<name>.go` with sibling
|
||||
@@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`:
|
||||
|
||||
```go
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepo},
|
||||
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "<domain>", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return <domain>.ScanRepoWithOptions(root, <domain>.ScanOptions{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
171
lint/domaincontract/diff.go
Normal file
171
lint/domaincontract/diff.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type addedLineRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
type changedGoPath struct {
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
var unifiedHunkRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`)
|
||||
|
||||
func changedGoLineRanges(root, from string) (map[string][]addedLineRange, error) {
|
||||
if from == "" {
|
||||
return nil, nil
|
||||
}
|
||||
names, err := gitCommandOutput(
|
||||
root,
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--find-renames",
|
||||
"--diff-filter=ACMR",
|
||||
from+"...HEAD",
|
||||
"--",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list changed Go files: %w", err)
|
||||
}
|
||||
paths, err := parseChangedGoPaths(names)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse changed Go files: %w", err)
|
||||
}
|
||||
|
||||
out := map[string][]addedLineRange{}
|
||||
for _, path := range paths {
|
||||
args := []string{
|
||||
"diff",
|
||||
"--unified=0",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
"--diff-filter=ACMR",
|
||||
from + "...HEAD",
|
||||
"--",
|
||||
}
|
||||
if path.Old != path.New {
|
||||
args = append(args, path.Old)
|
||||
}
|
||||
args = append(args, path.New)
|
||||
patch, err := gitCommandOutput(root, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read diff for %s: %w", path.New, err)
|
||||
}
|
||||
ranges, err := parseAddedLineRanges(patch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse diff for %s: %w", path.New, err)
|
||||
}
|
||||
out[path.New] = ranges
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseChangedGoPaths(raw []byte) ([]changedGoPath, error) {
|
||||
fields := bytes.Split(raw, []byte{0})
|
||||
var out []changedGoPath
|
||||
for i := 0; i < len(fields); {
|
||||
status := string(fields[i])
|
||||
i++
|
||||
if status == "" {
|
||||
break
|
||||
}
|
||||
if i >= len(fields) || len(fields[i]) == 0 {
|
||||
return nil, fmt.Errorf("truncated name-status record")
|
||||
}
|
||||
oldPath := filepath.ToSlash(string(fields[i]))
|
||||
i++
|
||||
newPath := oldPath
|
||||
if status[0] == 'R' || status[0] == 'C' {
|
||||
if i >= len(fields) || len(fields[i]) == 0 {
|
||||
return nil, fmt.Errorf("truncated rename/copy record for %q", oldPath)
|
||||
}
|
||||
newPath = filepath.ToSlash(string(fields[i]))
|
||||
i++
|
||||
if status[0] == 'C' {
|
||||
// A copy introduces every destination line. Diff only the new
|
||||
// path so Git presents it as an added file rather than a
|
||||
// metadata-only copy with no added-line ranges.
|
||||
oldPath = newPath
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(newPath, ".go") {
|
||||
continue
|
||||
}
|
||||
out = append(out, changedGoPath{Old: oldPath, New: newPath})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAddedLineRanges(patch []byte) ([]addedLineRange, error) {
|
||||
var out []addedLineRange
|
||||
for _, raw := range bytes.Split(patch, []byte{'\n'}) {
|
||||
line := string(raw)
|
||||
if !strings.HasPrefix(line, "@@") {
|
||||
continue
|
||||
}
|
||||
match := unifiedHunkRE.FindStringSubmatch(line)
|
||||
if match == nil {
|
||||
return nil, fmt.Errorf("unsupported unified hunk header %q", line)
|
||||
}
|
||||
start, err := strconv.Atoi(match[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse added start line in %q: %w", line, err)
|
||||
}
|
||||
count := 1
|
||||
if match[2] != "" {
|
||||
count, err = strconv.Atoi(match[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse added line count in %q: %w", line, err)
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, addedLineRange{Start: start, End: start + count - 1})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstAddedLineInSpan(ranges []addedLineRange, start, end int) (int, bool) {
|
||||
for _, r := range ranges {
|
||||
if start <= r.End && end >= r.Start {
|
||||
if start > r.Start {
|
||||
return start, true
|
||||
}
|
||||
return r.Start, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func gitCommandOutput(root string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
||||
if stderr != "" {
|
||||
return nil, fmt.Errorf("%w: %s", err, stderr)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
96
lint/domaincontract/diff_test.go
Normal file
96
lint/domaincontract/diff_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseChangedGoPaths(t *testing.T) {
|
||||
raw := []byte("M\x00changed.go\x00R100\x00old.go\x00renamed.go\x00C100\x00source.go\x00copied.go\x00A\x00README.md\x00")
|
||||
got, err := parseChangedGoPaths(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []changedGoPath{
|
||||
{Old: "changed.go", New: "changed.go"},
|
||||
{Old: "old.go", New: "renamed.go"},
|
||||
{Old: "copied.go", New: "copied.go"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangedGoPathsRejectsTruncatedRename(t *testing.T) {
|
||||
if _, err := parseChangedGoPaths([]byte("R100\x00old.go\x00")); err == nil {
|
||||
t.Fatal("expected truncated rename error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddedLineRanges(t *testing.T) {
|
||||
patch := []byte(`diff --git a/x.go b/x.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/x.go
|
||||
+++ b/x.go
|
||||
@@ -2,0 +3,2 @@
|
||||
+first
|
||||
+second
|
||||
@@ -10 +12 @@
|
||||
-old
|
||||
+new
|
||||
@@ -20 +21,0 @@
|
||||
-deleted
|
||||
`)
|
||||
got, err := parseAddedLineRanges(patch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []addedLineRange{{Start: 3, End: 4}, {Start: 12, End: 12}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("ranges = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ranges = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddedLineRangesRejectsUnknownHunk(t *testing.T) {
|
||||
if _, err := parseAddedLineRanges([]byte("@@@ unsupported @@@\n")); err == nil {
|
||||
t.Fatal("expected unsupported hunk error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstAddedLineInSpan(t *testing.T) {
|
||||
ranges := []addedLineRange{{Start: 5, End: 7}, {Start: 10, End: 10}}
|
||||
tests := []struct {
|
||||
start, end int
|
||||
line int
|
||||
ok bool
|
||||
}{
|
||||
{start: 1, end: 4, ok: false},
|
||||
{start: 4, end: 6, line: 5, ok: true},
|
||||
{start: 6, end: 9, line: 6, ok: true},
|
||||
{start: 8, end: 12, line: 10, ok: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
line, ok := firstAddedLineInSpan(ranges, tc.start, tc.end)
|
||||
if line != tc.line || ok != tc.ok {
|
||||
t.Errorf(
|
||||
"firstAddedLineInSpan(%d, %d) = (%d, %v), want (%d, %v)",
|
||||
tc.start,
|
||||
tc.end,
|
||||
line,
|
||||
ok,
|
||||
tc.line,
|
||||
tc.ok,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
126
lint/domaincontract/policy.go
Normal file
126
lint/domaincontract/policy.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
publicDomainsPath = "internal/qualitygate/config/allowlists/public-domains.txt"
|
||||
fixtureDomainsPath = "internal/qualitygate/config/allowlists/fixture-domains.txt"
|
||||
)
|
||||
|
||||
type domainPolicyEntry struct {
|
||||
Host string
|
||||
File string
|
||||
Line int
|
||||
}
|
||||
|
||||
type domainPolicy struct {
|
||||
Public map[string]domainPolicyEntry
|
||||
Fixtures map[string]domainPolicyEntry
|
||||
}
|
||||
|
||||
// isReservedExampleHostname recognizes only names reserved by RFC 2606 for
|
||||
// examples, testing, invalid-name examples, and localhost use. These names are
|
||||
// safe source placeholders and are policy exceptions, not supported public
|
||||
// endpoints.
|
||||
func isReservedExampleHostname(host string) bool {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
switch host {
|
||||
case "example.com", "example.net", "example.org":
|
||||
return true
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
switch labels[len(labels)-1] {
|
||||
case "test", "example", "invalid", "localhost":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func loadDomainPolicy(root string) (domainPolicy, error) {
|
||||
public, err := loadDomainList(root, publicDomainsPath)
|
||||
if err != nil {
|
||||
return domainPolicy{}, err
|
||||
}
|
||||
fixtures, err := loadDomainList(root, fixtureDomainsPath)
|
||||
if err != nil {
|
||||
return domainPolicy{}, err
|
||||
}
|
||||
for host, entry := range fixtures {
|
||||
if publicEntry, ok := public[host]; ok {
|
||||
return domainPolicy{}, fmt.Errorf(
|
||||
"%s:%d: hostname %q is already listed at %s:%d",
|
||||
entry.File, entry.Line, host, publicEntry.File, publicEntry.Line,
|
||||
)
|
||||
}
|
||||
}
|
||||
return domainPolicy{Public: public, Fixtures: fixtures}, nil
|
||||
}
|
||||
|
||||
func loadDomainList(root, rel string) (map[string]domainPolicyEntry, error) {
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open domain allowlist %s: %w", rel, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
entries := map[string]domainPolicyEntry{}
|
||||
var previous string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for line := 1; scanner.Scan(); line++ {
|
||||
host := strings.TrimSpace(scanner.Text())
|
||||
if host == "" || strings.HasPrefix(host, "#") {
|
||||
continue
|
||||
}
|
||||
if host != strings.ToLower(host) {
|
||||
return nil, fmt.Errorf("%s:%d: hostname must be lowercase: %q", rel, line, host)
|
||||
}
|
||||
if err := validatePolicyHostname(host); err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", rel, line, err)
|
||||
}
|
||||
if previous != "" && host <= previous {
|
||||
return nil, fmt.Errorf("%s:%d: hostnames must be unique and sorted: %q", rel, line, host)
|
||||
}
|
||||
entries[host] = domainPolicyEntry{Host: host, File: rel, Line: line}
|
||||
previous = host
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read domain allowlist %s: %w", rel, err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("%s: domain list must not be empty", rel)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func validatePolicyHostname(host string) error {
|
||||
if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") {
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
for _, r := range label {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
}
|
||||
if !strings.ContainsAny(labels[len(labels)-1], "abcdefghijklmnopqrstuvwxyz") {
|
||||
return fmt.Errorf("invalid exact hostname %q", host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
120
lint/domaincontract/policy_test.go
Normal file
120
lint/domaincontract/policy_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDomainPolicy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, publicDomainsPath, "# public\napi.example.com\nwww.example.com\n")
|
||||
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
|
||||
|
||||
policy, err := loadDomainPolicy(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(policy.Public) != 2 || len(policy.Fixtures) != 1 {
|
||||
t.Fatalf("unexpected policy sizes: public=%d fixtures=%d", len(policy.Public), len(policy.Fixtures))
|
||||
}
|
||||
if policy.Public["api.example.com"].Line != 2 {
|
||||
t.Fatalf("api.example.com line = %d, want 2", policy.Public["api.example.com"].Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDomainPolicyRejectsInvalidLists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
public string
|
||||
fixtures string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "uppercase",
|
||||
public: "API.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "must be lowercase",
|
||||
},
|
||||
{
|
||||
name: "unsorted",
|
||||
public: "www.example.com\napi.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "unique and sorted",
|
||||
},
|
||||
{
|
||||
name: "duplicate",
|
||||
public: "api.example.com\napi.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "unique and sorted",
|
||||
},
|
||||
{
|
||||
name: "wildcard",
|
||||
public: "*.example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "scheme",
|
||||
public: "https://example.com\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
public: "api.example.com/v1\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "port",
|
||||
public: "api.example.com:443\n",
|
||||
fixtures: "fixture.example.com\n",
|
||||
want: "invalid exact hostname",
|
||||
},
|
||||
{
|
||||
name: "cross-list duplicate",
|
||||
public: "api.example.com\n",
|
||||
fixtures: "api.example.com\n",
|
||||
want: "already listed",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, publicDomainsPath, tc.public)
|
||||
writeFile(t, root, fixtureDomainsPath, tc.fixtures)
|
||||
_, err := loadDomainPolicy(root)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("loadDomainPolicy() error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedExampleHostname(t *testing.T) {
|
||||
for _, host := range []string{
|
||||
"example.com",
|
||||
"example.net",
|
||||
"example.org",
|
||||
"example.test",
|
||||
"docs.example",
|
||||
"missing.invalid",
|
||||
"service.localhost",
|
||||
} {
|
||||
if !isReservedExampleHostname(host) {
|
||||
t.Errorf("%q should be a reserved example hostname", host)
|
||||
}
|
||||
}
|
||||
for _, host := range []string{
|
||||
"attacker.example.com",
|
||||
"example.dev",
|
||||
"private.corp.internal",
|
||||
} {
|
||||
if isReservedExampleHostname(host) {
|
||||
t.Errorf("%q must still require policy approval", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package domaincontract guards the Go CLI against direct reuse of the current
|
||||
// resolver-owned host FQDNs outside core.ResolveEndpoints.
|
||||
// Package domaincontract guards resolver ownership and rejects newly introduced
|
||||
// static Go hostnames that are not covered by the repository domain policy.
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -75,10 +76,40 @@ func skipDir(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanRepo walks production .go files under root and flags string literals
|
||||
// containing a forbidden resolver host outside the allowlist. Comments and
|
||||
// _test.go files are not scanned.
|
||||
// ScanRepo runs the resolver-owned endpoint guard and a full repository domain
|
||||
// inventory. CI should use ScanRepoWithOptions with a changed-from revision so
|
||||
// historical unapproved domains are not attributed to an unrelated change.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) {
|
||||
return ScanRepoWithOptions(root, ScanOptions{})
|
||||
}
|
||||
|
||||
type ScanOptions struct {
|
||||
ChangedFrom string
|
||||
}
|
||||
|
||||
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) {
|
||||
out, err := scanHardcodedEndpoints(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainViolations, err := scanUnapprovedDomains(root, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, domainViolations...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanHardcodedEndpoints(root string) ([]lintapi.Violation, error) {
|
||||
var out []lintapi.Violation
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
|
||||
911
lint/domaincontract/unapproved.go
Normal file
911
lint/domaincontract/unapproved.go
Normal file
@@ -0,0 +1,911 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/constant"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
const (
|
||||
unapprovedDomainRule = "unapproved-domain"
|
||||
unusedDomainRule = "domain-allowlist-unused"
|
||||
incompleteDomainRule = "domain-scan-incomplete"
|
||||
)
|
||||
|
||||
type typedGoFile struct {
|
||||
File *ast.File
|
||||
Fset *token.FileSet
|
||||
Info *types.Info
|
||||
}
|
||||
|
||||
type domainEvidence struct {
|
||||
Host string
|
||||
Kind string
|
||||
Expr ast.Expr
|
||||
}
|
||||
|
||||
type evidenceKey struct {
|
||||
Host string
|
||||
Start, End token.Pos
|
||||
}
|
||||
|
||||
type fileDomainScan struct {
|
||||
File *ast.File
|
||||
Fset *token.FileSet
|
||||
Info *types.Info
|
||||
Evidence []domainEvidence
|
||||
TypeInfoRequired []ast.Expr
|
||||
seen map[evidenceKey]bool
|
||||
parents map[ast.Node]ast.Node
|
||||
}
|
||||
|
||||
type collectionCompositeKind uint8
|
||||
|
||||
const (
|
||||
notCollectionComposite collectionCompositeKind = iota
|
||||
sequenceComposite
|
||||
mapComposite
|
||||
)
|
||||
|
||||
type hostnameFieldID struct {
|
||||
Type string
|
||||
Field string
|
||||
}
|
||||
|
||||
var nonNetworkHostnameFields = map[hostnameFieldID]bool{
|
||||
{Type: "github.com/larksuite/cli/events/im.CardActionTriggerOutput", Field: "Host"}: true,
|
||||
{Type: "github.com/larksuite/cli/internal/cmdmeta.Meta", Field: "Domain"}: true,
|
||||
}
|
||||
|
||||
func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve repository root: %w", err)
|
||||
}
|
||||
publicPath := filepath.Join(root, filepath.FromSlash(publicDomainsPath))
|
||||
if _, err := os.Stat(publicPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if _, goModErr := os.Stat(filepath.Join(root, "go.mod")); os.IsNotExist(goModErr) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("domain policy unavailable: %w", err)
|
||||
}
|
||||
policy, err := loadDomainPolicy(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
added, err := changedGoLineRanges(root, opts.ChangedFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed, typeLoadErr := loadTypedGoFiles(root)
|
||||
goFiles, err := trackedGoFiles(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
observedPublic := map[string]bool{}
|
||||
observedFixtures := map[string]bool{}
|
||||
inventoryComplete := typeLoadErr == nil
|
||||
var out []lintapi.Violation
|
||||
parseFailureReported := false
|
||||
typeInfoGapReported := false
|
||||
for _, rel := range goFiles {
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
parsedFset := token.NewFileSet()
|
||||
parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0)
|
||||
if parseErr != nil {
|
||||
inventoryComplete = false
|
||||
if opts.ChangedFrom == "" {
|
||||
out = append(out, incompleteDomainViolation(rel, parseErr))
|
||||
parseFailureReported = true
|
||||
} else if _, changed := added[rel]; changed {
|
||||
out = append(out, incompleteDomainViolation(rel, parseErr))
|
||||
parseFailureReported = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
tf, ok := typed[filepath.Clean(path)]
|
||||
if !ok {
|
||||
tf = typedGoFile{File: parsedFile, Fset: parsedFset}
|
||||
}
|
||||
|
||||
scan := newFileDomainScan(tf)
|
||||
scan.collectSemanticEvidence()
|
||||
scan.collectAbsoluteURLEvidence()
|
||||
if len(scan.TypeInfoRequired) > 0 {
|
||||
// Inventory completeness is a property of the whole HEAD. Whether
|
||||
// this PR owns an incomplete-scan diagnostic is decided separately
|
||||
// by the added-line intersection below.
|
||||
inventoryComplete = false
|
||||
}
|
||||
for _, expr := range scan.TypeInfoRequired {
|
||||
start := tf.Fset.Position(expr.Pos()).Line
|
||||
end := tf.Fset.Position(expr.End()).Line
|
||||
line := start
|
||||
if opts.ChangedFrom != "" {
|
||||
var intersects bool
|
||||
line, intersects = firstAddedLineInSpan(added[rel], start, end)
|
||||
if !intersects {
|
||||
continue
|
||||
}
|
||||
}
|
||||
typeInfoGapReported = true
|
||||
out = append(out, incompleteDomainViolationAt(
|
||||
rel,
|
||||
line,
|
||||
fmt.Errorf("Go type information unavailable for hostname-oriented field evidence"),
|
||||
))
|
||||
break
|
||||
}
|
||||
fixture := isDomainFixturePath(rel)
|
||||
// The detector's own policy literals and contract corpus may be
|
||||
// scanned, but they cannot justify keeping an allowlist entry.
|
||||
policyOwner := strings.HasPrefix(rel, "lint/domaincontract/")
|
||||
for _, evidence := range scan.Evidence {
|
||||
if isReservedExampleHostname(evidence.Host) {
|
||||
continue
|
||||
}
|
||||
if _, ok := policy.Public[evidence.Host]; ok {
|
||||
if !fixture && !policyOwner {
|
||||
observedPublic[evidence.Host] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := policy.Fixtures[evidence.Host]; ok && fixture {
|
||||
if !policyOwner {
|
||||
observedFixtures[evidence.Host] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
start := tf.Fset.Position(evidence.Expr.Pos()).Line
|
||||
end := tf.Fset.Position(evidence.Expr.End()).Line
|
||||
line := start
|
||||
if opts.ChangedFrom != "" {
|
||||
var intersects bool
|
||||
line, intersects = firstAddedLineInSpan(added[rel], start, end)
|
||||
if !intersects {
|
||||
continue
|
||||
}
|
||||
}
|
||||
suggestion := "remove the hostname or replace it with an approved public endpoint; " +
|
||||
"public allowlist additions require evidence and CODEOWNER approval"
|
||||
if _, fixtureOnly := policy.Fixtures[evidence.Host]; fixtureOnly && !fixture {
|
||||
suggestion = "remove the fixture-only hostname or move this use into an approved fixture scope; " +
|
||||
"fixture entries are not approved for production Go code or skills"
|
||||
}
|
||||
out = append(out, lintapi.Violation{
|
||||
Rule: unapprovedDomainRule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: rel,
|
||||
Line: line,
|
||||
Message: fmt.Sprintf(
|
||||
"unapproved hostname %q found in %s",
|
||||
evidence.Host,
|
||||
evidence.Kind,
|
||||
),
|
||||
Suggestion: suggestion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A syntax error is also surfaced by go/packages. Prefer the file-specific
|
||||
// parse diagnostic when one was already reported; otherwise make a
|
||||
// repository-wide type-loading failure explicit instead of silently
|
||||
// continuing without the type information required by field evidence.
|
||||
if typeLoadErr != nil && !parseFailureReported && !typeInfoGapReported {
|
||||
out = append(out, incompleteDomainViolation("go.mod", typeLoadErr))
|
||||
}
|
||||
|
||||
if inventoryComplete {
|
||||
for host, entry := range policy.Public {
|
||||
if !observedPublic[host] {
|
||||
out = append(out, unusedDomainViolation(entry))
|
||||
}
|
||||
}
|
||||
for host, entry := range policy.Fixtures {
|
||||
if !observedFixtures[host] {
|
||||
out = append(out, unusedDomainViolation(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func trackedGoFiles(root string) ([]string, error) {
|
||||
out, err := gitCommandOutput(root, "ls-files", "-z", "--", "*.go")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tracked Go files: %w", err)
|
||||
}
|
||||
var files []string
|
||||
for _, raw := range strings.Split(string(out), "\x00") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
rel := filepath.ToSlash(raw)
|
||||
if strings.HasPrefix(rel, "vendor/") || strings.HasPrefix(rel, "node_modules/") {
|
||||
continue
|
||||
}
|
||||
files = append(files, rel)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func loadTypedGoFiles(root string) (map[string]typedGoFile, error) {
|
||||
moduleDirs, err := trackedGoModuleDirs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]typedGoFile{}
|
||||
var firstLoadErr error
|
||||
var loadErrCount int
|
||||
for _, moduleDir := range moduleDirs {
|
||||
moduleRoot := root
|
||||
if moduleDir != "." {
|
||||
moduleRoot = filepath.Join(root, filepath.FromSlash(moduleDir))
|
||||
}
|
||||
files, err := loadTypedGoModule(moduleRoot)
|
||||
for path, file := range files {
|
||||
out[path] = file
|
||||
}
|
||||
if err != nil {
|
||||
loadErrCount++
|
||||
if firstLoadErr == nil {
|
||||
firstLoadErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
if loadErrCount == 1 {
|
||||
return out, firstLoadErr
|
||||
}
|
||||
if loadErrCount > 1 {
|
||||
return out, fmt.Errorf("%w (and %d more module errors)", firstLoadErr, loadErrCount-1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func trackedGoModuleDirs(root string) ([]string, error) {
|
||||
raw, err := gitCommandOutput(root, "ls-files", "-z")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tracked Go modules: %w", err)
|
||||
}
|
||||
var dirs []string
|
||||
for _, path := range strings.Split(string(raw), "\x00") {
|
||||
path = filepath.ToSlash(path)
|
||||
if path != "go.mod" && !strings.HasSuffix(path, "/go.mod") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.ToSlash(filepath.Dir(path))
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
func loadTypedGoModule(moduleRoot string) (map[string]typedGoFile, error) {
|
||||
fset := token.NewFileSet()
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName |
|
||||
packages.NeedFiles |
|
||||
packages.NeedCompiledGoFiles |
|
||||
packages.NeedImports |
|
||||
packages.NeedDeps |
|
||||
packages.NeedTypes |
|
||||
packages.NeedSyntax |
|
||||
packages.NeedTypesInfo,
|
||||
Dir: moduleRoot,
|
||||
Fset: fset,
|
||||
Tests: true,
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Go type information: %w", err)
|
||||
}
|
||||
out := map[string]typedGoFile{}
|
||||
var firstPackageErr string
|
||||
var packageErrCount int
|
||||
packages.Visit(pkgs, nil, func(pkg *packages.Package) {
|
||||
if pkg == nil {
|
||||
return
|
||||
}
|
||||
for _, pkgErr := range pkg.Errors {
|
||||
packageErrCount++
|
||||
if firstPackageErr == "" {
|
||||
firstPackageErr = pkgErr.Error()
|
||||
}
|
||||
}
|
||||
if pkg.TypesInfo == nil || pkg.Fset == nil {
|
||||
return
|
||||
}
|
||||
for i, file := range pkg.Syntax {
|
||||
if i >= len(pkg.CompiledGoFiles) {
|
||||
break
|
||||
}
|
||||
path := filepath.Clean(pkg.CompiledGoFiles[i])
|
||||
if _, exists := out[path]; exists {
|
||||
continue
|
||||
}
|
||||
out[path] = typedGoFile{File: file, Fset: pkg.Fset, Info: pkg.TypesInfo}
|
||||
}
|
||||
})
|
||||
if packageErrCount == 1 {
|
||||
return out, fmt.Errorf("load Go type information: %s", firstPackageErr)
|
||||
}
|
||||
if packageErrCount > 1 {
|
||||
return out, fmt.Errorf(
|
||||
"load Go type information: %s (and %d more package errors)",
|
||||
firstPackageErr,
|
||||
packageErrCount-1,
|
||||
)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newFileDomainScan(file typedGoFile) *fileDomainScan {
|
||||
return &fileDomainScan{
|
||||
File: file.File,
|
||||
Fset: file.Fset,
|
||||
Info: file.Info,
|
||||
seen: map[evidenceKey]bool{},
|
||||
parents: astParentMap(file.File),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) collectSemanticEvidence() {
|
||||
ast.Inspect(s.File, func(node ast.Node) bool {
|
||||
switch n := node.(type) {
|
||||
case *ast.AssignStmt:
|
||||
if len(n.Lhs) != len(n.Rhs) {
|
||||
return true
|
||||
}
|
||||
for i, lhs := range n.Lhs {
|
||||
if s.Info == nil &&
|
||||
potentialHostnameSelectorTarget(lhs) &&
|
||||
s.hasStaticBareHostnameValue(n.Rhs[i]) {
|
||||
s.requireTypeInfo(n.Rhs[i])
|
||||
}
|
||||
if index, ok := stripParens(lhs).(*ast.IndexExpr); ok {
|
||||
switch {
|
||||
case s.isHostnameTarget(index.X):
|
||||
s.addMapPair(index.Index, n.Rhs[i])
|
||||
case s.isHostnameMapKey(index.Index):
|
||||
s.addHostValue(n.Rhs[i], "host assignment")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.isHostnameTarget(lhs) {
|
||||
s.addHostValue(n.Rhs[i], "host assignment")
|
||||
}
|
||||
}
|
||||
case *ast.ValueSpec:
|
||||
if len(n.Names) != len(n.Values) {
|
||||
return true
|
||||
}
|
||||
for i, name := range n.Names {
|
||||
if isHostnameSemanticName(name.Name) {
|
||||
s.addHostValue(n.Values[i], "host assignment")
|
||||
}
|
||||
}
|
||||
case *ast.KeyValueExpr:
|
||||
if s.Info == nil && s.keyValueNeedsTypeInfo(n) {
|
||||
s.requireTypeInfo(n.Value)
|
||||
}
|
||||
if s.isHostnameKeyValue(n) {
|
||||
s.addHostValue(n.Value, "host assignment")
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) requireTypeInfo(expr ast.Expr) {
|
||||
for _, existing := range s.TypeInfoRequired {
|
||||
if existing.Pos() == expr.Pos() && existing.End() == expr.End() {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.TypeInfoRequired = append(s.TypeInfoRequired, expr)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) hasStaticBareHostnameValue(expr ast.Expr) bool {
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
host, ok := semanticHostname(value)
|
||||
return ok && !isReservedExampleHostname(host)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) keyValueNeedsTypeInfo(pair *ast.KeyValueExpr) bool {
|
||||
composite, ok := s.parents[pair].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, explicitMap := composite.Type.(*ast.MapType); explicitMap {
|
||||
return false
|
||||
}
|
||||
key, ok := pair.Key.(*ast.Ident)
|
||||
return ok && isHostnameSemanticName(key.Name) && s.hasStaticBareHostnameValue(pair.Value)
|
||||
}
|
||||
|
||||
func potentialHostnameSelectorTarget(expr ast.Expr) bool {
|
||||
switch n := stripParens(expr).(type) {
|
||||
case *ast.SelectorExpr:
|
||||
return isHostnameSemanticName(n.Sel.Name)
|
||||
case *ast.StarExpr:
|
||||
return potentialHostnameSelectorTarget(n.X)
|
||||
case *ast.IndexExpr:
|
||||
return potentialHostnameSelectorTarget(n.X)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) collectAbsoluteURLEvidence() {
|
||||
ast.Inspect(s.File, func(node ast.Node) bool {
|
||||
expr, ok := node.(ast.Expr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if ident, ok := expr.(*ast.Ident); ok && s.Info != nil && s.Info.Defs[ident] != nil {
|
||||
// A declaration name may carry the constant value in types.Info,
|
||||
// but it is not a second source expression.
|
||||
return true
|
||||
}
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if s.hasStaticStringContainer(expr) {
|
||||
return true
|
||||
}
|
||||
host, ok := absoluteURLHostname(value)
|
||||
if ok {
|
||||
s.addEvidence(host, "absolute URL", expr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) hasStaticStringContainer(expr ast.Expr) bool {
|
||||
parent, ok := s.parents[expr].(ast.Expr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch parent.(type) {
|
||||
case *ast.BinaryExpr, *ast.ParenExpr:
|
||||
_, ok := staticStringValue(parent, s.Info, nil)
|
||||
return ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) addHostValue(expr ast.Expr, kind string) {
|
||||
expr = stripParens(expr)
|
||||
if composite, ok := expr.(*ast.CompositeLit); ok {
|
||||
switch s.collectionCompositeKind(composite) {
|
||||
case sequenceComposite:
|
||||
for _, element := range composite.Elts {
|
||||
if valueExpr, ok := element.(ast.Expr); ok {
|
||||
s.addHostValue(valueExpr, "host collection")
|
||||
}
|
||||
}
|
||||
case mapComposite:
|
||||
for _, element := range composite.Elts {
|
||||
pair, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyExpr, ok := pair.Key.(ast.Expr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s.addMapPair(keyExpr, pair.Value)
|
||||
}
|
||||
default:
|
||||
if s.Info == nil {
|
||||
s.requireTypeInfoForUnclassifiedCollection(composite)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if evidence, ok := s.hostnameEvidence(expr, kind); ok {
|
||||
s.addEvidence(evidence.Host, evidence.Kind, evidence.Expr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) requireTypeInfoForUnclassifiedCollection(composite *ast.CompositeLit) {
|
||||
for _, element := range composite.Elts {
|
||||
if pair, ok := element.(*ast.KeyValueExpr); ok {
|
||||
keyExpr, ok := pair.Key.(ast.Expr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyIsHost := s.hasStaticBareHostnameValue(keyExpr)
|
||||
valueIsHost := s.hasStaticBareHostnameValue(pair.Value)
|
||||
if keyIsHost == valueIsHost {
|
||||
continue
|
||||
}
|
||||
if keyIsHost {
|
||||
s.requireTypeInfo(keyExpr)
|
||||
} else {
|
||||
s.requireTypeInfo(pair.Value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
valueExpr, ok := element.(ast.Expr)
|
||||
if ok && s.hasStaticBareHostnameValue(valueExpr) {
|
||||
s.requireTypeInfo(valueExpr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addMapPair reports a map side only when it is the sole hostname-shaped
|
||||
// static value. A semantic map name does not establish whether a string map
|
||||
// is hostname->metadata or alias->hostname, so reporting both sides would turn
|
||||
// filenames such as client.pem into blocking hostname evidence.
|
||||
func (s *fileDomainScan) addMapPair(key, value ast.Expr) {
|
||||
keyEvidence, keyOK := s.hostnameEvidence(key, "host collection")
|
||||
valueEvidence, valueOK := s.hostnameEvidence(value, "host collection")
|
||||
if keyOK == valueOK {
|
||||
return
|
||||
}
|
||||
if keyOK {
|
||||
s.addEvidence(keyEvidence.Host, keyEvidence.Kind, keyEvidence.Expr)
|
||||
return
|
||||
}
|
||||
s.addEvidence(valueEvidence.Host, valueEvidence.Kind, valueEvidence.Expr)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) hostnameEvidence(expr ast.Expr, kind string) (domainEvidence, bool) {
|
||||
expr = stripParens(expr)
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
if !ok {
|
||||
return domainEvidence{}, false
|
||||
}
|
||||
if host, ok := absoluteURLHostname(value); ok {
|
||||
return domainEvidence{Host: host, Kind: "absolute URL", Expr: expr}, true
|
||||
}
|
||||
if host, ok := semanticHostname(value); ok {
|
||||
return domainEvidence{Host: host, Kind: kind, Expr: expr}, true
|
||||
}
|
||||
return domainEvidence{}, false
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) collectionCompositeKind(expr *ast.CompositeLit) collectionCompositeKind {
|
||||
if s.Info != nil {
|
||||
if tv, ok := s.Info.Types[expr]; ok && tv.Type != nil {
|
||||
switch tv.Type.Underlying().(type) {
|
||||
case *types.Array, *types.Slice:
|
||||
return sequenceComposite
|
||||
case *types.Map:
|
||||
return mapComposite
|
||||
}
|
||||
}
|
||||
}
|
||||
switch expr.Type.(type) {
|
||||
case *ast.ArrayType:
|
||||
return sequenceComposite
|
||||
case *ast.MapType:
|
||||
return mapComposite
|
||||
default:
|
||||
return notCollectionComposite
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) addEvidence(host, kind string, expr ast.Expr) {
|
||||
key := evidenceKey{Host: host, Start: expr.Pos(), End: expr.End()}
|
||||
if s.seen[key] {
|
||||
return
|
||||
}
|
||||
s.seen[key] = true
|
||||
s.Evidence = append(s.Evidence, domainEvidence{Host: host, Kind: kind, Expr: expr})
|
||||
}
|
||||
|
||||
func staticStringValue(expr ast.Expr, info *types.Info, seen map[*ast.Object]bool) (string, bool) {
|
||||
if info != nil {
|
||||
if tv, ok := info.Types[expr]; ok && tv.Value != nil && tv.Value.Kind() == constant.String {
|
||||
return constant.StringVal(tv.Value), true
|
||||
}
|
||||
}
|
||||
switch n := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if n.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(n.Value)
|
||||
return value, err == nil
|
||||
case *ast.ParenExpr:
|
||||
return staticStringValue(n.X, info, seen)
|
||||
case *ast.BinaryExpr:
|
||||
if n.Op != token.ADD {
|
||||
return "", false
|
||||
}
|
||||
left, ok := staticStringValue(n.X, info, seen)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
right, ok := staticStringValue(n.Y, info, seen)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return left + right, true
|
||||
case *ast.Ident:
|
||||
if info != nil {
|
||||
if obj := info.ObjectOf(n); obj != nil {
|
||||
if c, ok := obj.(*types.Const); ok {
|
||||
if c.Val().Kind() == constant.String {
|
||||
return constant.StringVal(c.Val()), true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Obj == nil || n.Obj.Kind != ast.Con {
|
||||
return "", false
|
||||
}
|
||||
if seen == nil {
|
||||
seen = map[*ast.Object]bool{}
|
||||
}
|
||||
if seen[n.Obj] {
|
||||
return "", false
|
||||
}
|
||||
seen[n.Obj] = true
|
||||
defer delete(seen, n.Obj)
|
||||
spec, ok := n.Obj.Decl.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
for i, name := range spec.Names {
|
||||
if name.Name == n.Name && i < len(spec.Values) {
|
||||
return staticStringValue(spec.Values[i], info, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func absoluteURLHostname(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http", "https", "ws", "wss":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
return normalizeCandidateHostname(parsed.Hostname())
|
||||
}
|
||||
|
||||
func semanticHostname(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.ContainsAny(value, `/\?#@`) || strings.ContainsAny(value, " \t\r\n") {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse("//" + value)
|
||||
if err != nil || parsed.Host == "" || parsed.Path != "" {
|
||||
return "", false
|
||||
}
|
||||
return normalizeCandidateHostname(parsed.Hostname())
|
||||
}
|
||||
|
||||
func normalizeCandidateHostname(host string) (string, bool) {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
if host == "" || !strings.Contains(host, ".") || net.ParseIP(host) != nil {
|
||||
return "", false
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return "", false
|
||||
}
|
||||
for _, r := range label {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||
continue
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return host, true
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameTarget(expr ast.Expr) bool {
|
||||
switch n := stripParens(expr).(type) {
|
||||
case *ast.Ident:
|
||||
return isHostnameSemanticName(n.Name)
|
||||
case *ast.SelectorExpr:
|
||||
return s.isHostnameSelector(n)
|
||||
case *ast.StarExpr:
|
||||
return s.isHostnameTarget(n.X)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameKeyValue(pair *ast.KeyValueExpr) bool {
|
||||
composite, ok := s.parents[pair].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch s.collectionCompositeKind(composite) {
|
||||
case mapComposite:
|
||||
key, ok := pair.Key.(ast.Expr)
|
||||
return ok && s.isHostnameMapKey(key)
|
||||
case notCollectionComposite:
|
||||
ident, ok := pair.Key.(*ast.Ident)
|
||||
return ok && s.isHostnameStructField(composite, ident.Name)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameMapKey(expr ast.Expr) bool {
|
||||
value, ok := staticStringValue(expr, s.Info, nil)
|
||||
return ok && isHostnameSemanticName(value)
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameSelector(selector *ast.SelectorExpr) bool {
|
||||
if s.Info == nil || !isHostnameSemanticName(selector.Sel.Name) {
|
||||
return false
|
||||
}
|
||||
selection := s.Info.Selections[selector]
|
||||
if selection == nil || selection.Kind() != types.FieldVal {
|
||||
return false
|
||||
}
|
||||
return !nonNetworkHostnameFields[hostnameFieldID{
|
||||
Type: namedTypeID(selection.Recv()),
|
||||
Field: selector.Sel.Name,
|
||||
}]
|
||||
}
|
||||
|
||||
func (s *fileDomainScan) isHostnameStructField(composite *ast.CompositeLit, field string) bool {
|
||||
if s.Info == nil || !isHostnameSemanticName(field) {
|
||||
return false
|
||||
}
|
||||
typeID := namedTypeID(s.Info.TypeOf(composite))
|
||||
if typeID == "" {
|
||||
return false
|
||||
}
|
||||
return !nonNetworkHostnameFields[hostnameFieldID{Type: typeID, Field: field}]
|
||||
}
|
||||
|
||||
func namedTypeID(typ types.Type) string {
|
||||
for {
|
||||
switch t := typ.(type) {
|
||||
case *types.Pointer:
|
||||
typ = t.Elem()
|
||||
case *types.Named:
|
||||
obj := t.Obj()
|
||||
if obj == nil || obj.Pkg() == nil {
|
||||
return ""
|
||||
}
|
||||
return obj.Pkg().Path() + "." + obj.Name()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isHostnameSemanticName(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
switch lower {
|
||||
case "host", "hosts", "hostname", "hostnames", "domain", "domains":
|
||||
return true
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"HostBy", "HostsBy", "HostnameBy", "HostnamesBy", "DomainBy", "DomainsBy",
|
||||
} {
|
||||
if i := strings.Index(name, marker); i >= 0 {
|
||||
end := i + len(marker)
|
||||
if end < len(name) && unicode.IsUpper(rune(name[end])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, prefix := range []string{
|
||||
"hostBy", "hostsBy", "hostnameBy", "hostnamesBy", "domainBy", "domainsBy",
|
||||
} {
|
||||
if strings.HasPrefix(name, prefix) &&
|
||||
len(name) > len(prefix) &&
|
||||
unicode.IsUpper(rune(name[len(prefix)])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if i := strings.LastIndexAny(name, "_-"); i >= 0 {
|
||||
return isHostnameSemanticName(name[i+1:])
|
||||
}
|
||||
for _, suffix := range []string{"Hostnames", "Hostname", "Domains", "Domain", "Hosts", "Host"} {
|
||||
if strings.HasSuffix(name, suffix) && len(name) > len(suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stripParens(expr ast.Expr) ast.Expr {
|
||||
for {
|
||||
paren, ok := expr.(*ast.ParenExpr)
|
||||
if !ok {
|
||||
return expr
|
||||
}
|
||||
expr = paren.X
|
||||
}
|
||||
}
|
||||
|
||||
func astParentMap(root ast.Node) map[ast.Node]ast.Node {
|
||||
parents := map[ast.Node]ast.Node{}
|
||||
var stack []ast.Node
|
||||
ast.Inspect(root, func(node ast.Node) bool {
|
||||
if node == nil {
|
||||
stack = stack[:len(stack)-1]
|
||||
return false
|
||||
}
|
||||
if len(stack) > 0 {
|
||||
parents[node] = stack[len(stack)-1]
|
||||
}
|
||||
stack = append(stack, node)
|
||||
return true
|
||||
})
|
||||
return parents
|
||||
}
|
||||
|
||||
func isDomainFixturePath(rel string) bool {
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.HasPrefix(rel, "skills/") {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(rel, "_test.go") || strings.HasPrefix(rel, "tests/") {
|
||||
return true
|
||||
}
|
||||
for _, part := range strings.Split(rel, "/") {
|
||||
if part == "testdata" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unusedDomainViolation(entry domainPolicyEntry) lintapi.Violation {
|
||||
return lintapi.Violation{
|
||||
Rule: unusedDomainRule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: entry.File,
|
||||
Line: entry.Line,
|
||||
Message: fmt.Sprintf("domain allowlist entry %q has no in-scope Go reference", entry.Host),
|
||||
Suggestion: "remove the unused entry; allowlist entries must be justified by a current in-scope reference",
|
||||
}
|
||||
}
|
||||
|
||||
func incompleteDomainViolation(file string, err error) lintapi.Violation {
|
||||
return incompleteDomainViolationAt(file, 1, err)
|
||||
}
|
||||
|
||||
func incompleteDomainViolationAt(file string, line int, err error) lintapi.Violation {
|
||||
return lintapi.Violation{
|
||||
Rule: incompleteDomainRule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: file,
|
||||
Line: line,
|
||||
Message: "domain scan incomplete: " + err.Error(),
|
||||
Suggestion: "fix the Go parse or type-loading error so hostname analysis can complete",
|
||||
}
|
||||
}
|
||||
462
lint/domaincontract/unapproved_repo_test.go
Normal file
462
lint/domaincontract/unapproved_repo_test.go
Normal file
@@ -0,0 +1,462 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
func gitTestCommand(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func setupDomainDiffRepo(t *testing.T, target string) (root, base string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n")
|
||||
writeFile(t, root, publicDomainsPath, "# public\npublic.example.com\n")
|
||||
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
|
||||
writeFile(t, root, "policy_refs.go", "package sample\n\nvar APIHost = \"public.example.com\"\n")
|
||||
writeFile(t, root, "policy_refs_test.go", "package sample\n\nvar FixtureHost = \"fixture.example.com\"\n")
|
||||
writeFile(t, root, "target.go", target)
|
||||
|
||||
gitTestCommand(t, root, "init", "-q")
|
||||
gitTestCommand(t, root, "config", "user.name", "Domain Contract Test")
|
||||
gitTestCommand(t, root, "config", "user.email", "domain-contract@example.com")
|
||||
gitTestCommand(t, root, "add", ".")
|
||||
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", "base")
|
||||
return root, gitTestCommand(t, root, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
func commitDomainDiff(t *testing.T, root, message string) {
|
||||
t.Helper()
|
||||
gitTestCommand(t, root, "add", "-A")
|
||||
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", message)
|
||||
}
|
||||
|
||||
func violationsForRule(vs []lintapi.Violation, rule string) []lintapi.Violation {
|
||||
var out []lintapi.Violation
|
||||
for _, v := range vs {
|
||||
if v.Rule == rule {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation {
|
||||
t.Helper()
|
||||
vs, err := ScanRepoWithOptions(root, ScanOptions{ChangedFrom: base})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
func TestUnapprovedDomainDiffContract(t *testing.T) {
|
||||
t.Run("new PR 1975 case", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar APIHost = \"internal-api-drive-stream.larkoffice.com\"\n")
|
||||
commitDomainDiff(t, root, "add internal host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "internal-api-drive-stream.larkoffice.com") {
|
||||
t.Fatalf("violations = %+v, want PR 1975 hostname", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hostname field in nested Go module", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.23.0\n")
|
||||
writeFile(t, root, "nested/target.go",
|
||||
"package nested\n\ntype Config struct{ Host string }\n\n"+
|
||||
"var config = Config{Host: \"private.corp.internal\"}\n")
|
||||
commitDomainDiff(t, root, "add nested module hostname")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, unapprovedDomainRule)
|
||||
if len(got) != 1 || filepath.ToSlash(got[0].File) != "nested/target.go" ||
|
||||
!strings.Contains(got[0].Message, "private.corp.internal") {
|
||||
t.Fatalf("violations = %+v, want nested-module hostname rejection", got)
|
||||
}
|
||||
if incomplete := violationsForRule(all, incompleteDomainRule); len(incomplete) != 0 {
|
||||
t.Fatalf("nested module must have complete type information: %+v", incomplete)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded field reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type Config struct{ Host string }\n\n"+
|
||||
"var config = Config{Host: \"private.corp.internal\"}\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname field")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
|
||||
t.Fatalf("violations = %+v, want changed field scan-incomplete at line 7", got)
|
||||
}
|
||||
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||
t.Fatalf("untyped field must not produce an unverified hostname finding: %+v", unapproved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded selector reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type Config struct{ Host string }\n\n"+
|
||||
"func configure(config *Config) { config.Host = \"private.corp.internal\" }\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname selector")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
|
||||
t.Fatalf("violations = %+v, want changed selector scan-incomplete at line 7", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded named slice reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type HostList []string\n\n"+
|
||||
"var AllowedHosts = HostList{\n\t\"attacker.zip\",\n}\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname slice")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
|
||||
t.Fatalf("violations = %+v, want named-slice scan-incomplete at line 8", got)
|
||||
}
|
||||
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||
t.Fatalf("untyped named slice must not produce an unverified hostname finding: %+v", unapproved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded named map reports incomplete scan", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type HostSet map[string]struct{}\n\n"+
|
||||
"var AllowedHosts = HostSet{\n\t\"attacker.zip\": {},\n}\n")
|
||||
commitDomainDiff(t, root, "add excluded hostname map")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
|
||||
t.Fatalf("violations = %+v, want named-map scan-incomplete at line 8", got)
|
||||
}
|
||||
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||
t.Fatalf("untyped named map must not produce an unverified hostname finding: %+v", unapproved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed excluded unrelated code stays allowed", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\nvar unrelated = 2\n")
|
||||
commitDomainDiff(t, root, "add excluded unrelated code")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unrelated excluded code must not require hostname type information: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new element in existing collection", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n}\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n\t\"attacker.zip\",\n}\n")
|
||||
commitDomainDiff(t, root, "add collection host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
|
||||
t.Fatalf("violations = %+v, want attacker.zip", got)
|
||||
}
|
||||
if got[0].Line != 5 {
|
||||
t.Fatalf("violation line = %d, want 5", got[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiline expression changed segment", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"example.com\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"internal\"\n")
|
||||
commitDomainDiff(t, root, "change concatenated host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
|
||||
t.Fatalf("violations = %+v, want private.corp.internal", got)
|
||||
}
|
||||
if got[0].Line != 4 {
|
||||
t.Fatalf("violation line = %d, want changed line 4", got[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrelated change beside historical hostname", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\nvar unrelated = 1\n")
|
||||
commitDomainDiff(t, root, "add unrelated value")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected historical-domain violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("historical hostname expression changed", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar HistoricalHost = \"replacement.private.internal\"\n")
|
||||
commitDomainDiff(t, root, "change historical host")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "replacement.private.internal") {
|
||||
t.Fatalf("violations = %+v, want replacement.private.internal", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new assignment references existing constant", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nconst existingConst = \"private.corp.internal\"\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nconst existingConst = \"private.corp.internal\"\nvar APIHost = existingConst\n")
|
||||
commitDomainDiff(t, root, "use existing hostname constant")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
|
||||
t.Fatalf("violations = %+v, want private.corp.internal", got)
|
||||
}
|
||||
if got[0].Line != 4 {
|
||||
t.Fatalf("violation line = %d, want 4", got[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlisted hostname", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"public.example.com\"\n")
|
||||
commitDomainDiff(t, root, "add public host")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected public-domain violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reserved example URL", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nfunc fakeValue() string { return \"https://example.test/resource\" }\n")
|
||||
commitDomainDiff(t, root, "add safe example URL")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected reserved-example violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("historical type gap suppresses unused policy diagnostics", func(t *testing.T) {
|
||||
root, _ := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\nplatform.example.com\npublic.example.com\n")
|
||||
writeFile(t, root, "excluded.go",
|
||||
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||
"type Config struct{ Host string }\n\n"+
|
||||
"var config = Config{Host: \"platform.example.com\"}\n")
|
||||
commitDomainDiff(t, root, "add historical platform hostname")
|
||||
base := gitTestCommand(t, root, "rev-parse", "HEAD")
|
||||
|
||||
writeFile(t, root, "target.go", "package sample\n\nvar unrelated = 2\n")
|
||||
commitDomainDiff(t, root, "change unrelated code")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
if got := violationsForRule(all, incompleteDomainRule); len(got) != 0 {
|
||||
t.Fatalf("historical type gap must not be attributed to this change: %+v", got)
|
||||
}
|
||||
if got := violationsForRule(all, unusedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("incomplete inventory must not produce unused-policy diagnostics: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist does not approve subdomains", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"evil.public.example.com\"\n")
|
||||
commitDomainDiff(t, root, "add unapproved public subdomain")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.public.example.com") {
|
||||
t.Fatalf("violations = %+v, want evil.public.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi assignment pairs names and values", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\nopen.larksuite.com\npublic.example.com\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar APIHost, BackupHost = \"open.larksuite.com\", \"attacker.zip\"\n")
|
||||
commitDomainDiff(t, root, "add multiple hosts")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
|
||||
t.Fatalf("violations = %+v, want only attacker.zip", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IDN hostname is rejected", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"例子.公司.cn\"\n")
|
||||
commitDomainDiff(t, root, "add IDN hostname")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "例子.公司.cn") {
|
||||
t.Fatalf("violations = %+v, want IDN hostname", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture limited to test files", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nvar unrelated = 1\nvar ProductionHost = \"fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use fixture in production")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
|
||||
t.Fatalf("violations = %+v, want production fixture rejection", got)
|
||||
}
|
||||
if !strings.Contains(got[0].Suggestion, "fixture-only hostname") ||
|
||||
strings.Contains(got[0].Suggestion, "public allowlist") {
|
||||
t.Fatalf("suggestion = %q, want fixture-scope guidance", got[0].Suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture accepted in test file", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "new_target_test.go",
|
||||
"package sample\n\nvar BackupHost = \"fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use fixture in test")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected fixture-domain violation: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture allowlist does not approve subdomains", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "new_target_test.go",
|
||||
"package sample\n\nvar BackupHost = \"evil.fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use unapproved fixture subdomain")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.fixture.example.com") {
|
||||
t.Fatalf("violations = %+v, want exact fixture match", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture rejected in skills", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "skills/example/example_test.go",
|
||||
"package example\n\nvar BackupHost = \"fixture.example.com\"\n")
|
||||
commitDomainDiff(t, root, "use fixture in skill")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
|
||||
t.Fatalf("violations = %+v, want skill fixture rejection", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pure rename", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t,
|
||||
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||
gitTestCommand(t, root, "mv", "target.go", "renamed.go")
|
||||
commitDomainDiff(t, root, "rename file")
|
||||
|
||||
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||
t.Fatalf("unexpected rename violation: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnapprovedDomainPolicyAndFailurePaths(t *testing.T) {
|
||||
t.Run("unused policy entry", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\npublic.example.com\nunused.example.com\n")
|
||||
commitDomainDiff(t, root, "add unused policy")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "unused.example.com") {
|
||||
t.Fatalf("violations = %+v, want unused.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public entry used only by fixture", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, publicDomainsPath,
|
||||
"# public\npublic.example.com\ntest-only.example.com\n")
|
||||
writeFile(t, root, "public_only_test.go",
|
||||
"package sample\n\nvar BackupHost = \"test-only.example.com\"\n")
|
||||
commitDomainDiff(t, root, "add test-only public policy")
|
||||
|
||||
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Message, "test-only.example.com") {
|
||||
t.Fatalf("violations = %+v, want test-only.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed Go parse failure", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "target.go", "package sample\n\nfunc broken(\n")
|
||||
commitDomainDiff(t, root, "break source")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "target.go" {
|
||||
t.Fatalf("violations = %+v, want target.go scan-incomplete", got)
|
||||
}
|
||||
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
|
||||
t.Fatalf("parse failure must not produce unreliable unused-policy diagnostics: %+v", unused)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repository type loading failure", func(t *testing.T) {
|
||||
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n\n"+
|
||||
"require example.com/missing v0.0.0\n\nreplace example.com/missing => ./missing\n")
|
||||
writeFile(t, root, "target.go",
|
||||
"package sample\n\nimport _ \"example.com/missing\"\n\n"+
|
||||
"type Config struct{ Host string }\nvar config = Config{Host: \"malicious.corp.internal\"}\n")
|
||||
commitDomainDiff(t, root, "break type loading")
|
||||
|
||||
all := scanDomainDiff(t, root, base)
|
||||
got := violationsForRule(all, incompleteDomainRule)
|
||||
if len(got) != 1 || filepath.Base(got[0].File) != "go.mod" {
|
||||
t.Fatalf("violations = %+v, want go.mod scan-incomplete", got)
|
||||
}
|
||||
if !strings.Contains(got[0].Message, "load Go type information") {
|
||||
t.Fatalf("message = %q, want type-loading failure", got[0].Message)
|
||||
}
|
||||
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
|
||||
t.Fatalf("type-loading failure must not produce unreliable unused-policy diagnostics: %+v", unused)
|
||||
}
|
||||
})
|
||||
}
|
||||
380
lint/domaincontract/unapproved_test.go
Normal file
380
lint/domaincontract/unapproved_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func scanDomainEvidence(t *testing.T, source string) []domainEvidence {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixture: %v\n%s", err, source)
|
||||
}
|
||||
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset})
|
||||
scan.collectSemanticEvidence()
|
||||
scan.collectAbsoluteURLEvidence()
|
||||
sort.Slice(scan.Evidence, func(i, j int) bool {
|
||||
if scan.Evidence[i].Host != scan.Evidence[j].Host {
|
||||
return scan.Evidence[i].Host < scan.Evidence[j].Host
|
||||
}
|
||||
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
|
||||
})
|
||||
return scan.Evidence
|
||||
}
|
||||
|
||||
func scanTypedDomainEvidence(t *testing.T, source string) []domainEvidence {
|
||||
t.Helper()
|
||||
return scanTypedDomainEvidenceInPackage(t, "fixture", source)
|
||||
}
|
||||
|
||||
func scanTypedDomainEvidenceInPackage(t *testing.T, packagePath, source string) []domainEvidence {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixture: %v\n%s", err, source)
|
||||
}
|
||||
info := &types.Info{
|
||||
Types: map[ast.Expr]types.TypeAndValue{},
|
||||
Defs: map[*ast.Ident]types.Object{},
|
||||
Uses: map[*ast.Ident]types.Object{},
|
||||
Selections: map[*ast.SelectorExpr]*types.Selection{},
|
||||
}
|
||||
if _, err := (&types.Config{}).Check(packagePath, fset, []*ast.File{file}, info); err != nil {
|
||||
t.Fatalf("type-check fixture: %v\n%s", err, source)
|
||||
}
|
||||
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset, Info: info})
|
||||
scan.collectSemanticEvidence()
|
||||
scan.collectAbsoluteURLEvidence()
|
||||
sort.Slice(scan.Evidence, func(i, j int) bool {
|
||||
if scan.Evidence[i].Host != scan.Evidence[j].Host {
|
||||
return scan.Evidence[i].Host < scan.Evidence[j].Host
|
||||
}
|
||||
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
|
||||
})
|
||||
return scan.Evidence
|
||||
}
|
||||
|
||||
func evidenceHosts(evidence []domainEvidence) []string {
|
||||
hosts := make([]string, 0, len(evidence))
|
||||
for _, item := range evidence {
|
||||
hosts = append(hosts, item.Host)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func TestTypedAbsoluteURLDeclarationProducesOneFinding(t *testing.T) {
|
||||
evidence := scanTypedDomainEvidence(t,
|
||||
"package p\nconst DomainContractE2EURL = \"https://private.corp.internal/v1\"\n")
|
||||
if got := evidenceHosts(evidence); len(got) != 1 || got[0] != "private.corp.internal" {
|
||||
t.Fatalf("hosts = %v, want [private.corp.internal]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoDomainEvidenceTruePositives(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "PR 1975 Feishu assignment",
|
||||
source: "package p\nfunc f() { host := \"internal-api-drive-stream.feishu.cn\"; _ = host }\n",
|
||||
want: []string{"internal-api-drive-stream.feishu.cn"},
|
||||
},
|
||||
{
|
||||
name: "PR 1975 Lark assignment",
|
||||
source: "package p\nfunc f() { var host string; host = \"internal-api-drive-stream.larksuite.com\"; _ = host }\n",
|
||||
want: []string{"internal-api-drive-stream.larksuite.com"},
|
||||
},
|
||||
{
|
||||
name: "uppercase snake target",
|
||||
source: "package p\nfunc f() { API_HOST := \"private.corp.internal\"; _ = API_HOST }\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "typed declaration",
|
||||
source: "package p\nconst APIHost string = \"attacker.zip\"\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "grouped const declaration",
|
||||
source: "package p\nconst (\n APIHost string = \"attacker.zip\"\n)\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "grouped var declaration",
|
||||
source: "package p\nvar (\n APIHost string = \"attacker.zip\"\n)\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "multi assignment",
|
||||
source: "package p\nfunc f() {\n" +
|
||||
" APIHost, BackupHost := \"public.example.com\", \"attacker.zip\"\n" +
|
||||
" _, _ = APIHost, BackupHost\n}\n",
|
||||
want: []string{"attacker.zip", "public.example.com"},
|
||||
},
|
||||
{
|
||||
name: "map semantic key",
|
||||
source: "package p\nvar c = map[string]string{\"host\": \"private.corp.internal\"}\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "map semantic key assignment",
|
||||
source: "package p\nfunc f() { c := map[string]string{}; c[\"host\"] = \"private.corp.internal\" }\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "host collection values",
|
||||
source: "package p\nvar ALLOWED_HOSTS = []string{\"private.corp.internal\", \"attacker.zip\"}\n",
|
||||
want: []string{"attacker.zip", "private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "host collection map keys",
|
||||
source: "package p\nvar allowedHosts = map[string]struct{}{\"attacker.zip\": {}}\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "host collection bool map keys",
|
||||
source: "package p\nvar AllowedHosts = map[string]bool{\"api.example.com\": true}\n",
|
||||
want: []string{"api.example.com"},
|
||||
},
|
||||
{
|
||||
name: "host collection map values",
|
||||
source: "package p\nvar HostsByRegion = map[string]string{\"sg\": \"api.example.com\"}\n",
|
||||
want: []string{"api.example.com"},
|
||||
},
|
||||
{
|
||||
name: "host collection map value assignment",
|
||||
source: "package p\nfunc f() {\n" +
|
||||
" HostsByRegion := map[string]string{}\n" +
|
||||
" HostsByRegion[\"sg\"] = \"api.example.com\"\n" +
|
||||
"}\n",
|
||||
want: []string{"api.example.com"},
|
||||
},
|
||||
{
|
||||
name: "static concatenation",
|
||||
source: "package p\nvar APIHost = \"attacker.\" + \"zip\"\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "multiline assignment",
|
||||
source: "package p\nfunc f() {\n APIHost :=\n \"attacker.zip\"\n _ = APIHost\n}\n",
|
||||
want: []string{"attacker.zip"},
|
||||
},
|
||||
{
|
||||
name: "escaped hostname",
|
||||
source: "package p\nvar APIHost = \"private\\u002ecorp\\u002einternal\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "hex escaped hostname",
|
||||
source: "package p\nvar APIHost = \"private\\x2ecorp\\x2einternal\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "octal escaped hostname",
|
||||
source: "package p\nvar APIHost = \"private\\056corp\\056internal\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "raw hostname",
|
||||
source: "package p\nvar APIHost = `private.corp.internal`\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "same-file constant reference",
|
||||
source: "package p\nconst existingConst = \"private.corp.internal\"\n" +
|
||||
"func f() { APIHost := existingConst; _ = APIHost }\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "absolute URL",
|
||||
source: "package p\nvar message = \"https://private.corp.internal/v1\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "websocket URL with port",
|
||||
source: "package p\nvar endpoint = \"wss://private.corp.internal:443/v1\"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "URL userinfo query and fragment",
|
||||
source: "package p\nvar endpoint = \" https://user:pass@private.corp.internal:8443/v1?q=1#result \"\n",
|
||||
want: []string{"private.corp.internal"},
|
||||
},
|
||||
{
|
||||
name: "IDN hostname",
|
||||
source: "package p\nvar APIHost = \"例子.公司.cn\"\n",
|
||||
want: []string{"例子.公司.cn"},
|
||||
},
|
||||
{
|
||||
name: "case port and trailing dot normalization",
|
||||
source: "package p\nvar APIHost = \"EXAMPLE.COM.:443\"\n",
|
||||
want: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := evidenceHosts(scanDomainEvidence(t, tc.source))
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("hosts = %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("hosts = %v, want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoDomainEvidenceTrueNegatives(t *testing.T) {
|
||||
source := `package p
|
||||
|
||||
import _ "github.com/larksuite/oapi-sdk-go/v3"
|
||||
|
||||
var file = "archive.zip"
|
||||
var event = "card.action.trigger"
|
||||
var schema = "im.messages.list"
|
||||
var configFile = "service.prod.json"
|
||||
var version = "v1.2.3"
|
||||
var email = "name@example.com"
|
||||
var lowConfidence = "attacker.zip"
|
||||
var downloadURL = "archive.zip/file"
|
||||
var prose = "See https://private.corp.internal/v1 for details"
|
||||
// https://private.corp.internal/v1
|
||||
var ghost = "private.corp.internal"
|
||||
var hostnameParser = "private.corp.internal"
|
||||
var domainError = "private.corp.internal"
|
||||
var APIHost = "localhost"
|
||||
var BackupHost = "127.0.0.1"
|
||||
var hosts = struct{ File string }{File: "archive.zip"}
|
||||
var AllowedHosts = map[string]string{"api.example.com": "client.pem"}
|
||||
|
||||
func dynamicValue() string { return "private.corp.internal" }
|
||||
var DynamicHost = dynamicValue()
|
||||
|
||||
func setAmbiguousHostMetadata() {
|
||||
AllowedHosts["api.example.com"] = "client.pem"
|
||||
}
|
||||
`
|
||||
if got := scanDomainEvidence(t, source); len(got) != 0 {
|
||||
t.Fatalf("unexpected evidence: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedStructFieldHostnameSemantics(t *testing.T) {
|
||||
t.Run("network fields", func(t *testing.T) {
|
||||
source := `package source
|
||||
|
||||
type Config struct { Host string }
|
||||
type FeishuSource struct { Domain string }
|
||||
|
||||
var config = Config{Host: "api.example.com"}
|
||||
var source = FeishuSource{Domain: "events.example.com"}
|
||||
`
|
||||
got := evidenceHosts(scanTypedDomainEvidenceInPackage(
|
||||
t,
|
||||
"github.com/larksuite/cli/internal/event/source",
|
||||
source,
|
||||
))
|
||||
want := []string{"api.example.com", "events.example.com"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("command metadata domain", func(t *testing.T) {
|
||||
source := `package cmdmeta
|
||||
|
||||
type Meta struct { Domain string }
|
||||
|
||||
var meta = Meta{Domain: "im.messages"}
|
||||
func update(meta *Meta) { meta.Domain = "docs.pages" }
|
||||
`
|
||||
if got := scanTypedDomainEvidenceInPackage(
|
||||
t,
|
||||
"github.com/larksuite/cli/internal/cmdmeta",
|
||||
source,
|
||||
); len(got) != 0 {
|
||||
t.Fatalf("unexpected command metadata evidence: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card action host", func(t *testing.T) {
|
||||
source := `package im
|
||||
|
||||
type CardActionTriggerOutput struct { Host string }
|
||||
|
||||
var output = CardActionTriggerOutput{Host: "card.action"}
|
||||
func update(output *CardActionTriggerOutput) { output.Host = "im.message" }
|
||||
`
|
||||
if got := scanTypedDomainEvidenceInPackage(
|
||||
t,
|
||||
"github.com/larksuite/cli/events/im",
|
||||
source,
|
||||
); len(got) != 0 {
|
||||
t.Fatalf("unexpected card host evidence: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field ownership is conservative", func(t *testing.T) {
|
||||
source := "package p\ntype Config struct { Host string }\nvar c = Config{Host: \"api.example.com\"}\n"
|
||||
if got := scanDomainEvidence(t, source); len(got) != 0 {
|
||||
t.Fatalf("unexpected untyped field evidence: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHostnameSemanticNames(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"host", "HOST", "hosts", "hostname", "domains",
|
||||
"api_host", "API_HOST", "ALLOWED_HOSTS",
|
||||
"apiHost", "APIHost", "backupHostname",
|
||||
"HostsByRegion", "APIHostsByRegion", "hostsByRegion",
|
||||
} {
|
||||
if !isHostnameSemanticName(name) {
|
||||
t.Errorf("%q should be hostname-semantic", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ghost", "hostnameParser", "domainError", "hostValue", "downloadURL", "endpoint", "origin",
|
||||
"HostBypass", "APIHostBypass",
|
||||
} {
|
||||
if isHostnameSemanticName(name) {
|
||||
t.Errorf("%q must not be hostname-semantic", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainFixturePaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"internal/x/x_test.go",
|
||||
"tests/cli_e2e/x.go",
|
||||
"internal/x/testdata/sample.go",
|
||||
} {
|
||||
if !isDomainFixturePath(path) {
|
||||
t.Errorf("%q should be fixture scope", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"internal/x/test_helper.go",
|
||||
"examples/demo.go",
|
||||
"skills/example/testdata/sample.go",
|
||||
"skills/example/example_test.go",
|
||||
} {
|
||||
if isDomainFixturePath(path) {
|
||||
t.Errorf("%q must not be fixture scope", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
lint/main.go
10
lint/main.go
@@ -3,7 +3,7 @@
|
||||
|
||||
// Command lintcheck runs repository source-contract guards that golangci-lint
|
||||
// cannot express directly. It currently covers typed-error contracts and the
|
||||
// resolver-owned endpoint contract.
|
||||
// resolver-owned endpoint and approved-domain contracts.
|
||||
//
|
||||
// lintcheck lives in its own Go module under lint/ so its build-time
|
||||
// dependency on golang.org/x/tools/go/packages does not leak into the
|
||||
@@ -43,8 +43,10 @@ type scanner struct {
|
||||
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepo(root)
|
||||
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ func main() {
|
||||
"Runs every registered lint domain against repo-root (default: current directory).\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks")
|
||||
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental source-contract checks")
|
||||
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
|
||||
flag.Parse()
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
71
shortcuts/apps/apps_cache_clear.go
Normal file
71
shortcuts/apps/apps_cache_clear.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheClear clears all cache entries for the app in the given environment.
|
||||
//
|
||||
// POST /apps/{app_id}/cache/clear,body {env}。清空当前应用指定环境下全部缓存,用于无法定位
|
||||
// 具体 key 的快速恢复;影响面大,定 high-risk-write(框架自动注入 --yes 确认)。
|
||||
var AppsCacheClear = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-clear",
|
||||
Description: "Clear all cache entries for the app in the given environment",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appCacheClearPath(appID)).
|
||||
Desc("Clear all cache entries for the app in the given environment").
|
||||
Body(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheClearPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
|
||||
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
|
||||
n := int64(0)
|
||||
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
|
||||
n = int64(f)
|
||||
}
|
||||
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
|
||||
}
|
||||
75
shortcuts/apps/apps_cache_delete.go
Normal file
75
shortcuts/apps/apps_cache_delete.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheDelete deletes a single business cache key (idempotent).
|
||||
//
|
||||
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
|
||||
// 故定 write(非 high-risk-write、不需 --yes)。目标不存在按幂等成功处理(deleted_key_count=0)。
|
||||
var AppsCacheDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-delete",
|
||||
Description: "Delete a single business cache key (idempotent)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "key", Desc: "business cache key", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
DELETE(appCachePath(appID)).
|
||||
Desc("Delete a Miaoda app runtime cache key").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := rctx.Str("key")
|
||||
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"key": key,
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheDeletePretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
|
||||
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
|
||||
key := common.GetString(out, "key")
|
||||
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
|
||||
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
|
||||
}
|
||||
105
shortcuts/apps/apps_cache_get.go
Normal file
105
shortcuts/apps/apps_cache_get.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheGet reads a single business cache key's value + metadata.
|
||||
//
|
||||
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
|
||||
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
|
||||
// 按 value 字节长度算出(端点不返回);未命中(exists=false)时不带 value,ttl_ms/value_size_bytes 为 null。
|
||||
var AppsCacheGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-get",
|
||||
Description: "Get a business cache key's value and metadata",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "key", Desc: "business cache key", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appCachePath(appID)).
|
||||
Desc("Get a Miaoda app runtime cache key").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := rctx.Str("key")
|
||||
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := projectCacheGet(data, key, rctx)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheGetPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectCacheGet 组装 cache-get 输出:key 回显、environment 取 resolved env、exists 直读;
|
||||
// 命中时带 ttl_ms + value(原始串)+ value_size_bytes(CLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
|
||||
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
|
||||
exists := cacheBool(data["exists"])
|
||||
out := map[string]interface{}{
|
||||
"key": key,
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"exists": exists,
|
||||
}
|
||||
if exists {
|
||||
val := common.GetString(data, "value")
|
||||
out["ttl_ms"] = cacheInt(data["ttl_ms"])
|
||||
out["value_size_bytes"] = len([]byte(val))
|
||||
out["value"] = val
|
||||
} else {
|
||||
out["ttl_ms"] = nil
|
||||
out["value_size_bytes"] = nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderCacheGetPretty 打元信息块(key/environment/exists,命中再加 ttl/value_size),命中时末尾展开 value。
|
||||
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
|
||||
exists, _ := out["exists"].(bool)
|
||||
pairs := [][2]string{
|
||||
{"key", common.GetString(out, "key")},
|
||||
{"environment", common.GetString(out, "environment")},
|
||||
{"exists", fmt.Sprintf("%v", exists)},
|
||||
}
|
||||
if exists {
|
||||
pairs = append(pairs,
|
||||
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
|
||||
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
|
||||
)
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
if exists {
|
||||
fmt.Fprintln(w, "value:")
|
||||
printCacheValuePretty(w, common.GetString(out, "value"))
|
||||
}
|
||||
}
|
||||
357
shortcuts/apps/apps_cache_test.go
Normal file
357
shortcuts/apps/apps_cache_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
|
||||
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
|
||||
)
|
||||
|
||||
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串(value 不反序列化)。
|
||||
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
|
||||
|
||||
// ── cache-get ──
|
||||
|
||||
// TestAppsCacheGet_HitJSON:命中时 json 默认——value 原样透传(不反序列化),
|
||||
// value_size_bytes 由 CLI 按 value 字节长度算出,environment 取服务端 resolved env。
|
||||
func TestAppsCacheGet_HitJSON(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
|
||||
t.Fatalf("get hit data=%v", d)
|
||||
}
|
||||
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
|
||||
}
|
||||
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
|
||||
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
|
||||
}
|
||||
// ttl_ms 必须是 JSON number(透传服务端数字,不得变成字符串);JSON 解析后为 float64。
|
||||
if _, ok := d["ttl_ms"].(float64); !ok {
|
||||
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_HitPretty:pretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
|
||||
func TestAppsCacheGet_HitPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("pretty missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_Miss:未命中——exists=false,无 value,ttl_ms / value_size_bytes 为 null。
|
||||
func TestAppsCacheGet_Miss(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": false,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != false {
|
||||
t.Fatalf("miss exists=%v", d["exists"])
|
||||
}
|
||||
if _, ok := d["value"]; ok {
|
||||
t.Fatalf("miss must not carry value: %v", d)
|
||||
}
|
||||
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
|
||||
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_ExistsAsString:服务端把 exists 返成字符串 "true" 时仍按命中处理
|
||||
// (cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
|
||||
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != true {
|
||||
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
|
||||
}
|
||||
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||
t.Fatalf("命中应带 value, got %v", d["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_PrettyNonJSONFallback:pretty 下 value 不是合法 JSON 时降级原样输出
|
||||
// (safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
|
||||
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
|
||||
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_TTLAsStringNormalized:服务端把 ttl_ms 返成字符串 "272000" 时,
|
||||
// 输出的 ttl_ms 必须归一成 JSON number(cacheInt),不得随 wire 形态漂移成字符串。
|
||||
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
f, ok := d["ttl_ms"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||
}
|
||||
if int(f) != 272000 {
|
||||
t.Fatalf("ttl_ms = %v, want 272000", f)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_CountAsStringNormalized:服务端把 deleted_key_count 返成字符串 "1" 时,
|
||||
// 输出必须归一成 JSON number(cacheInt)。
|
||||
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if _, ok := d["deleted_key_count"].(float64); !ok {
|
||||
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunOmitsEnv:不传 --environment 时 dry-run query 不带 env(服务端自动选),但带 key。
|
||||
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "GET" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
|
||||
}
|
||||
if a.Params["key"] != "k:1" {
|
||||
t.Fatalf("key must be in query, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunWithEnv:显式 --environment dev → query 带 env=dev。
|
||||
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Params["env"] != "dev" {
|
||||
t.Fatalf("env must be dev, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_RequiresKey:缺 --key → 校验错。
|
||||
func TestAppsCacheGet_RequiresKey(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --key error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-delete ──
|
||||
|
||||
// TestAppsCacheDelete_Hit:删中命中的 key → deleted_key_count=1;pretty 打 "✓ cache deleted"。
|
||||
func TestAppsCacheDelete_Hit(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache deleted") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentJSON:目标不存在 → 幂等成功,deleted_key_count=0,pretty 措辞区分。
|
||||
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
|
||||
t.Fatalf("absent data=%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentPretty:不存在 pretty 打 "✓ cache already absent"。
|
||||
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "already absent") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_DryRun:DELETE 方法、/cache 路由,query 带 key + env。
|
||||
func TestAppsCacheDelete_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "DELETE" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
|
||||
t.Fatalf("params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-clear ──
|
||||
|
||||
// TestAppsCacheClear_Success:清空成功 → deleted_key_count=128;pretty 打 "✓ cache cleared: 128 entries (dev)"。
|
||||
func TestAppsCacheClear_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: cacheClearURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_RequiresConfirmation:high-risk-write 无 --yes → 被确认门拦截。
|
||||
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyWithEnv:dry-run POST /cache/clear,body 带 env=dev。
|
||||
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "POST" || a.URL != cacheClearURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["env"] != "dev" {
|
||||
t.Fatalf("body must carry env=dev, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyOmitsEnv:不传 --environment → body 不带 env(服务端自动选)。
|
||||
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if _, ok := a.Body["env"]; ok {
|
||||
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项(method/url/params/body)。
|
||||
// 复用本包规范的 dryRunAPIEnvelope(api 现嵌在 data.api 下,见 dryrun_test.go)。
|
||||
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
|
||||
t.Helper()
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
|
||||
t.Fatalf("bad dry-run json: %v\n%s", err, s)
|
||||
}
|
||||
return env.API[0]
|
||||
}
|
||||
99
shortcuts/apps/cache_common.go
Normal file
99
shortcuts/apps/cache_common.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 应用运行时缓存(Cache)调试命令共享件:路由 + 环境 flag + 渲染。
|
||||
//
|
||||
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`,按运行环境(env→dbBranch)隔离:
|
||||
// 环境 flag 用 cacheEnvFlag()(只 --environment,不带 db 家族的旧名 --env),env 值经 dbEnv 读、
|
||||
// 经 dbEnvParams 注入——get/delete 放 query,clear 放 body(省略即服务端自动选分支)。
|
||||
|
||||
// appCachePath 返回缓存单 key 读/删 URL:cache(GET 读、DELETE 删,靠方法区分)。
|
||||
func appCachePath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appCacheClearPath 返回清空指定环境缓存 URL:cache/clear。
|
||||
func appCacheClearPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env,
|
||||
// 故只注册干净的 --environment(不带 db 家族那套隐藏 --env + 拒收逻辑)。
|
||||
// 省略即服务端按应用多环境状态自动选分支(多环境→dev,非多环境→online)。
|
||||
func cacheEnvFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "environment",
|
||||
Enum: []string{"dev", "online"},
|
||||
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
|
||||
}
|
||||
}
|
||||
|
||||
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool,
|
||||
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中(hit→miss 翻转)。
|
||||
func cacheBool(v interface{}) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(x), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cacheInt 把服务端下发的数值字段归一成 int64(无法解析→nil)。本仓惯例:数值可能以字符串下发
|
||||
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
|
||||
// (number ↔ string)。归一后输出类型恒定为数字或 null,消费方无需自己容忍字符串。
|
||||
func cacheInt(raw interface{}) interface{} {
|
||||
if f, ok := numericAsFloat(raw); ok {
|
||||
return int64(f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvedEnv 取服务端回吐的 resolved env;缺失时兜底成请求侧 --environment(可能为空)。
|
||||
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
|
||||
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
|
||||
if env := common.GetString(data, "env"); env != "" {
|
||||
return env
|
||||
}
|
||||
return dbEnv(rctx)
|
||||
}
|
||||
|
||||
// formatCacheTTL 把剩余 TTL(毫秒)格式化成 4m32s 这样的时长串;非数字返回 "—"。
|
||||
func formatCacheTTL(ms interface{}) string {
|
||||
f, ok := numericAsFloat(ms)
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
return (time.Duration(int64(f)) * time.Millisecond).String()
|
||||
}
|
||||
|
||||
// printCacheValuePretty 把 value 反序列化后缩进展开(pretty 口径);非 JSON 则原样打印。
|
||||
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
|
||||
func printCacheValuePretty(w io.Writer, raw string) {
|
||||
v := safeParseJSON(raw)
|
||||
if s, ok := v.(string); ok {
|
||||
fmt.Fprintln(w, s)
|
||||
return
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, raw)
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsFileUpload,
|
||||
AppsFileDelete,
|
||||
AppsFileQuotaGet,
|
||||
AppsCacheGet,
|
||||
AppsCacheDelete,
|
||||
AppsCacheClear,
|
||||
AppsGitCredentialInit,
|
||||
AppsGitCredentialList,
|
||||
AppsGitCredentialRemove,
|
||||
|
||||
@@ -20,13 +20,14 @@ import (
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 cache(get/delete/clear)
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。
|
||||
func TestAppsShortcuts_Returns79(t *testing.T) {
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 82。
|
||||
func TestAppsShortcuts_Returns82(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 79 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
|
||||
if len(got) != 82 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -27,19 +28,23 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
|
||||
"Each new question creates a field in the form's table; question IDs are field IDs.",
|
||||
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
api := common.NewDryRunAPI().
|
||||
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
Set("form_id", runtime.Str("form-id")).
|
||||
Body(map[string]interface{}{"questions": questions})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
formId := runtime.Str("form-id")
|
||||
questionsJSON := runtime.Str("questions")
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
questions, err := parseFormQuestionsCreate(questionsJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
@@ -78,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
|
||||
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
if questions == nil {
|
||||
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
|
||||
}
|
||||
if len(questions) > 10 {
|
||||
return nil, baseValidationErrorf("--questions must contain at most 10 items")
|
||||
}
|
||||
for i, question := range questions {
|
||||
item, ok := question.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
|
||||
}
|
||||
title, ok := item["title"].(string)
|
||||
if !ok || strings.TrimSpace(title) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
|
||||
}
|
||||
questionType, ok := item["type"].(string)
|
||||
if !ok || strings.TrimSpace(questionType) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
|
||||
}
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
|
||||
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
|
||||
for _, want := range []string{
|
||||
"+form-questions-list",
|
||||
"verified empty form can create directly",
|
||||
"question IDs are field IDs",
|
||||
"explicitly requests a separate same-title question",
|
||||
"+form-questions-update",
|
||||
} {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ var BaseURLResolve = common.Shortcut{
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"base:block:read",
|
||||
"base:field:read",
|
||||
"base:record:read",
|
||||
"wiki:node:retrieve",
|
||||
@@ -40,7 +41,7 @@ var BaseURLResolve = common.Shortcut{
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
|
||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -57,10 +58,34 @@ var BaseURLResolve = common.Shortcut{
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
baseToken := firstPathSegmentAfter(parsed.Path, "/base/")
|
||||
if selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")); selectedBlockID != "" {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Body(map[string]interface{}{}).
|
||||
Set("base_token", baseToken).
|
||||
Set("selected_block_id", selectedBlockID)
|
||||
}
|
||||
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||
case "wiki_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
dry := common.NewDryRunAPI()
|
||||
selectedBlockID := strings.TrimSpace(parsed.Query().Get("table"))
|
||||
if selectedBlockID == "" {
|
||||
return dry.
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
}
|
||||
dry.Desc("2-step: resolve the Wiki node to a Base, then identify the selected Base block")
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve the Wiki node to its underlying Base").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
dry.POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Desc("[2] List Base blocks and match selected_block_id").
|
||||
Body(map[string]interface{}{})
|
||||
return dry.
|
||||
Set("base_token", "<obj_token from step 1>").
|
||||
Set("selected_block_id", selectedBlockID)
|
||||
case "record_share_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||
@@ -170,7 +195,7 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
out := resolveBaseURL(parsed)
|
||||
enrichBaseResolveHint(runtime, out)
|
||||
enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed))
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "wiki_url":
|
||||
@@ -178,6 +203,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selection := resolveBaseURLSelection(parsed)
|
||||
applyBaseURLSelection(out, selection)
|
||||
enrichBaseResolveHint(runtime, out, selection)
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "record_share_url":
|
||||
@@ -251,24 +279,50 @@ func classifyBaseURL(u *url.URL) string {
|
||||
}
|
||||
|
||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||
query := u.Query()
|
||||
out := map[string]interface{}{
|
||||
"input_type": "base_url",
|
||||
"resource_type": "bitable",
|
||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||
}
|
||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
||||
out["table_id"] = tableID
|
||||
}
|
||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
||||
out["view_id"] = viewID
|
||||
}
|
||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
||||
out["record_id"] = recordID
|
||||
}
|
||||
applyBaseURLSelection(out, resolveBaseURLSelection(u))
|
||||
return out
|
||||
}
|
||||
|
||||
type baseURLSelection struct {
|
||||
blockID string
|
||||
viewID string
|
||||
recordID string
|
||||
}
|
||||
|
||||
func resolveBaseURLSelection(u *url.URL) baseURLSelection {
|
||||
query := u.Query()
|
||||
return baseURLSelection{
|
||||
blockID: strings.TrimSpace(query.Get("table")),
|
||||
viewID: strings.TrimSpace(query.Get("view")),
|
||||
recordID: strings.TrimSpace(query.Get("record")),
|
||||
}
|
||||
}
|
||||
|
||||
func applyBaseURLSelection(out map[string]interface{}, selection baseURLSelection) {
|
||||
if selection.blockID != "" {
|
||||
// The Base web UI historically uses the query key "table" for the
|
||||
// currently selected top-level block. Its value can identify a table,
|
||||
// dashboard, workflow, or another block type. Keep it neutral until the
|
||||
// block directory confirms the resource type.
|
||||
out["block_id"] = selection.blockID
|
||||
out["selection_source"] = "url_query"
|
||||
}
|
||||
}
|
||||
|
||||
func applyResolvedTableSelection(out map[string]interface{}, selection baseURLSelection) {
|
||||
if selection.viewID != "" {
|
||||
out["view_id"] = selection.viewID
|
||||
}
|
||||
if selection.recordID != "" {
|
||||
out["record_id"] = selection.recordID
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||
@@ -368,13 +422,89 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
if baseToken == "" || tableID == "" {
|
||||
selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id"))
|
||||
if baseToken == "" || selectedBlockID == "" {
|
||||
out["hint"] = resolveHint("", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found {
|
||||
out["block_type"] = block.Type
|
||||
if block.Name != "" {
|
||||
out["block_name"] = block.Name
|
||||
}
|
||||
switch block.Type {
|
||||
case "table":
|
||||
applyResolvedTableSelection(out, selection)
|
||||
enrichResolvedTable(runtime, out, baseToken, selectedBlockID)
|
||||
case "dashboard":
|
||||
out["dashboard_id"] = selectedBlockID
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": "this dashboard is only the block currently selected by the URL; if the user names a different dashboard than block_name, use +dashboard-list and match that name first, otherwise use +dashboard-get to inspect this dashboard",
|
||||
}
|
||||
case "workflow":
|
||||
out["workflow_id"] = selectedBlockID
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": "use +workflow-get to inspect the resolved workflow",
|
||||
}
|
||||
case "folder":
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": fmt.Sprintf("use +base-block-list --base-token %s --parent-id %s to list this folder's direct children", baseToken, selectedBlockID),
|
||||
}
|
||||
case "docx":
|
||||
if block.DocxToken != "" {
|
||||
out["docx_token"] = block.DocxToken
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": fmt.Sprintf("use docs +fetch --doc %s to read this document", block.DocxToken),
|
||||
}
|
||||
} else {
|
||||
out["hint"] = map[string]interface{}{
|
||||
"next_step": "use +base-block-list --type docx and match block_id to retrieve this document's docx_token",
|
||||
}
|
||||
}
|
||||
default:
|
||||
out["hint"] = resolveUnknownBlockHint()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
out["hint"] = resolveUnknownBlockHint()
|
||||
}
|
||||
|
||||
type resolvedBaseBlock struct {
|
||||
ID string
|
||||
Type string
|
||||
Name string
|
||||
DocxToken string
|
||||
}
|
||||
|
||||
func resolveSelectedBaseBlock(runtime *common.RuntimeContext, baseToken, selectedBlockID string) (resolvedBaseBlock, bool, error) {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "blocks", "list"), nil, map[string]interface{}{})
|
||||
if err != nil {
|
||||
return resolvedBaseBlock{}, false, err
|
||||
}
|
||||
for _, item := range common.GetSlice(data, "blocks") {
|
||||
row, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
block := resolvedBaseBlock{
|
||||
ID: strings.TrimSpace(common.GetString(row, "id")),
|
||||
Type: strings.TrimSpace(common.GetString(row, "type")),
|
||||
Name: strings.TrimSpace(common.GetString(row, "name")),
|
||||
DocxToken: strings.TrimSpace(common.GetString(row, "docx_token")),
|
||||
}
|
||||
if block.ID == selectedBlockID {
|
||||
return block, true, nil
|
||||
}
|
||||
}
|
||||
return resolvedBaseBlock{}, false, nil
|
||||
}
|
||||
|
||||
func enrichResolvedTable(runtime *common.RuntimeContext, out map[string]interface{}, baseToken, tableID string) {
|
||||
out["table_id"] = tableID
|
||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||
if err != nil {
|
||||
out["hint"] = resolveHint(tableID, nil)
|
||||
@@ -383,6 +513,12 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf
|
||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||
}
|
||||
|
||||
func resolveUnknownBlockHint() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"next_step": "use +base-block-list and match block_id to determine whether this is a table, dashboard, workflow, folder, or docx block",
|
||||
}
|
||||
}
|
||||
|
||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,6 +18,9 @@ import (
|
||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
t.Run("with coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
|
||||
))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
@@ -31,7 +35,7 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
if data["block_id"] != "tbl123" || data["selection_source"] != "url_query" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("missing Base coordinates: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
@@ -62,45 +66,213 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
||||
t.Run("unconfirmed selected block stays neutral", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
||||
if data["base_token"] != "bas123" || data["block_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("unconfirmed block must not be reported as a table: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("unconfirmed block must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("unconfirmed block must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList {
|
||||
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field endpoint does not confirm untyped block", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"},
|
||||
))
|
||||
fieldStub := fieldListStub("bas123", "tbl123")
|
||||
fieldStub.Optional = true
|
||||
fieldStub.OnMatch = func(_ *http.Request) {
|
||||
t.Fatalf("field endpoint must not be used to infer selected block type")
|
||||
}
|
||||
reg.Register(fieldStub)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected block coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["block_type"]; ok {
|
||||
t.Fatalf("field endpoint must not confirm block type without block directory: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("field endpoint must not promote an untyped block to table_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("untyped block must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when block type is unconfirmed: %#v", hint)
|
||||
}
|
||||
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dashboard selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_dashboard&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "blk_dashboard" || data["selection_source"] != "url_query" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" || data["block_name"] != "Sales" {
|
||||
t.Fatalf("unexpected dashboard coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("dashboard must not be reported as table_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+dashboard-get") || !strings.Contains(nextStep, "+dashboard-list") || !strings.Contains(nextStep, "different dashboard than block_name") {
|
||||
t.Fatalf("unexpected dashboard hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workflow selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "wkf_notify", "type": "workflow", "name": "Notify"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=wkf_notify&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "wkf_notify" || data["block_type"] != "workflow" || data["workflow_id"] != "wkf_notify" {
|
||||
t.Fatalf("unexpected workflow coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("workflow must not be reported as table_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("workflow must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("workflow must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if !strings.Contains(hint["next_step"].(string), "+workflow-get") {
|
||||
t.Fatalf("unexpected workflow hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("folder selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "bfl_projects", "type": "folder", "name": "Projects"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=bfl_projects&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "bfl_projects" || data["block_type"] != "folder" || data["block_name"] != "Projects" {
|
||||
t.Fatalf("unexpected folder coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("folder must not be reported as table_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+base-block-list --base-token bas123 --parent-id bfl_projects") || strings.Contains(nextStep, "determine whether") {
|
||||
t.Fatalf("unexpected folder hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("docx selected through table query key", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec", "docx_token": "docx123"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_doc&view=vew_stale&record=rec_stale", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["block_id"] != "blk_doc" || data["block_type"] != "docx" || data["block_name"] != "Spec" || data["docx_token"] != "docx123" {
|
||||
t.Fatalf("unexpected docx coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("docx must not be reported as table_id: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "docs +fetch --doc docx123") || strings.Contains(nextStep, "determine whether") {
|
||||
t.Fatalf("unexpected docx hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}) *httpmock.Stub {
|
||||
items := make([]interface{}, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
items = append(items, block)
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"blocks": items,
|
||||
"total": len(items),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
t.Run("bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bas123",
|
||||
"title": "Demo Base",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||
@@ -114,6 +286,57 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bitable with table coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
|
||||
))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew123&record=rec123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["block_id"] != "tbl123" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected Wiki Base table coordinates: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bitable with dashboard selection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||
reg.Register(baseBlockListResolveStub("bas123",
|
||||
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
|
||||
))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/wiki/wik123?table=blk_dashboard&view=vew_stale&record=rec_stale",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["block_id"] != "blk_dashboard" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" {
|
||||
t.Fatalf("unexpected Wiki Base dashboard coordinates: %#v", data)
|
||||
}
|
||||
if _, ok := data["view_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
|
||||
}
|
||||
if _, ok := data["record_id"]; ok {
|
||||
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -136,6 +359,23 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=" + wikiToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": baseToken,
|
||||
"title": title,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||
t.Run("enriched", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -50,6 +50,7 @@ type RuntimeContext struct {
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -1016,6 +1017,25 @@ func stripUTF8BOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\uFEFF")
|
||||
}
|
||||
|
||||
// InputResolvedFromSource reports whether the named flag's value was loaded
|
||||
// from an external source (@file or stdin `-`) by resolveInputFlags, as
|
||||
// opposed to typed inline on the command line. Domain guards that apply
|
||||
// shape heuristics to inline values ("this looks like a file path — did you
|
||||
// forget the @?") must skip resolved values: their content was already read
|
||||
// from the right place and may legitimately look like anything, including a
|
||||
// path. Without this bit such a guard re-rejects correct @file / stdin
|
||||
// invocations, because by the time Validate runs both arrive as plain text.
|
||||
func (ctx *RuntimeContext) InputResolvedFromSource(name string) bool {
|
||||
return ctx.inputResolved[name]
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) markInputResolved(name string) {
|
||||
if ctx.inputResolved == nil {
|
||||
ctx.inputResolved = map[string]bool{}
|
||||
}
|
||||
ctx.inputResolved[name] = true
|
||||
}
|
||||
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
@@ -1055,6 +1075,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
rctx.markInputResolved(fl.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1091,6 +1112,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
rctx.markInputResolved(fl.Name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestResolveInputFlags_DirectValue(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != "hello world" {
|
||||
t.Errorf("expected %q, got %q", "hello world", got)
|
||||
}
|
||||
if rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("inline value must not be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
@@ -55,6 +58,9 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != "content from stdin" {
|
||||
t.Errorf("expected %q, got %q", "content from stdin", got)
|
||||
}
|
||||
if !rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("stdin value should be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_File(t *testing.T) {
|
||||
@@ -75,6 +81,27 @@ func TestResolveInputFlags_File(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != content {
|
||||
t.Errorf("expected %q, got %q", content, got)
|
||||
}
|
||||
if !rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("@file value should be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInputFlags_EscapedAtStaysInline pins that the @@ escape is
|
||||
// inline content (a literal leading @), not an external source — heuristic
|
||||
// guards keyed on InputResolvedFromSource must still see it.
|
||||
func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@@handle"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("markdown"); got != "@handle" {
|
||||
t.Errorf("expected %q, got %q", "@handle", got)
|
||||
}
|
||||
if rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("escaped @@ value must not be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyFile(t *testing.T) {
|
||||
|
||||
@@ -39,6 +39,13 @@ func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, i
|
||||
return rctx
|
||||
}
|
||||
|
||||
// TestMarkInputResolved marks a flag as resolved from @file / stdin, so
|
||||
// domain tests can exercise guards that branch on InputResolvedFromSource
|
||||
// without wiring the full resolveInputFlags path.
|
||||
func TestMarkInputResolved(rctx *RuntimeContext, name string) {
|
||||
rctx.markInputResolved(name)
|
||||
}
|
||||
|
||||
// TestNewRuntimeContextForAPI creates a RuntimeContext ready for HTTP tests:
|
||||
// sets Cmd, Config, Factory, context, and the requested identity so callers
|
||||
// can invoke DoAPI / CallAPI directly without wiring through a cobra parent
|
||||
|
||||
@@ -47,6 +47,14 @@ const defaultLocateDocLimit = 10
|
||||
// with `drive file.comments create_v2` against a fresh docx.
|
||||
const maxCommentTotalRunes = 10000
|
||||
|
||||
// maxCommentReplyElements is the element-count cap declared ONLY by the
|
||||
// reply-create endpoint (POST .../comments/:comment_id/replies), whose
|
||||
// content.elements schema says "最大元素个数为100". It is enforced only by
|
||||
// +add-reply. create_v2 (+add-comment) and the reply-update endpoint
|
||||
// (+update-reply) do not declare this cap, so their inputs are not capped
|
||||
// here — see the shared parseCommentReplyElements, which stays uncapped.
|
||||
const maxCommentReplyElements = 100
|
||||
|
||||
// The file comment API treats supported Drive file comments as full-file
|
||||
// comments in the UI, but currently rejects an empty anchor.block_id for file
|
||||
// targets. TODO: remove this placeholder after the API accepts omitting
|
||||
|
||||
@@ -918,6 +918,27 @@ func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// create_v2 (+add-comment) uses reply_elements, which does NOT declare the
|
||||
// 100-element cap that the reply-create endpoint does; +add-comment must not
|
||||
// reject >100 elements locally.
|
||||
func TestDriveAddCommentDoesNotCapElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 101)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--full-comment",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("+add-comment must not cap element count locally, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
|
||||
212
shortcuts/drive/drive_add_reply.go
Normal file
212
shortcuts/drive/drive_add_reply.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveAddReplyOp = driveCommentOp{
|
||||
Label: "comment reply",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveAddReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
|
||||
}
|
||||
|
||||
func (s driveAddReplySpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"elements": driveReplyV1Elements(s.ReplyElements),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveAddReply replies to an existing comment through the Drive comment
|
||||
// reply create API (POST .../comments/:comment_id/replies), while accepting
|
||||
// Wiki URLs/tokens and resolving them to the underlying object.
|
||||
//
|
||||
// Note: the documented alternative — POST .../comments with comment_id in the
|
||||
// body ("如填写,则视为回复已有评论") — does NOT reply on docx in practice; it
|
||||
// silently creates a new standalone comment instead.
|
||||
var DriveAddReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-reply",
|
||||
Description: "Add a reply to an existing comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:create"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveAddReplyOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID to reply to (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
),
|
||||
Tips: []string{
|
||||
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
"Whole-document comments (is_whole=true) and solved comments (is_solved=true) do not accept replies; check the comment state via `drive +list-comments` first.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveAddReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveAddReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveAddReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveAddReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveAddReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Adding reply to comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
)
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extra := map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"created": true,
|
||||
}
|
||||
if replyID := extractDriveCreatedReplyID(data); replyID != "" {
|
||||
extra["reply_id"] = replyID
|
||||
}
|
||||
runtime.Out(driveCommentTargetOutput(target, extra), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveAddReplySpec(runtime *common.RuntimeContext) (driveAddReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveAddReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveAddReplySpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveAddReplySpec{}, err
|
||||
}
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return driveAddReplySpec{}, err
|
||||
}
|
||||
// The reply-create endpoint documents a 100-element cap on content.elements;
|
||||
// reject over-cap input locally instead of surfacing the opaque [1069302].
|
||||
if len(replyElements) > maxCommentReplyElements {
|
||||
return driveAddReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--content has %d elements; the reply endpoint caps content.elements at %d", len(replyElements), maxCommentReplyElements).
|
||||
WithParam("--content")
|
||||
}
|
||||
return driveAddReplySpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
ReplyElements: replyElements,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// driveReplyV1Elements converts the simplified +add-comment reply element form
|
||||
// (text / mention_user / link) to the Drive v1 comment create wire form
|
||||
// (text_run / person / docs_link).
|
||||
func driveReplyV1Elements(replyElements []map[string]interface{}) []map[string]interface{} {
|
||||
elements := make([]map[string]interface{}, 0, len(replyElements))
|
||||
for _, element := range replyElements {
|
||||
switch common.GetString(element, "type") {
|
||||
case "text":
|
||||
elements = append(elements, map[string]interface{}{
|
||||
"type": "text_run",
|
||||
"text_run": map[string]interface{}{"text": common.GetString(element, "text")},
|
||||
})
|
||||
case "mention_user":
|
||||
elements = append(elements, map[string]interface{}{
|
||||
"type": "person",
|
||||
"person": map[string]interface{}{"user_id": common.GetString(element, "mention_user")},
|
||||
})
|
||||
case "link":
|
||||
elements = append(elements, map[string]interface{}{
|
||||
"type": "docs_link",
|
||||
"docs_link": map[string]interface{}{"url": common.GetString(element, "link")},
|
||||
})
|
||||
}
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
// extractDriveCreatedReplyID pulls the created reply ID out of the reply
|
||||
// create response, tolerating the shapes the API family uses: a top-level
|
||||
// reply_id, a nested reply object, or a reply_list wrapper.
|
||||
func extractDriveCreatedReplyID(data map[string]interface{}) string {
|
||||
if replyID := common.GetString(data, "reply_id"); replyID != "" {
|
||||
return replyID
|
||||
}
|
||||
if reply := common.GetMap(data, "reply"); reply != nil {
|
||||
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
|
||||
return replyID
|
||||
}
|
||||
}
|
||||
replyList := common.GetMap(data, "reply_list")
|
||||
if replyList == nil {
|
||||
return ""
|
||||
}
|
||||
for _, item := range common.GetSlice(replyList, "replies") {
|
||||
reply, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
|
||||
return replyID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildDriveAddReplyDryRun(spec driveAddReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> add reply to comment").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
|
||||
Desc("[2] Add reply to comment on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: add reply to comment").
|
||||
POST("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
393
shortcuts/drive/drive_add_reply_test.go
Normal file
393
shortcuts/drive/drive_add_reply_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveReplyV1Elements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
elements, err := parseCommentReplyElements(`[
|
||||
{"type":"text","text":"a<b"},
|
||||
{"type":"mention_user","mention_user":"ou_123"},
|
||||
{"type":"link","link":"https://example.com"}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := driveReplyV1Elements(elements)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0]["type"] != "text_run" {
|
||||
t.Fatalf("elements[0].type = %#v, want text_run", got[0]["type"])
|
||||
}
|
||||
textRun, ok := got[0]["text_run"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("elements[0].text_run is %T, want map", got[0]["text_run"])
|
||||
}
|
||||
if textRun["text"] != "a<b" {
|
||||
t.Fatalf("elements[0].text_run.text = %#v, want escaped a<b", textRun["text"])
|
||||
}
|
||||
person, ok := got[1]["person"].(map[string]interface{})
|
||||
if !ok || got[1]["type"] != "person" {
|
||||
t.Fatalf("elements[1] = %#v, want person element", got[1])
|
||||
}
|
||||
if person["user_id"] != "ou_123" {
|
||||
t.Fatalf("elements[1].person.user_id = %#v, want ou_123", person["user_id"])
|
||||
}
|
||||
docsLink, ok := got[2]["docs_link"].(map[string]interface{})
|
||||
if !ok || got[2]["type"] != "docs_link" {
|
||||
t.Fatalf("elements[2] = %#v, want docs_link element", got[2])
|
||||
}
|
||||
if docsLink["url"] != "https://example.com" {
|
||||
t.Fatalf("elements[2].docs_link.url = %#v, want https://example.com", docsLink["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"reply": map[string]interface{}{
|
||||
"reply_id": "reply_9",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"收到,我来处理"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if _, ok := body["comment_id"]; ok {
|
||||
t.Fatalf("request body must not carry comment_id (it rides in the URL path): %v", body)
|
||||
}
|
||||
content := mustMapValue(t, body["content"], "request.content")
|
||||
elements := mustSliceValue(t, content["elements"], "request.content.elements")
|
||||
element := mustMapValue(t, elements[0], "request.content.elements[0]")
|
||||
if got := mustStringField(t, element, "type", "request.content.elements[0].type"); got != "text_run" {
|
||||
t.Fatalf("request element type = %q, want text_run", got)
|
||||
}
|
||||
elementText := mustMapValue(t, element["text_run"], "request.content.elements[0].text_run")
|
||||
if got := mustStringField(t, elementText, "text", "request.content.elements[0].text_run.text"); got != "收到,我来处理" {
|
||||
t.Fatalf("text_run.text = %q, want 收到,我来处理", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_9" {
|
||||
t.Fatalf("reply_id = %q, want reply_9", got)
|
||||
}
|
||||
if got := data["created"]; got != true {
|
||||
t.Fatalf("created = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyExecuteWikiResolvesToDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply from wiki"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWiki" {
|
||||
t.Fatalf("file_token = %q, want docxFromWiki", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
if _, ok := data["reply_id"]; ok {
|
||||
t.Fatalf("reply_id should be omitted when the response carries none: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyRejectsUnsupportedTargets(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported --url resource type "folder"`) {
|
||||
t.Fatalf("expected unsupported-type error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestDriveAddReplyWikiResolvesToUnsupported(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "mindnote",
|
||||
"obj_token": "mindnoteFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote", but comment reply only supports`) {
|
||||
t.Fatalf("expected wiki-resolution error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestExtractDriveCreatedReplyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "nil data", data: nil, want: ""},
|
||||
{name: "top-level reply_id", data: map[string]interface{}{"reply_id": "r1"}, want: "r1"},
|
||||
{name: "nested reply object", data: map[string]interface{}{"reply": map[string]interface{}{"reply_id": "r2"}}, want: "r2"},
|
||||
{name: "nested reply without id falls through", data: map[string]interface{}{"reply": map[string]interface{}{}}, want: ""},
|
||||
{
|
||||
name: "reply_list wrapper",
|
||||
data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{
|
||||
"not-a-map",
|
||||
map[string]interface{}{"reply_id": ""},
|
||||
map[string]interface{}{"reply_id": "r3"},
|
||||
}}},
|
||||
want: "r3",
|
||||
},
|
||||
{name: "reply_list without match", data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{map[string]interface{}{}}}}, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := extractDriveCreatedReplyID(tt.data); got != tt.want {
|
||||
t.Fatalf("extractDriveCreatedReplyID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyRejectsUnsafeCommentID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("expected comment-id validation error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--comment-id")
|
||||
}
|
||||
|
||||
func TestDriveAddReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "comment not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "comment not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
|
||||
t.Fatalf("api[1].url = %q, want replies URL with comment ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyInvalidContent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `not-json`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") {
|
||||
t.Fatalf("expected content JSON error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyRejectsTooManyElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 101)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "caps content.elements at 100") {
|
||||
t.Fatalf("expected 100-element cap error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--content")
|
||||
}
|
||||
|
||||
func TestDriveAddReplyAcceptsMaxElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 100)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("100 elements should be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddReply, []string{
|
||||
"+add-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--content", `[{"type":"text","text":"reply"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
|
||||
t.Fatalf("api[0].url = %q, want reply create URL with comment ID", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if _, ok := body["comment_id"]; ok {
|
||||
t.Fatalf("api[0].body must not carry comment_id: %v", body)
|
||||
}
|
||||
content := mustMapValue(t, body["content"], "api[0].body.content")
|
||||
if _, ok := content["elements"]; !ok {
|
||||
t.Fatalf("api[0].body.content.elements missing: %v", body)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -13,72 +14,137 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// permApplyTypes is the authoritative list of type values the apply-permission
|
||||
// endpoint accepts for its required `type` query parameter.
|
||||
var permApplyTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "slides",
|
||||
type permApplyResourceKind struct {
|
||||
Type string
|
||||
Path string
|
||||
}
|
||||
|
||||
// permApplyURLMarkers maps document URL path markers to the `type` value the
|
||||
// apply-permission endpoint expects. Markers are disjoint strings (each begins
|
||||
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
|
||||
var permApplyURLMarkers = []struct {
|
||||
Marker string
|
||||
Type string
|
||||
}{
|
||||
{"/wiki/", "wiki"},
|
||||
{"/docx/", "docx"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnote/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/doc/", "doc"},
|
||||
// permApplyResourceKinds is the authoritative target contract for the
|
||||
// apply-permission endpoint: accepted types and their URL root paths.
|
||||
var permApplyResourceKinds = []permApplyResourceKind{
|
||||
{Type: "doc", Path: "/doc/"},
|
||||
{Type: "sheet", Path: "/sheets/"},
|
||||
{Type: "file", Path: "/file/"},
|
||||
{Type: "wiki", Path: "/wiki/"},
|
||||
{Type: "bitable", Path: "/base/"},
|
||||
{Type: "bitable", Path: "/bitable/"},
|
||||
{Type: "docx", Path: "/docx/"},
|
||||
{Type: "mindnote", Path: "/mindnote/"},
|
||||
{Type: "slides", Path: "/slides/"},
|
||||
{Type: "apps", Path: "/page/"},
|
||||
}
|
||||
|
||||
var permApplyTypes = func() []string {
|
||||
types := make([]string, 0, len(permApplyResourceKinds))
|
||||
seen := make(map[string]struct{}, len(permApplyResourceKinds))
|
||||
for _, resourceKind := range permApplyResourceKinds {
|
||||
if _, ok := seen[resourceKind.Type]; ok {
|
||||
continue
|
||||
}
|
||||
seen[resourceKind.Type] = struct{}{}
|
||||
types = append(types, resourceKind.Type)
|
||||
}
|
||||
return types
|
||||
}()
|
||||
|
||||
func permApplyTypeAllowed(docType string) bool {
|
||||
for _, allowedType := range permApplyTypes {
|
||||
if docType == allowedType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
|
||||
// value that may be either a bare token or a full document URL, plus an
|
||||
// optional explicit --type. Explicit --type wins over URL inference.
|
||||
// optional explicit --type. A URL's path and explicit --type must agree.
|
||||
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && !permApplyTypeAllowed(explicitType) {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(permApplyTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
for _, m := range permApplyURLMarkers {
|
||||
if tok, ok := extractURLToken(raw, m.Marker); ok {
|
||||
token = tok
|
||||
if explicitType == "" {
|
||||
docType = m.Type
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
ref, ok := parsePermApplyResourceURL(raw)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/, /page/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
token, docType = ref.Token, ref.Type
|
||||
if explicitType != "" && explicitType != docType {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
docType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
} else {
|
||||
token = raw
|
||||
}
|
||||
|
||||
if explicitType != "" {
|
||||
docType = explicitType
|
||||
}
|
||||
|
||||
if docType == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(permApplyTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validatePermApplyToken(token); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return token, docType, nil
|
||||
}
|
||||
|
||||
func parsePermApplyResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
escapedPath := parsed.EscapedPath()
|
||||
for _, resourceKind := range permApplyResourceKinds {
|
||||
if !strings.HasPrefix(escapedPath, resourceKind.Path) {
|
||||
continue
|
||||
}
|
||||
escapedToken := strings.TrimSuffix(strings.TrimPrefix(escapedPath, resourceKind.Path), "/")
|
||||
if escapedToken == "" || strings.Contains(escapedToken, "/") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
token, err := url.PathUnescape(escapedToken)
|
||||
if err != nil || token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: resourceKind.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func validatePermApplyToken(token string) error {
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
if token == "." || strings.Contains(token, "/") {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token must be a non-dot single path segment",
|
||||
).WithParam("--token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DriveApplyPermission applies to the document owner for view or edit access
|
||||
// on behalf of the invoking user. Matches the open-apis endpoint
|
||||
// /open-apis/drive/v1/permissions/:token/members/apply.
|
||||
@@ -88,16 +154,19 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
|
||||
var DriveApplyPermission = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+apply-permission",
|
||||
Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides",
|
||||
Description: "Apply to the owner for view or edit permission on a Drive resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:permission.member:apply"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "token", Desc: "target token or URL (docx/sheets/base/file/wiki/doc/mindnote/slides/page)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: permApplyTypes},
|
||||
{Name: "perm", Desc: "permission to request", Required: true, Enum: []string{"view", "edit"}},
|
||||
{Name: "remark", Desc: "optional note shown on the request card sent to the owner"},
|
||||
},
|
||||
Tips: []string{
|
||||
"When --token is a URL, its path determines --type; a conflicting --type is rejected.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
return err
|
||||
@@ -109,7 +178,7 @@ var DriveApplyPermission = common.Shortcut{
|
||||
}
|
||||
body := buildPermApplyBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Apply to document owner for access").
|
||||
Desc("Apply to resource owner for access").
|
||||
POST("/open-apis/drive/v1/permissions/:token/members/apply").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(body).
|
||||
@@ -131,7 +200,7 @@ var DriveApplyPermission = common.Shortcut{
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return decoratePermApplyError(err)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
@@ -148,3 +217,34 @@ func buildPermApplyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func decoratePermApplyError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
guidance := permApplyErrorGuidance(problem.Code)
|
||||
if guidance == "" {
|
||||
return err
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
problem.Hint = guidance
|
||||
} else if !strings.Contains(problem.Hint, guidance) {
|
||||
problem.Hint += "; " + guidance
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func permApplyErrorGuidance(code int) string {
|
||||
switch code {
|
||||
case 1063006:
|
||||
return "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait for the daily quota to reset before retrying"
|
||||
case 1063007:
|
||||
return "this document does not accept a permission-apply request; verify the target and requested permission, or contact the owner directly"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
@@ -33,6 +35,18 @@ func TestResolvePermApplyTarget_BareTokenWithType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_BareTokenWithAppsType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolvePermApplyTarget("appBareToken", "apps")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "appBareToken" || docType != "apps" {
|
||||
t.Fatalf("got token=%q type=%q, want appBareToken/apps", token, docType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_URLInference(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
@@ -50,6 +64,7 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
|
||||
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
|
||||
{"mindnote", "https://example.feishu.cn/mindnote/mnTok444", "mnTok444", "mindnote"},
|
||||
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
|
||||
{"apps page", "https://example.feishu.cn/page/appMetaTok/?from=share", "appMetaTok", "apps"},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
@@ -66,15 +81,100 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_ExplicitTypeOverridesURL(t *testing.T) {
|
||||
func TestResolvePermApplyTarget_RejectsMalformedPageURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Even though the URL marker is /docx/, an explicit --type wins.
|
||||
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/page/?from=share", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "could not infer token") {
|
||||
t.Fatalf("expected page token inference error, got token=%q type=%q error=%v", token, docType, err)
|
||||
}
|
||||
if token != "doxTok123" || docType != "wiki" {
|
||||
t.Fatalf("got (%q,%q), want (doxTok123,wiki)", token, docType)
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_RejectsAppsMarkerOutsidePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{
|
||||
name: "query",
|
||||
raw: "https://example.feishu.cn/share?redirect=/page/appMetaTok",
|
||||
},
|
||||
{
|
||||
name: "fragment",
|
||||
raw: "https://example.feishu.cn/share#/page/appMetaTok",
|
||||
},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolvePermApplyTarget(tt.raw, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected URL path inference error, got token=%q type=%q", token, docType)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error category/subtype = %q/%q, want %q/%q",
|
||||
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--token" {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, "--token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_RejectsConflictingURLType(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicts with URL path type") {
|
||||
t.Fatalf("expected URL type conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePermApplyTarget_RejectsUnsafeOrAmbiguousTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
type_ string
|
||||
}{
|
||||
{"bare traversal token", "..", "docx"},
|
||||
{"bare dot token", ".", "docx"},
|
||||
{"URL traversal token", "https://example.feishu.cn/docx/../victim", ""},
|
||||
{"marker outside resource root", "https://example.feishu.cn/share/docx/doxUnexpected", ""},
|
||||
{"encoded path separator", "https://example.feishu.cn/docx/doxTarget%2Fother", ""},
|
||||
{"encoded query separator", "https://example.feishu.cn/docx/doxTarget%3Fother", ""},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := resolvePermApplyTarget(tt.raw, tt.type_)
|
||||
if err == nil {
|
||||
t.Fatalf("resolvePermApplyTarget(%q, %q) unexpectedly succeeded", tt.raw, tt.type_)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--token" {
|
||||
t.Fatalf("error param = %q, want --token", validationErr.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +250,33 @@ func TestDriveApplyPermission_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_DryRunAcceptsAppsBareToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--token", "appBareToken",
|
||||
"--type", "apps",
|
||||
"--perm", "edit",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v1/permissions/appBareToken/members/apply",
|
||||
`"apps"`,
|
||||
`"edit"`,
|
||||
`"appBareToken"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
// Stub URL includes "?type=docx" — the stub only matches when the request
|
||||
@@ -196,6 +323,11 @@ func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063007, "msg": "request not applicable",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server says requests are disabled"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -212,6 +344,18 @@ func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "not applicable") {
|
||||
t.Fatalf("expected surfaced server message, got: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeInvalidParameters || problem.Code != 1063007 {
|
||||
t.Fatalf("problem = %+v, want api/invalid_parameters code 1063007", problem)
|
||||
}
|
||||
for _, want := range []string{"server says requests are disabled", "does not accept a permission-apply request", "contact the owner"} {
|
||||
if !strings.Contains(problem.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, problem.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
|
||||
@@ -235,4 +379,17 @@ func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 1063006")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeRateLimit || problem.Code != 1063006 {
|
||||
t.Fatalf("problem = %+v, want api/rate_limit code 1063006", problem)
|
||||
}
|
||||
if problem.Retryable {
|
||||
t.Fatalf("problem.Retryable = true, want false for the daily per-document quota")
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "at most 5 times per day") {
|
||||
t.Fatalf("hint missing daily quota guidance: %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
175
shortcuts/drive/drive_batch_query_comments.go
Normal file
175
shortcuts/drive/drive_batch_query_comments.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// driveBatchQueryCommentsMaxIDs mirrors the server-side cap on comment_ids
|
||||
// per batch_query call.
|
||||
const driveBatchQueryCommentsMaxIDs = 100
|
||||
|
||||
var driveBatchQueryCommentsOp = driveCommentOp{
|
||||
Label: "comments batch query",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveBatchQueryCommentsSpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentIDs []string
|
||||
NeedReaction bool
|
||||
NeedRelation bool
|
||||
}
|
||||
|
||||
// RequestBody assembles the batch_query body for the resolved fileType.
|
||||
// need_relation is absent from the platform metadata for this endpoint but
|
||||
// honored live (same undocumented parameter +list-comments already uses);
|
||||
// only docx returns relation data, so it is sent for docx targets only.
|
||||
func (s driveBatchQueryCommentsSpec) RequestBody(fileType string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"comment_ids": s.CommentIDs,
|
||||
}
|
||||
if s.NeedReaction {
|
||||
body["need_reaction"] = true
|
||||
}
|
||||
if s.NeedRelation && fileType == "docx" {
|
||||
body["need_relation"] = true
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// DriveBatchQueryComments fetches comments by ID through the Drive comment
|
||||
// batch_query API, while accepting Wiki URLs/tokens and resolving them to the
|
||||
// underlying object.
|
||||
var DriveBatchQueryComments = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+batch-query-comments",
|
||||
Description: "Batch get comments by comment ID for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.comment:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveBatchQueryCommentsOp),
|
||||
common.Flag{Name: "comment-ids", Type: "string_slice", Desc: fmt.Sprintf("comment IDs to fetch (comma-separated or repeated flag, max %d)", driveBatchQueryCommentsMaxIDs), Required: true},
|
||||
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
|
||||
common.Flag{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
|
||||
),
|
||||
Tips: []string{
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
"--comment-ids accepts comma-separated values and repeated flags, up to 100 IDs per call.",
|
||||
"--need-relation returns the docx comment anchor (items[].relation with the block position); see the lark-drive comment-location guide.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveBatchQueryCommentsSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveBatchQueryCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveBatchQueryCommentsDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveBatchQueryCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveBatchQueryCommentsOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Batch querying %d comment(s) in %s...\n", len(spec.CommentIDs), common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments/batch_query", validate.EncodePathSegment(target.FileToken))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(target.FileType),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := driveCommentItems(data)
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"items": items,
|
||||
"count": len(items),
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveBatchQueryCommentsSpec(runtime *common.RuntimeContext) (driveBatchQueryCommentsSpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveBatchQueryCommentsOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveBatchQueryCommentsSpec{}, err
|
||||
}
|
||||
ids, err := normalizeDriveCommentIDs(runtime.StrSlice("comment-ids"))
|
||||
if err != nil {
|
||||
return driveBatchQueryCommentsSpec{}, err
|
||||
}
|
||||
return driveBatchQueryCommentsSpec{
|
||||
Ref: ref,
|
||||
CommentIDs: ids,
|
||||
NeedReaction: runtime.Bool("need-reaction"),
|
||||
NeedRelation: runtime.Bool("need-relation"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeDriveCommentIDs(raw []string) ([]string, error) {
|
||||
ids := make([]string, 0, len(raw))
|
||||
for i, id := range raw {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids element #%d is empty", i+1).WithParam("--comment-ids")
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids must contain at least one comment ID").WithParam("--comment-ids")
|
||||
}
|
||||
if len(ids) > driveBatchQueryCommentsMaxIDs {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids accepts at most %d comment IDs per call (got %d)", driveBatchQueryCommentsMaxIDs, len(ids)).WithParam("--comment-ids")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func buildDriveBatchQueryCommentsDryRun(spec driveBatchQueryCommentsSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
// The wiki obj_type is unknown until step 1 resolves, so RequestBody
|
||||
// cannot decide the docx-only need_relation gate here; surface it as a
|
||||
// placeholder the same way +list-comments does.
|
||||
body := spec.RequestBody("<obj_type from step 1>")
|
||||
if spec.NeedRelation {
|
||||
body["need_relation"] = "<sent only when obj_type is docx>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> batch query comments").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/batch_query").
|
||||
Desc("[2] Batch query comments on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: batch query comments").
|
||||
POST("/open-apis/drive/v1/files/:file_token/comments/batch_query").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody(spec.Ref.Type)).
|
||||
Set("file_token", spec.Ref.Token)
|
||||
}
|
||||
560
shortcuts/drive/drive_batch_query_comments_test.go
Normal file
560
shortcuts/drive/drive_batch_query_comments_test.go
Normal file
@@ -0,0 +1,560 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestNormalizeDriveCommentIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := normalizeDriveCommentIDs([]string{" c1 ", "c2"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "c1" || got[1] != "c2" {
|
||||
t.Fatalf("normalizeDriveCommentIDs = %v, want [c1 c2]", got)
|
||||
}
|
||||
|
||||
if _, err := normalizeDriveCommentIDs(nil); err == nil || !strings.Contains(err.Error(), "at least one") {
|
||||
t.Fatalf("expected at-least-one error, got %v", err)
|
||||
}
|
||||
if _, err := normalizeDriveCommentIDs([]string{"c1", " "}); err == nil || !strings.Contains(err.Error(), "element #2 is empty") {
|
||||
t.Fatalf("expected empty-element error, got %v", err)
|
||||
}
|
||||
|
||||
tooMany := make([]string, driveBatchQueryCommentsMaxIDs+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("c%d", i)
|
||||
}
|
||||
_, err = normalizeDriveCommentIDs(tooMany)
|
||||
if err == nil || !strings.Contains(err.Error(), "at most 100") {
|
||||
t.Fatalf("expected max-IDs error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--comment-ids")
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"comment_id": "comment_1", "is_solved": false},
|
||||
{"comment_id": "comment_2", "is_solved": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1,comment_2",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
ids := mustSliceValue(t, body["comment_ids"], "request.comment_ids")
|
||||
if len(ids) != 2 || ids[0] != "comment_1" || ids[1] != "comment_2" {
|
||||
t.Fatalf("request comment_ids = %v, want [comment_1 comment_2]", ids)
|
||||
}
|
||||
if _, ok := body["need_reaction"]; ok {
|
||||
t.Fatalf("request should omit need_reaction by default: %v", body)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
|
||||
t.Fatalf("file_token = %q, want docxResource", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := data["count"]; got != float64(2) {
|
||||
t.Fatalf("count = %#v, want 2", got)
|
||||
}
|
||||
if _, ok := data["wiki_token"]; ok {
|
||||
t.Fatalf("wiki_token should be omitted for direct targets: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteWikiWithReaction(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("token"); got != "wikiResource" {
|
||||
t.Errorf("wiki token = %q, want wikiResource", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "sheetFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "sheet" {
|
||||
t.Errorf("file_type = %q, want sheet", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-reaction",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["need_reaction"]; got != true {
|
||||
t.Fatalf("request need_reaction = %#v, want true", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "sheetFromWiki" {
|
||||
t.Fatalf("file_token = %q, want sheetFromWiki", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
|
||||
t.Fatalf("file_type = %q, want sheet", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteAppsPageURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/appsPageResource/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "apps" {
|
||||
t.Errorf("file_type = %q, want apps", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.feishu.cn/page/appsPageResource/",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "apps" {
|
||||
t.Fatalf("file_type = %q, want apps", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "appsPageResource" {
|
||||
t.Fatalf("file_token = %q, want appsPageResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteBaseURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/baseResource/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/base/baseResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("file_type = %q, want bitable", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsWikiResolvesToUnsupported(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "mindnote",
|
||||
"obj_token": "mindnoteToken",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote"`) {
|
||||
t.Fatalf("expected wiki-resolution error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsExecuteBaseAliasType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/baseToken/comments/batch_query",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable (base alias normalized)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []map[string]interface{}{}},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--token", "baseToken",
|
||||
"--type", "base",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
args: []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--token", "docxResource",
|
||||
"--comment-ids", "comment_1",
|
||||
},
|
||||
wantErr: "mutually exclusive",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "blank comment id element",
|
||||
args: []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", " ",
|
||||
},
|
||||
wantErr: "element #1 is empty",
|
||||
wantParam: "--comment-ids",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "comment not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_404",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "comment not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1,comment_2",
|
||||
"--need-reaction",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/batch_query") {
|
||||
t.Fatalf("api[0].url = %q, want resolved batch_query URL", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if got := body["need_reaction"]; got != true {
|
||||
t.Fatalf("api[0].body.need_reaction = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
|
||||
t.Fatalf("api[1].method = %q, want POST", got)
|
||||
}
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
ids := mustSliceValue(t, body["comment_ids"], "api[1].body.comment_ids")
|
||||
if len(ids) != 1 || ids[0] != "comment_1" {
|
||||
t.Fatalf("api[1].body.comment_ids = %v, want [comment_1]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsDryRunWikiNeedRelation(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-relation",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := body["need_relation"]; got != "<sent only when obj_type is docx>" {
|
||||
t.Fatalf("api[1].body.need_relation = %#v, want conditional placeholder", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsOmittedItemsNormalized(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("len(items) = %d, want 0", len(items))
|
||||
}
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsNeedRelationDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []interface{}{}},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-relation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["need_relation"]; got != true {
|
||||
t.Fatalf("request need_relation = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveBatchQueryCommentsNeedRelationIgnoredForNonDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/sheetResource/comments/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []interface{}{}},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
|
||||
"+batch-query-comments",
|
||||
"--url", "https://example.larksuite.com/sheets/sheetResource",
|
||||
"--comment-ids", "comment_1",
|
||||
"--need-relation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if _, ok := body["need_relation"]; ok {
|
||||
t.Fatalf("need_relation must be omitted for non-docx targets: %v", body)
|
||||
}
|
||||
}
|
||||
246
shortcuts/drive/drive_comment_common.go
Normal file
246
shortcuts/drive/drive_comment_common.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// driveCommentOp describes one comment-family shortcut for the shared
|
||||
// --url/--token/--type input resolution. Label appears in error messages;
|
||||
// Types lists the wire file_type values the underlying endpoint accepts.
|
||||
// Wiki URLs/tokens are always accepted as input and unwrapped to the
|
||||
// underlying document, which must then land in Types.
|
||||
type driveCommentOp struct {
|
||||
Label string
|
||||
Types []string
|
||||
}
|
||||
|
||||
func (op driveCommentOp) supports(fileType string) bool {
|
||||
return slices.Contains(op.Types, fileType)
|
||||
}
|
||||
|
||||
// inputTypeList renders the values accepted as input (wire types plus wiki).
|
||||
func (op driveCommentOp) inputTypeList() string {
|
||||
return strings.Join(op.flagEnum(), ", ")
|
||||
}
|
||||
|
||||
// targetTypeList renders the wire types the endpoint accepts (wiki excluded).
|
||||
func (op driveCommentOp) targetTypeList() string {
|
||||
return strings.Join(op.Types, ", ")
|
||||
}
|
||||
|
||||
// flagEnum returns the Enum set for the --type flag: the endpoint's wire
|
||||
// types plus wiki (resolved to a wire type before the API call) and the
|
||||
// base product-name alias when bitable is supported (normalized to bitable).
|
||||
func (op driveCommentOp) flagEnum() []string {
|
||||
enum := make([]string, 0, len(op.Types)+2)
|
||||
for _, t := range op.Types {
|
||||
enum = append(enum, t)
|
||||
if t == "bitable" {
|
||||
enum = append(enum, "base")
|
||||
}
|
||||
}
|
||||
return append(enum, "wiki")
|
||||
}
|
||||
|
||||
// driveCommentRef is the parsed --url/--token/--type input before wiki unwrapping.
|
||||
type driveCommentRef struct {
|
||||
Token string
|
||||
Type string
|
||||
SourceFlag string
|
||||
}
|
||||
|
||||
// driveCommentTarget is the underlying document a comment API call targets.
|
||||
type driveCommentTarget struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
WikiToken string // non-empty when the input was a wiki node
|
||||
}
|
||||
|
||||
// driveCommentTargetFlags returns the shared --url/--token/--type flag trio
|
||||
// used by the comment-family shortcuts that resolve a document target.
|
||||
func driveCommentTargetFlags(op driveCommentOp) []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "url", Desc: fmt.Sprintf("recommended: Lark/Feishu document URL (%s); Wiki URLs are unwrapped automatically", op.inputTypeList())},
|
||||
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
|
||||
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: op.flagEnum()},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDriveCommentInput parses --url/--token/--type into a driveCommentRef,
|
||||
// mirroring +list-comments input handling: --url and --token are mutually
|
||||
// exclusive, URLs are parsed for type+token, bare tokens require --type, and
|
||||
// wiki is always accepted for later unwrapping.
|
||||
func resolveDriveCommentInput(op driveCommentOp, urlInput, tokenInput, explicitType string) (driveCommentRef, error) {
|
||||
urlInput = strings.TrimSpace(urlInput)
|
||||
tokenInput = strings.TrimSpace(tokenInput)
|
||||
if urlInput != "" && tokenInput != "" {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
|
||||
}
|
||||
if urlInput == "" && tokenInput == "" {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
|
||||
}
|
||||
|
||||
raw := urlInput
|
||||
sourceFlag := "--url"
|
||||
if raw == "" {
|
||||
raw = tokenInput
|
||||
sourceFlag = "--token"
|
||||
}
|
||||
inputType := normalizeDriveCommentType(strings.ToLower(strings.TrimSpace(explicitType)))
|
||||
|
||||
if ref, ok := common.ParseResourceURL(raw); ok {
|
||||
refType := normalizeDriveCommentType(ref.Type)
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if refType != "wiki" && !op.supports(refType) {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; %s supports %s",
|
||||
sourceFlag,
|
||||
refType,
|
||||
op.Label,
|
||||
op.inputTypeList(),
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveCommentRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if token, ok := parseDriveListCommentsAppsURL(raw); ok {
|
||||
const refType = "apps"
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if !op.supports(refType) {
|
||||
return driveCommentRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; %s supports %s",
|
||||
sourceFlag,
|
||||
refType,
|
||||
op.Label,
|
||||
op.inputTypeList(),
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveCommentRef{Token: token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
|
||||
}
|
||||
if inputType == "" {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: %s)", sourceFlag, op.inputTypeList()).WithParam("--type")
|
||||
}
|
||||
if inputType != "wiki" && !op.supports(inputType) {
|
||||
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: %s", inputType, op.inputTypeList()).WithParam("--type")
|
||||
}
|
||||
return driveCommentRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
// normalizeDriveCommentType maps compatibility aliases to wire values
|
||||
// (base → bitable) so type checks and error messages use one vocabulary.
|
||||
func normalizeDriveCommentType(docType string) string {
|
||||
switch strings.TrimSpace(docType) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.TrimSpace(docType)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDriveCommentTarget unwraps wiki refs to the underlying document via
|
||||
// wiki get_node and validates the resolved type against op.Types.
|
||||
func resolveDriveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, op driveCommentOp, ref driveCommentRef) (driveCommentTarget, error) {
|
||||
if ref.Type != "wiki" {
|
||||
return driveCommentTarget{FileToken: ref.Token, FileType: ref.Type}, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": ref.Token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveCommentTarget{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveCommentType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return driveCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "wiki" || !op.supports(objType) {
|
||||
return driveCommentTarget{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but %s only supports %s",
|
||||
objType,
|
||||
op.Label,
|
||||
op.targetTypeList(),
|
||||
).WithParam(ref.SourceFlag)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return driveCommentTarget{FileToken: objToken, FileType: objType, WikiToken: ref.Token}, nil
|
||||
}
|
||||
|
||||
// validateDriveCommentPathID validates a comment/reply identifier destined
|
||||
// for a URL path segment.
|
||||
func validateDriveCommentPathID(value, flagName string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must not be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if err := validate.ResourceName(strings.TrimSpace(value), flagName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveCommentItems extracts data.items for output, normalizing a missing or
|
||||
// null field to an empty slice: emitting the server's shape verbatim would
|
||||
// surface "items": null, which breaks jq consumers iterating .data.items[].
|
||||
func driveCommentItems(data map[string]interface{}) []interface{} {
|
||||
if items := common.GetSlice(data, "items"); items != nil {
|
||||
return items
|
||||
}
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// driveCommentTargetOutput assembles the output fields shared by the
|
||||
// comment-family shortcuts: the resolved target plus the wiki origin, if any.
|
||||
func driveCommentTargetOutput(target driveCommentTarget, extra map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": target.FileType,
|
||||
}
|
||||
if target.WikiToken != "" {
|
||||
out["wiki_token"] = target.WikiToken
|
||||
}
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
268
shortcuts/drive/drive_comment_common_test.go
Normal file
268
shortcuts/drive/drive_comment_common_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func assertDriveCommentValidationError(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
// assertDriveCommentAPIError asserts the error kept the typed API contract:
|
||||
// CallAPITyped errors must reach the caller unchanged, message-only checks
|
||||
// would still pass if a refactor wrapped them into untyped errors.
|
||||
func assertDriveCommentAPIError(t *testing.T, err error, wantCode int) {
|
||||
t.Helper()
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
if problem.Subtype == "" {
|
||||
t.Fatalf("subtype is empty, want populated")
|
||||
}
|
||||
if problem.Code != wantCode {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, wantCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDriveCommentInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "sheet", "file", "slides"}}
|
||||
docOnlyOp := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
op driveCommentOp
|
||||
urlInput string
|
||||
rawInput string
|
||||
docType string
|
||||
wantToken string
|
||||
wantType string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "url docx",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
|
||||
wantToken: "docxResource",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "url wiki always accepted",
|
||||
op: docOnlyOp,
|
||||
urlInput: "https://example.larksuite.com/wiki/wikiResource",
|
||||
wantToken: "wikiResource",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "token flag also accepts url",
|
||||
op: op,
|
||||
rawInput: "https://example.larksuite.com/sheets/sheetResource",
|
||||
wantToken: "sheetResource",
|
||||
wantType: "sheet",
|
||||
},
|
||||
{
|
||||
name: "bare token with type",
|
||||
op: op,
|
||||
rawInput: "docxResource",
|
||||
docType: "docx",
|
||||
wantToken: "docxResource",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
op: docOnlyOp,
|
||||
rawInput: "wikiResource",
|
||||
docType: "wiki",
|
||||
wantToken: "wikiResource",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/docx/docxResource",
|
||||
rawInput: "docxResource",
|
||||
wantErr: "mutually exclusive",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "missing input",
|
||||
op: op,
|
||||
wantErr: "specify --url or --token",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "bare token needs type",
|
||||
op: op,
|
||||
rawInput: "docxResource",
|
||||
wantErr: "--type is required",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "type conflicts with url",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/wiki/wikiResource",
|
||||
docType: "docx",
|
||||
wantErr: "conflicts",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
|
||||
wantErr: "unsupported --url resource type",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type for doc-only op",
|
||||
op: docOnlyOp,
|
||||
urlInput: "https://example.larksuite.com/sheets/sheetResource",
|
||||
wantErr: "comment reply supports doc, docx, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "apps page url",
|
||||
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
|
||||
urlInput: "https://example.feishu.cn/page/appsPageResource/",
|
||||
wantToken: "appsPageResource",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "apps page url rejected by op without apps",
|
||||
op: docOnlyOp,
|
||||
urlInput: "https://example.feishu.cn/page/appsPageResource",
|
||||
wantErr: `unsupported --url resource type "apps"`,
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "apps page url conflicts with explicit type",
|
||||
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
|
||||
urlInput: "https://example.feishu.cn/page/appsPageResource",
|
||||
docType: "docx",
|
||||
wantErr: "conflicts",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "base alias normalized in error",
|
||||
op: op,
|
||||
urlInput: "https://example.larksuite.com/base/baseResource",
|
||||
wantErr: `unsupported --url resource type "bitable"`,
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "unrecognized url",
|
||||
op: op,
|
||||
urlInput: "https://example.com/unknown/path",
|
||||
wantErr: "unsupported --url URL",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "bare token with path fragments",
|
||||
op: op,
|
||||
rawInput: "abc/def",
|
||||
docType: "docx",
|
||||
wantErr: "invalid bare token",
|
||||
wantParam: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid explicit type",
|
||||
op: docOnlyOp,
|
||||
rawInput: "sheetResource",
|
||||
docType: "sheet",
|
||||
wantErr: "invalid --type",
|
||||
wantParam: "--type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resolveDriveCommentInput(tt.op, tt.urlInput, tt.rawInput, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Token != tt.wantToken || got.Type != tt.wantType {
|
||||
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveCommentPathID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateDriveCommentPathID("7457000000000000001", "--comment-id"); err != nil {
|
||||
t.Fatalf("unexpected error for valid ID: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty", value: " ", wantErr: "must not be empty"},
|
||||
{name: "path traversal", value: "../admin", wantErr: "path traversal"},
|
||||
{name: "url metacharacters", value: "abc?x=1", wantErr: "invalid characters"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCommentPathID(tt.value, "--comment-id")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--comment-id")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCommentOpTypeHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
op := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
|
||||
if got := op.inputTypeList(); got != "doc, docx, wiki" {
|
||||
t.Fatalf("inputTypeList() = %q, want %q", got, "doc, docx, wiki")
|
||||
}
|
||||
if got := op.targetTypeList(); got != "doc, docx" {
|
||||
t.Fatalf("targetTypeList() = %q, want %q", got, "doc, docx")
|
||||
}
|
||||
if got := op.flagEnum(); len(got) != 3 || got[2] != "wiki" {
|
||||
t.Fatalf("flagEnum() = %v, want types plus trailing wiki", got)
|
||||
}
|
||||
if !op.supports("docx") || op.supports("sheet") || op.supports("wiki") {
|
||||
t.Fatalf("supports() misclassified: docx=%v sheet=%v wiki=%v", op.supports("docx"), op.supports("sheet"), op.supports("wiki"))
|
||||
}
|
||||
}
|
||||
134
shortcuts/drive/drive_delete_reply.go
Normal file
134
shortcuts/drive/drive_delete_reply.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveDeleteReplyOp = driveCommentOp{
|
||||
Label: "reply delete",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveDeleteReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
ReplyID string
|
||||
}
|
||||
|
||||
// DriveDeleteReply deletes a reply of a comment through the Drive comment
|
||||
// reply delete API, while accepting Wiki URLs/tokens and resolving them to
|
||||
// the underlying object.
|
||||
var DriveDeleteReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+delete-reply",
|
||||
Description: "Delete a reply of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveDeleteReplyOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID the reply belongs to (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "reply-id", Desc: "reply ID to delete (from drive +list-comments items[].reply_list.replies[].reply_id)", Required: true},
|
||||
),
|
||||
Tips: []string{
|
||||
"Reply IDs come from `drive +list-comments` (items[].reply_list.replies[].reply_id).",
|
||||
"Deletion is permanent; there is no undo or trash for comment replies.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveDeleteReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveDeleteReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveDeleteReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveDeleteReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveDeleteReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
validate.EncodePathSegment(spec.ReplyID),
|
||||
)
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
nil,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"reply_id": spec.ReplyID,
|
||||
"deleted": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveDeleteReplySpec(runtime *common.RuntimeContext) (driveDeleteReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveDeleteReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveDeleteReplySpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveDeleteReplySpec{}, err
|
||||
}
|
||||
replyID := strings.TrimSpace(runtime.Str("reply-id"))
|
||||
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
|
||||
return driveDeleteReplySpec{}, err
|
||||
}
|
||||
return driveDeleteReplySpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
ReplyID: replyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDriveDeleteReplyDryRun(spec driveDeleteReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> delete reply").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
DELETE("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
|
||||
Desc("[2] Delete reply on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: delete reply").
|
||||
DELETE("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
279
shortcuts/drive/drive_delete_reply_test.go
Normal file
279
shortcuts/drive/drive_delete_reply_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveDeleteReplyExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
|
||||
t.Fatalf("reply_id = %q, want reply_2", got)
|
||||
}
|
||||
if got := data["deleted"]; got != true {
|
||||
t.Fatalf("deleted = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyExecuteViaWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "file",
|
||||
"obj_token": "fileFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/fileFromWiki/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "file" {
|
||||
t.Errorf("file_type = %q, want file", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
|
||||
t.Fatalf("file_type = %q, want file", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe reply id",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "../reply",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "empty reply id",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", " ",
|
||||
},
|
||||
wantErr: "--reply-id must not be empty",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
"--reply-id", "reply_2",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
},
|
||||
wantErr: "reply delete supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "reply not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "reply not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "DELETE" {
|
||||
t.Fatalf("api[0].method = %q, want DELETE", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[0].url = %q, want resolved path segments", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveDeleteReply, []string{
|
||||
"+delete-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "DELETE" {
|
||||
t.Fatalf("api[1].method = %q, want DELETE", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -16,47 +19,180 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const driveMetadataReadScope = "drive:drive.metadata:readonly"
|
||||
|
||||
type driveDownloadOutputPathValidator func(string) error
|
||||
|
||||
func driveDownloadNormalizeFileName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = path.Base(name)
|
||||
if name == "" || name == "." || name == ".." || strings.Trim(name, "/") == "" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func driveDownloadFallbackFileName(title, fileToken string) string {
|
||||
if name := driveDownloadNormalizeFileName(title); name != "" {
|
||||
return name
|
||||
}
|
||||
return fileToken
|
||||
}
|
||||
|
||||
func driveDownloadCandidateOutputPath(header http.Header, candidate string) (string, bool) {
|
||||
fileName := driveDownloadNormalizeFileName(candidate)
|
||||
if fileName == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
fileName = sanitizeExportFileName(fileName, "")
|
||||
if fileName == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
fileName, _ = common.AutoAppendDownloadExtension(fileName, header, "")
|
||||
if strings.TrimSpace(fileName) == "" || fileName == "." || fileName == ".." || strings.Trim(fileName, "/") == "" {
|
||||
return "", false
|
||||
}
|
||||
return fileName, true
|
||||
}
|
||||
|
||||
func driveDownloadDefaultOutputPath(header http.Header, title, fileToken string, validatePath driveDownloadOutputPathValidator) (string, error) {
|
||||
candidates := []string{
|
||||
larkcore.FileNameByHeader(header),
|
||||
title,
|
||||
fileToken,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
fileName, ok := driveDownloadCandidateOutputPath(header, candidate)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if validatePath != nil {
|
||||
if err := validatePath(fileName); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fileName, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
func driveDownloadShouldFailOnMetadataTitleError(ctx context.Context, err error) bool {
|
||||
if ctx != nil {
|
||||
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
if problem.Category == errs.CategoryAuthorization {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var DriveDownload = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+download",
|
||||
Description: "Download a file from Drive to local",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
// Metadata is only required when --output is omitted and the CLI needs the
|
||||
// remote title as the pre-download fallback filename.
|
||||
ConditionalScopes: []string{driveMetadataReadScope},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file token", Required: true},
|
||||
{Name: "output", Desc: "local save path"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
outputPath := runtime.Str("output")
|
||||
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if outputPath == "" {
|
||||
if err := runtime.EnsureScopes([]string{driveMetadataReadScope}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
outputPath := runtime.Str("output")
|
||||
plan := common.NewDryRunAPI()
|
||||
downloadDesc := "[1] Download file bytes to the explicit output path"
|
||||
if outputPath == "" {
|
||||
outputPath = fileToken
|
||||
outputPath = "<Content-Disposition filename | metadata title | token>"
|
||||
downloadDesc = "[2] Download file bytes; Content-Disposition filename wins over metadata title when present"
|
||||
plan.
|
||||
POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[1] Resolve metadata title before downloading; fails before the download request if metadata scope is missing").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
return plan.
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Set("file_token", fileToken).Set("output", outputPath)
|
||||
Desc(downloadDesc).
|
||||
Set("file_token", fileToken).
|
||||
Set("output", outputPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
outputPath := runtime.Str("output")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
outputPath = fileToken
|
||||
}
|
||||
|
||||
// Early path validation + overwrite check
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
if outputPath != "" {
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
}
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
|
||||
var metadataTitle string
|
||||
if outputPath == "" {
|
||||
title, err := common.FetchDriveMetaTitle(runtime, fileToken, "file")
|
||||
if err != nil {
|
||||
if driveDownloadShouldFailOnMetadataTitleError(ctx, err) {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: metadata title lookup failed; continuing with Content-Disposition or token filename: %v\n", err)
|
||||
} else {
|
||||
metadataTitle = title
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
|
||||
@@ -66,10 +202,24 @@ var DriveDownload = common.Shortcut{
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return wrapDriveNetworkErr(err, "download failed: %s", err)
|
||||
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if outputPath == "" {
|
||||
var resolveErr error
|
||||
outputPath, resolveErr = driveDownloadDefaultOutputPath(resp.Header, metadataTitle, fileToken, func(path string) error {
|
||||
_, err := runtime.ResolveSavePath(path)
|
||||
return err
|
||||
})
|
||||
if resolveErr != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot derive a safe default output path: %s", resolveErr).WithCause(resolveErr)
|
||||
}
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
|
||||
@@ -5,6 +5,8 @@ package drive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -21,6 +23,30 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
|
||||
// +download intact while giving callers a preview-based path to view content.
|
||||
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(problem.Hint, "drive +preview") {
|
||||
return err
|
||||
}
|
||||
hint := driveDownloadForbiddenPreviewHint()
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
problem.Hint = hint
|
||||
return err
|
||||
}
|
||||
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
|
||||
return err
|
||||
}
|
||||
|
||||
func driveDownloadForbiddenPreviewHint() string {
|
||||
const tokenArg = "<FILE_TOKEN>"
|
||||
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
|
||||
}
|
||||
|
||||
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
|
||||
// to a typed validation error:
|
||||
// - Path validation failures → "unsafe file path: ..."
|
||||
|
||||
@@ -639,12 +639,29 @@ func sanitizeExportFileName(name, fallback string) string {
|
||||
)
|
||||
name = replacer.Replace(name)
|
||||
name = strings.Trim(name, ". ")
|
||||
if name == "" {
|
||||
if name == "" || isWindowsReservedDeviceFileName(name) {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func isWindowsReservedDeviceFileName(name string) bool {
|
||||
base := strings.TrimRight(name, ". ")
|
||||
if dot := strings.IndexByte(base, '.'); dot >= 0 {
|
||||
base = base[:dot]
|
||||
}
|
||||
switch strings.ToUpper(base) {
|
||||
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
|
||||
return true
|
||||
}
|
||||
if len(base) == 4 {
|
||||
prefix := strings.ToUpper(base[:3])
|
||||
suffix := base[3]
|
||||
return (prefix == "COM" || prefix == "LPT") && suffix >= '1' && suffix <= '9'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureExportFileExtension appends the expected local suffix when the chosen
|
||||
// file name does not already end with the export format's extension.
|
||||
func ensureExportFileExtension(name, fileExtension string) string {
|
||||
|
||||
@@ -58,6 +58,20 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
|
||||
if got := sanitizeExportFileName("../quarterly:report?.pdf", "fallback.bin"); got != "quarterly_report_.pdf" {
|
||||
t.Fatalf("sanitizeExportFileName() = %q, want %q", got, "quarterly_report_.pdf")
|
||||
}
|
||||
for _, name := range []string{"CON.txt", "con.backup.txt", "nul", "COM1.pdf", "lpt9.csv"} {
|
||||
t.Run("reserved-"+name, func(t *testing.T) {
|
||||
if got := sanitizeExportFileName(name, "fallback.bin"); got != "fallback.bin" {
|
||||
t.Fatalf("sanitizeExportFileName(%q) = %q, want fallback.bin", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, name := range []string{"CONTEXT.txt", "COM10.pdf", "LPT0.csv"} {
|
||||
t.Run("allowed-"+name, func(t *testing.T) {
|
||||
if got := sanitizeExportFileName(name, "fallback.bin"); got != name {
|
||||
t.Fatalf("sanitizeExportFileName(%q) = %q, want original name", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := ensureExportFileExtension("meeting-notes", "markdown"); got != "meeting-notes.md" {
|
||||
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "meeting-notes.md")
|
||||
}
|
||||
|
||||
@@ -12,19 +12,31 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn driveRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
var driveTaskCheckPollMu sync.Mutex
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
@@ -34,9 +46,15 @@ func driveTestConfig() *core.CliConfig {
|
||||
}
|
||||
|
||||
func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
return mountAndRunDriveWithContext(t, context.Background(), s, args, f, stdout)
|
||||
}
|
||||
|
||||
func mountAndRunDriveWithContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "drive"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetContext(ctx)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
@@ -1562,6 +1580,691 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_403/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("permission denied"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_403",
|
||||
"--output", "blocked.md",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP 403 error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category=%q, want network", problem.Category)
|
||||
}
|
||||
if problem.Code != http.StatusForbidden {
|
||||
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "drive +preview") {
|
||||
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
|
||||
}
|
||||
if strings.Contains(problem.Hint, "file_403") {
|
||||
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
|
||||
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
|
||||
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_missing/download",
|
||||
Status: http.StatusNotFound,
|
||||
RawBody: []byte("not found"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_missing",
|
||||
"--output", "missing.md",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP 404 error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Code != http.StatusNotFound {
|
||||
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
|
||||
}
|
||||
if strings.Contains(problem.Hint, "drive +preview") {
|
||||
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="////"`},
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "////", "file_token", nil); got != "file_token" {
|
||||
t.Fatalf("default output path = %q, want file_token", got)
|
||||
}
|
||||
if got := driveDownloadFallbackFileName(`\\`, "file_token"); got != "file_token" {
|
||||
t.Fatalf("fallback filename = %q, want file_token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathSanitizesWindowsReservedCharacters(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="Q1: forecast?.txt"`},
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", nil); got != "Q1_ forecast_.txt" {
|
||||
t.Fatalf("default output path = %q, want Q1_ forecast_.txt", got)
|
||||
}
|
||||
|
||||
header = http.Header{
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "Q1: forecast?", "file_token", nil); got != "Q1_ forecast_.txt" {
|
||||
t.Fatalf("metadata fallback output path = %q, want Q1_ forecast_.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathRejectsWindowsReservedDeviceNames(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="CON.txt"`},
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", nil); got != "Metadata Title.txt" {
|
||||
t.Fatalf("default output path = %q, want Metadata Title.txt", got)
|
||||
}
|
||||
|
||||
header = http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
}
|
||||
if got := mustDriveDownloadDefaultOutputPath(t, header, "COM1.pdf", "file_token", nil); got != "file_token" {
|
||||
t.Fatalf("metadata fallback output path = %q, want file_token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathFallsBackWhenHeaderCandidateFailsPathValidation(t *testing.T) {
|
||||
validatePath := func(path string) error {
|
||||
_, err := validate.SafeOutputPath(path)
|
||||
return err
|
||||
}
|
||||
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{"attachment; filename=\"evil\u202etxt\""},
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", validatePath)
|
||||
if got != "Metadata Title.txt" {
|
||||
t.Fatalf("default output path = %q, want Metadata Title.txt", got)
|
||||
}
|
||||
|
||||
header = http.Header{
|
||||
"Content-Type": []string{"text/plain"},
|
||||
}
|
||||
got = mustDriveDownloadDefaultOutputPath(t, header, "evil\u202etxt", "file_token", validatePath)
|
||||
if got != "file_token.txt" {
|
||||
t.Fatalf("metadata fallback output path = %q, want file_token.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustDriveDownloadDefaultOutputPath(t *testing.T, header http.Header, title, fileToken string, validatePath driveDownloadOutputPathValidator) string {
|
||||
t.Helper()
|
||||
got, err := driveDownloadDefaultOutputPath(header, title, fileToken, validatePath)
|
||||
if err != nil {
|
||||
t.Fatalf("driveDownloadDefaultOutputPath() error = %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestDriveDownloadDryRunPlansMetadataWhenOutputOmitted(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_dryrun",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
apis, _ := data["api"].([]interface{})
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("api count = %d, want 2\nstdout=%s", len(apis), stdout.String())
|
||||
}
|
||||
first, _ := apis[0].(map[string]interface{})
|
||||
if first["method"] != "POST" || first["url"] != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("first api = %#v, want metadata batch_query", first)
|
||||
}
|
||||
second, _ := apis[1].(map[string]interface{})
|
||||
if second["method"] != "GET" || second["url"] != "/open-apis/drive/v1/files/file_dryrun/download" {
|
||||
t.Fatalf("second api = %#v, want file download", second)
|
||||
}
|
||||
if second["desc"] != "[2] Download file bytes; Content-Disposition filename wins over metadata title when present" {
|
||||
t.Fatalf("second desc = %#v, want metadata-aware step 2", second["desc"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDryRunExplicitOutputSkipsMetadata(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_dryrun",
|
||||
"--output", "report.bin",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
apis, _ := data["api"].([]interface{})
|
||||
if len(apis) != 1 {
|
||||
t.Fatalf("api count = %d, want 1\nstdout=%s", len(apis), stdout.String())
|
||||
}
|
||||
first, _ := apis[0].(map[string]interface{})
|
||||
if first["method"] != "GET" || first["url"] != "/open-apis/drive/v1/files/file_dryrun/download" {
|
||||
t.Fatalf("api = %#v, want file download", first)
|
||||
}
|
||||
if first["desc"] != "[1] Download file bytes to the explicit output path" {
|
||||
t.Fatalf("api desc = %#v, want explicit-output step 1", first["desc"])
|
||||
}
|
||||
if data["output"] != "report.bin" {
|
||||
t.Fatalf("output = %#v, want report.bin", data["output"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadOmittedOutputRequiresMetadataScope(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:file:download"}, nil)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_no_scope",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing metadata scope error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("problem = category %q subtype %q, want authorization/missing_scope", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadRejectsInvalidFileToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "../bad",
|
||||
"--output", "report.bin",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid file-token error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file-token" {
|
||||
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--file-token", problem.Category, problem.Subtype, validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadRejectsUnsafeExplicitOutput(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_safe",
|
||||
"--output", "../report.bin",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--output", problem.Category, problem.Subtype, validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadExplicitOutputSkipsMetadataScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:file:download"}, nil)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_no_meta_scope/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("bytes"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_no_meta_scope",
|
||||
"--output", "explicit.bin",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(tmpDir, "explicit.bin")); err != nil || string(data) != "bytes" {
|
||||
t.Fatalf("explicit output content = %q, err=%v; want bytes", string(data), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadRejectsExistingDefaultOutputWithoutOverwrite(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_existing_title", "doc_type": "file", "title": "Existing Report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_existing_title/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("new"),
|
||||
Headers: http.Header{"Content-Type": []string{"text/plain"}},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "Existing Report.txt"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_existing_title",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite protection error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--output" {
|
||||
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--output", problem.Category, problem.Subtype, validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadUsesContentDispositionWhenOutputOmitted(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
metaStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_named", "doc_type": "file", "title": "Metadata Report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(metaStub)
|
||||
metadataSeenBeforeDownload := false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_named/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("downloaded"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"Content-Disposition": []string{`attachment; filename="server-report.md"`},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
metadataSeenBeforeDownload = len(metaStub.CapturedBody) > 0
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_named",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !metadataSeenBeforeDownload {
|
||||
t.Fatal("metadata title lookup must happen before download")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "server-report.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "downloaded" {
|
||||
t.Fatalf("downloaded content = %q, want downloaded", string(data))
|
||||
}
|
||||
out := decodeDriveEnvelope(t, stdout)
|
||||
if got := filepath.Base(common.GetString(out, "saved_path")); got != "server-report.md" {
|
||||
t.Fatalf("saved_path base=%q, want server-report.md\nstdout=%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadFallsBackToMetadataTitleWhenOutputOmitted(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_title", "doc_type": "file", "title": "Quarterly Report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_title/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("plain text"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_title",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Quarterly Report.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "plain text" {
|
||||
t.Fatalf("downloaded content = %q, want plain text", string(data))
|
||||
}
|
||||
out := decodeDriveEnvelope(t, stdout)
|
||||
if got := filepath.Base(common.GetString(out, "saved_path")); got != "Quarterly Report.txt" {
|
||||
t.Fatalf("saved_path base=%q, want Quarterly Report.txt\nstdout=%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadFallsBackToTokenWhenOutputOmittedAndMetadataEmpty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_empty/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("bytes"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_empty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "file_empty"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "bytes" {
|
||||
t.Fatalf("downloaded content = %q, want bytes", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadMetadataNonPermissionErrorContinuesWithTokenFallback(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "rate limit",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_rate_limited/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("bytes"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_rate_limited",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: metadata title lookup failed") {
|
||||
t.Fatalf("stderr missing metadata warning: %s", stderr.String())
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "file_rate_limited"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "bytes" {
|
||||
t.Fatalf("downloaded content = %q, want bytes", string(data))
|
||||
}
|
||||
out := decodeDriveEnvelope(t, stdout)
|
||||
if got := filepath.Base(common.GetString(out, "saved_path")); got != "file_rate_limited" {
|
||||
t.Fatalf("saved_path base=%q, want file_rate_limited\nstdout=%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadTypedMetadataTimeoutFallsBack(t *testing.T) {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "metadata lookup timed out")
|
||||
if driveDownloadShouldFailOnMetadataTitleError(context.Background(), err) {
|
||||
t.Fatal("typed metadata timeout should use warning fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadMetadataContextErrorStopsBeforeDownload(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wantErr error
|
||||
makeCtx func() (context.Context, context.CancelFunc)
|
||||
cancelIn func(context.CancelFunc, *http.Request)
|
||||
}{
|
||||
{
|
||||
name: "canceled",
|
||||
wantErr: context.Canceled,
|
||||
makeCtx: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(context.Background())
|
||||
},
|
||||
cancelIn: func(cancel context.CancelFunc, req *http.Request) {
|
||||
cancel()
|
||||
<-req.Context().Done()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
wantErr: context.DeadlineExceeded,
|
||||
makeCtx: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
},
|
||||
cancelIn: func(_ context.CancelFunc, req *http.Request) {
|
||||
<-req.Context().Done()
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runCtx, cancel := tc.makeCtx()
|
||||
defer cancel()
|
||||
|
||||
cfg := driveTestConfig()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
metadataRequests := 0
|
||||
downloadRequests := 0
|
||||
f.LarkClient = func() (*lark.Client, error) {
|
||||
return lark.NewClient(
|
||||
cfg.AppID,
|
||||
credential.RuntimeAppSecret(cfg.AppSecret),
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(cfg.Brand)),
|
||||
lark.WithHttpClient(&http.Client{Transport: driveRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(req.URL.Path, "/metas/batch_query") {
|
||||
metadataRequests++
|
||||
tc.cancelIn(cancel, req)
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
if strings.Contains(req.URL.Path, "/download") {
|
||||
downloadRequests++
|
||||
}
|
||||
return nil, errors.New("unexpected request after metadata context error")
|
||||
})}),
|
||||
), nil
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDriveWithContext(t, runCtx, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_context_error",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
if metadataRequests != 1 {
|
||||
t.Fatalf("metadata requests = %d, want 1", metadataRequests)
|
||||
}
|
||||
if downloadRequests != 0 {
|
||||
t.Fatalf("download requests = %d, want 0", downloadRequests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadMetadataErrorBeforeDownloadWhenOutputOmitted(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_no_meta",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected metadata lookup error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypeMissingScope || problem.Code != 99991679 {
|
||||
t.Fatalf("problem = category %q subtype %q code %d, want authorization/missing_scope/99991679", problem.Category, problem.Subtype, problem.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type capturedDriveMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
|
||||
@@ -348,7 +348,7 @@ func driveListCommentsScopeParam(scope string) (*bool, bool) {
|
||||
}
|
||||
|
||||
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
|
||||
items := common.GetSlice(data, "items")
|
||||
items := driveCommentItems(data)
|
||||
return map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": target.FileType,
|
||||
|
||||
@@ -525,3 +525,38 @@ func TestDriveListCommentsExecuteAppsPageURL(t *testing.T) {
|
||||
t.Fatalf("count = %#v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsOmittedItemsNormalized(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": ""},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("len(items) = %d, want 0", len(items))
|
||||
}
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
156
shortcuts/drive/drive_list_replies.go
Normal file
156
shortcuts/drive/drive_list_replies.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveListRepliesOp = driveCommentOp{
|
||||
Label: "replies list",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveListRepliesSpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
PageSize int
|
||||
PageToken string
|
||||
NeedReaction bool
|
||||
}
|
||||
|
||||
// DriveListReplies lists the replies of one comment through the Drive comment
|
||||
// reply list API, while accepting Wiki URLs/tokens and resolving them to the
|
||||
// underlying object.
|
||||
var DriveListReplies = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+list-replies",
|
||||
Description: "List replies of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.comment:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveListRepliesOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID whose replies to list (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
|
||||
common.Flag{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on replies"},
|
||||
),
|
||||
Tips: []string{
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
"The root reply (the comment body itself) is the earliest-created reply: it is items[0] of the FIRST page only (no --page-token); items[0] of later pages is a regular reply.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveListRepliesSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveListRepliesSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveListRepliesDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveListRepliesSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveListRepliesOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing replies of comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
)
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
path,
|
||||
buildDriveListRepliesParams(spec, target.FileType),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := driveCommentItems(data)
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"items": items,
|
||||
"has_more": common.GetBool(data, "has_more"),
|
||||
"page_token": common.GetString(data, "page_token"),
|
||||
"count": len(items),
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveListRepliesSpec(runtime *common.RuntimeContext) (driveListRepliesSpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveListRepliesOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveListRepliesSpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveListRepliesSpec{}, err
|
||||
}
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
return driveListRepliesSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
|
||||
}
|
||||
return driveListRepliesSpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
PageSize: pageSize,
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
NeedReaction: runtime.Bool("need-reaction"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDriveListRepliesParams(spec driveListRepliesSpec, fileType string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
if spec.NeedReaction {
|
||||
params["need_reaction"] = true
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func buildDriveListRepliesDryRun(spec driveListRepliesSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> list comment replies").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
|
||||
Desc("[2] List replies of comment on resolved document").
|
||||
Params(buildDriveListRepliesParams(spec, "<obj_type from step 1>")).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: list comment replies").
|
||||
GET("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
|
||||
Params(buildDriveListRepliesParams(spec, spec.Ref.Type)).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
425
shortcuts/drive/drive_list_replies_test.go
Normal file
425
shortcuts/drive/drive_list_replies_test.go
Normal file
@@ -0,0 +1,425 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveListRepliesExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := query.Get("page_size"); got != "50" {
|
||||
t.Errorf("page_size = %q, want 50 (default)", got)
|
||||
}
|
||||
if query.Has("page_token") {
|
||||
t.Errorf("page_token should be omitted when not set, got %q", query.Get("page_token"))
|
||||
}
|
||||
if query.Has("need_reaction") {
|
||||
t.Errorf("need_reaction should be omitted when not set, got %q", query.Get("need_reaction"))
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"reply_id": "reply_1",
|
||||
"content": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "text_run",
|
||||
"text_run": map[string]interface{}{"text": "根回复正文"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{"reply_id": "reply_2"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
items := mustSliceValue(t, data["items"], "data.items")
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("len(items) = %d, want 2", len(items))
|
||||
}
|
||||
firstItem := mustMapValue(t, items[0], "data.items[0]")
|
||||
if got := mustStringField(t, firstItem, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
|
||||
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
|
||||
}
|
||||
firstContent := mustMapValue(t, firstItem["content"], "data.items[0].content")
|
||||
firstElements := mustSliceValue(t, firstContent["elements"], "data.items[0].content.elements")
|
||||
firstElement := mustMapValue(t, firstElements[0], "data.items[0].content.elements[0]")
|
||||
firstText := mustMapValue(t, firstElement["text_run"], "data.items[0].content.elements[0].text_run")
|
||||
if got := mustStringField(t, firstText, "text", "data.items[0].content.elements[0].text_run.text"); got != "根回复正文" {
|
||||
t.Fatalf("items[0] text = %q, want 根回复正文", got)
|
||||
}
|
||||
secondItem := mustMapValue(t, items[1], "data.items[1]")
|
||||
if got := mustStringField(t, secondItem, "reply_id", "data.items[1].reply_id"); got != "reply_2" {
|
||||
t.Fatalf("items[1].reply_id = %q, want reply_2", got)
|
||||
}
|
||||
if got := data["count"]; got != float64(2) {
|
||||
t.Fatalf("count = %#v, want 2", got)
|
||||
}
|
||||
if got := data["has_more"]; got != true {
|
||||
t.Fatalf("has_more = %#v, want true", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "page_token", "data.page_token"); got != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesExecuteViaWikiToBitable(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bitableFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/bitableFromWiki/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"reply_id": "reply_1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("file_type = %q, want bitable", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
items := mustSliceValue(t, data["items"], "data.items")
|
||||
item := mustMapValue(t, items[0], "data.items[0]")
|
||||
if got := mustStringField(t, item, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
|
||||
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesPaginationAndReactionParams(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("page_size"); got != "10" {
|
||||
t.Errorf("page_size = %q, want 10", got)
|
||||
}
|
||||
if got := query.Get("page_token"); got != "cursor_1" {
|
||||
t.Errorf("page_token = %q, want cursor_1", got)
|
||||
}
|
||||
if got := query.Get("need_reaction"); got != "true" {
|
||||
t.Errorf("need_reaction = %q, want true", got)
|
||||
}
|
||||
if got := query.Get("user_id_type"); got != "" {
|
||||
t.Errorf("user_id_type = %q, want omitted (flag removed)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--page-size", "10",
|
||||
"--page-token", "cursor_1",
|
||||
"--need-reaction",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "empty comment id",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", " ",
|
||||
},
|
||||
wantErr: "--comment-id must not be empty",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "page size too small",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--page-size", "0",
|
||||
},
|
||||
wantErr: "--page-size must be between 1 and 100",
|
||||
wantParam: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "page size too large",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--page-size", "101",
|
||||
},
|
||||
wantErr: "--page-size must be between 1 and 100",
|
||||
wantParam: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
},
|
||||
wantErr: "replies list supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveListReplies, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069301,
|
||||
"msg": "comment not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "comment not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--need-reaction",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "GET" {
|
||||
t.Fatalf("api[0].method = %q, want GET", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
|
||||
t.Fatalf("api[0].url = %q, want resolved path segments", got)
|
||||
}
|
||||
params := mustMapValue(t, call["params"], "api[0].params")
|
||||
if got := params["need_reaction"]; got != true {
|
||||
t.Fatalf("api[0].params.need_reaction = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "GET" {
|
||||
t.Fatalf("api[1].method = %q, want GET", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
|
||||
t.Fatalf("api[1].url = %q, want resolved comment ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListRepliesOmittedItemsNormalized(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": ""},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListReplies, []string{
|
||||
"+list-replies",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("len(items) = %d, want 0", len(items))
|
||||
}
|
||||
if got := data["count"]; got != float64(0) {
|
||||
t.Fatalf("count = %#v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,10 @@ var driveMemberAddURLPathToType = []struct {
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
{"/page/", "apps"},
|
||||
}
|
||||
|
||||
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
|
||||
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes", "apps"}
|
||||
|
||||
const driveMemberAddBatchLimit = 10
|
||||
|
||||
@@ -61,7 +62,7 @@ const driveMemberAddBatchLimit = 10
|
||||
var DriveMemberAdd = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-add",
|
||||
Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
|
||||
Description: "Add a collaborator/member permission to a Drive resource",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"docs:permission.member:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -320,7 +321,7 @@ func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string
|
||||
|
||||
func isSupportedDriveMemberAddResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
|
||||
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes", "apps":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -35,10 +35,12 @@ func TestResolveDriveMemberAddTarget_URLAndBareToken(t *testing.T) {
|
||||
{"folder URL", "https://example.feishu.cn/drive/folder/fldTok", "", "fldTok", "folder"},
|
||||
{"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"},
|
||||
{"mindnotes URL", "https://example.feishu.cn/mindnotes/mndTok", "", "mndTok", "mindnote"},
|
||||
{"apps page URL", "https://example.feishu.cn/page/appMetaTok/?from=share", "", "appMetaTok", "apps"},
|
||||
{"larkoffice URL", "https://tenant.larkoffice.com/docx/doxTok", "", "doxTok", "docx"},
|
||||
{"explicit type overrides URL", "https://example.feishu.cn/docx/doxTok", "wiki", "doxTok", "wiki"},
|
||||
{"bare token with explicit docx type", "N83ZduEnHooFswxnVWGcazlLnFf", "docx", "N83ZduEnHooFswxnVWGcazlLnFf", "docx"},
|
||||
{"bare token with explicit folder type", "fldToken123", "folder", "fldToken123", "folder"},
|
||||
{"bare token with explicit apps type", "appMetaTok", "apps", "appMetaTok", "apps"},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
|
||||
@@ -24,7 +24,7 @@ type driveMemberListSpec struct {
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
"mindnote", "minutes", "slides", "folder", "apps",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
@@ -45,6 +45,7 @@ var driveMemberListURLPathToType = []struct {
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
{"/page/", "apps"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
@@ -88,7 +89,7 @@ func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive resource URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
@@ -235,13 +236,13 @@ func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
|
||||
Description: "List collaborator/member permissions on a Drive resource",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder/apps)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
|
||||
@@ -83,6 +83,19 @@ func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "apps page URL",
|
||||
token: "https://example.feishu.cn/page/appMetaTok/?from=share",
|
||||
wantTok: "appMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit apps type",
|
||||
token: "appBareMetaTok",
|
||||
docType: "apps",
|
||||
wantTok: "appBareMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -21,26 +22,68 @@ type drivePermissionGetSettingSpec struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
type drivePermissionGetSettingResourceKind struct {
|
||||
Type string
|
||||
CanonicalPath string
|
||||
PathAliases []string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
var drivePermissionGetSettingResourceKinds = []drivePermissionGetSettingResourceKind{
|
||||
{Type: "doc", CanonicalPath: "/doc/"},
|
||||
{Type: "sheet", CanonicalPath: "/sheets/"},
|
||||
{Type: "file", CanonicalPath: "/file/"},
|
||||
{Type: "wiki", CanonicalPath: "/wiki/"},
|
||||
{Type: "bitable", CanonicalPath: "/base/", PathAliases: []string{"/bitable/"}},
|
||||
{Type: "docx", CanonicalPath: "/docx/"},
|
||||
{Type: "mindnote", CanonicalPath: "/mindnote/", PathAliases: []string{"/mindnotes/"}},
|
||||
{Type: "minutes", CanonicalPath: "/minutes/"},
|
||||
{Type: "slides", CanonicalPath: "/slides/"},
|
||||
{Type: "folder", CanonicalPath: "/drive/folder/"},
|
||||
{Type: "apps", CanonicalPath: "/page/"},
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = func() []string {
|
||||
types := make([]string, 0, len(drivePermissionGetSettingResourceKinds))
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
types = append(types, resourceKind.Type)
|
||||
}
|
||||
return types
|
||||
}()
|
||||
|
||||
func findDrivePermissionGetSettingResourceKind(docType string) (drivePermissionGetSettingResourceKind, bool) {
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
if docType == resourceKind.Type {
|
||||
return resourceKind, true
|
||||
}
|
||||
}
|
||||
return drivePermissionGetSettingResourceKind{}, false
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourcePath(path, prefix, docType string) (common.ResourceRef, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
escapedToken := strings.TrimSuffix(path[len(prefix):], "/")
|
||||
if escapedToken == "" || strings.Contains(escapedToken, "/") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
token, err := url.PathUnescape(escapedToken)
|
||||
if err != nil || token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: docType, Token: token}, true
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceKindPath(path string, resourceKind drivePermissionGetSettingResourceKind) (common.ResourceRef, bool) {
|
||||
if ref, ok := parseDrivePermissionGetSettingResourcePath(path, resourceKind.CanonicalPath, resourceKind.Type); ok {
|
||||
return ref, true
|
||||
}
|
||||
for _, alias := range resourceKind.PathAliases {
|
||||
if ref, ok := parseDrivePermissionGetSettingResourcePath(path, alias, resourceKind.Type); ok {
|
||||
return ref, true
|
||||
}
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
@@ -68,7 +111,7 @@ func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePer
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive resource URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
@@ -80,8 +123,8 @@ func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePer
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
if err := validateDrivePermissionGetSettingToken(ref.Token); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, err
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
@@ -94,53 +137,61 @@ func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePer
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(rawToken, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
if err := validateDrivePermissionGetSettingToken(rawToken); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, err
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, mapping := range drivePermissionGetSettingURLPathToType {
|
||||
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
|
||||
continue
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
if ref, ok := parseDrivePermissionGetSettingResourceKindPath(parsed.EscapedPath(), resourceKind); ok {
|
||||
return ref, true
|
||||
}
|
||||
token := parsed.Path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
for _, allowed := range drivePermissionGetSettingTypes {
|
||||
if docType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
_, ok := findDrivePermissionGetSettingResourceKind(docType)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
|
||||
return u
|
||||
}
|
||||
resourceKind, ok := findDrivePermissionGetSettingResourceKind(s.Type)
|
||||
token := strings.TrimSpace(s.Token)
|
||||
if !ok || token == "" {
|
||||
return ""
|
||||
}
|
||||
return common.BuildResourceURL("", s.Type, s.Token)
|
||||
|
||||
brand := core.LarkBrand("")
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
brand = runtime.Config.Brand
|
||||
}
|
||||
host := "https://www.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "https://www.larksuite.com"
|
||||
}
|
||||
return host + resourceKind.CanonicalPath + url.PathEscape(token)
|
||||
}
|
||||
|
||||
func validateDrivePermissionGetSettingToken(token string) error {
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
if token == "." || strings.Contains(token, "/") {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token must be a non-dot single path segment",
|
||||
).WithParam("--token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
@@ -166,8 +217,7 @@ func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive
|
||||
// document, file, wiki node, or folder.
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive resource.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
@@ -177,7 +227,7 @@ var DrivePermissionGetSetting = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder/apps)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -83,6 +84,12 @@ func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "canonical mindnote URL",
|
||||
token: "https://example.feishu.cn/mindnote/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
@@ -104,6 +111,19 @@ func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "apps page URL",
|
||||
token: "https://example.feishu.cn/page/appMetaTok/?from=share",
|
||||
wantTok: "appMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit apps type",
|
||||
token: "appBareMetaTok",
|
||||
docType: "apps",
|
||||
wantTok: "appBareMetaTok",
|
||||
wantType: "apps",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
@@ -126,6 +146,57 @@ func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingResourceKindsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const token = "resourceRoundTripTok"
|
||||
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
||||
kind := resourceKind
|
||||
t.Run(kind.Type, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bareRuntime := newDrivePermissionGetSettingRuntime(t, token, kind.Type)
|
||||
bareSpec, err := readDrivePermissionGetSettingSpec(bareRuntime)
|
||||
if err != nil {
|
||||
t.Fatalf("read bare-token spec: %v", err)
|
||||
}
|
||||
resourceURL := bareSpec.url(bareRuntime)
|
||||
if resourceURL == "" {
|
||||
t.Fatalf("resource URL is empty for allowed type %q", kind.Type)
|
||||
}
|
||||
|
||||
urlRuntime := newDrivePermissionGetSettingRuntime(t, resourceURL, "")
|
||||
urlSpec, err := readDrivePermissionGetSettingSpec(urlRuntime)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated URL spec %q: %v", resourceURL, err)
|
||||
}
|
||||
if urlSpec.Token != token || urlSpec.Type != kind.Type {
|
||||
t.Fatalf(
|
||||
"generated URL resolved to token/type %q/%q, want %q/%q",
|
||||
urlSpec.Token,
|
||||
urlSpec.Type,
|
||||
token,
|
||||
kind.Type,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, "appMetaTok", "apps")
|
||||
runtime.Config.Brand = core.BrandLark
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got, want := spec.url(runtime), "https://www.larksuite.com/page/appMetaTok"; got != want {
|
||||
t.Fatalf("resource URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -147,6 +218,26 @@ func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "bare token contains path separator",
|
||||
token: "doxTok/other",
|
||||
docType: "docx",
|
||||
wantParam: "--token",
|
||||
wantMessage: "single path segment",
|
||||
},
|
||||
{
|
||||
name: "bare dot token",
|
||||
token: ".",
|
||||
docType: "docx",
|
||||
wantParam: "--token",
|
||||
wantMessage: "non-dot single path segment",
|
||||
},
|
||||
{
|
||||
name: "non-HTTP URL",
|
||||
token: "ftp://example.feishu.cn/docx/doxTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
@@ -421,6 +512,62 @@ func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesResourceURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
}{
|
||||
{
|
||||
name: "apps",
|
||||
token: "appMetaTok",
|
||||
docType: "apps",
|
||||
wantURL: "https://www.feishu.cn/page/appMetaTok",
|
||||
},
|
||||
{
|
||||
name: "minutes",
|
||||
token: "obcnMinuteTok",
|
||||
docType: "minutes",
|
||||
wantURL: "https://www.feishu.cn/minutes/obcnMinuteTok",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/" + tt.token + "/public?type=" + tt.docType,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", tt.token,
|
||||
"--type", tt.docType,
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "URL: "+tt.wantURL) {
|
||||
t.Fatalf("pretty output missing resource URL %q:\n%s", tt.wantURL, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
var DrivePreview = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+preview",
|
||||
Description: "List or download available preview artifacts for a Drive file",
|
||||
Description: "View or download Drive file content, or list and fetch available preview artifacts",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Drive file token", Required: true},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
|
||||
{Name: "version", Desc: "optional file version"},
|
||||
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
|
||||
{Name: "output", Desc: "local output path for downloaded preview"},
|
||||
@@ -40,6 +40,25 @@ var DrivePreview = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
requestedType := strings.TrimSpace(runtime.Str("type"))
|
||||
if requestedType == "source_file" {
|
||||
downloadParams := map[string]interface{}{
|
||||
"preview_type": drivePreviewTypeSourceFile,
|
||||
}
|
||||
if version != "" {
|
||||
downloadParams["version"] = version
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("Download the source file artifact").
|
||||
Params(downloadParams).
|
||||
Set("file_token", fileToken).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", requestedType).
|
||||
Set("selected_type", "source_file").
|
||||
Set("selected_type_code", drivePreviewTypeSourceFile).
|
||||
Set("output", runtime.Str("output"))
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if version != "" {
|
||||
body["version"] = version
|
||||
@@ -67,7 +86,7 @@ var DrivePreview = common.Shortcut{
|
||||
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
|
||||
Params(downloadParams).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", runtime.Str("type")).
|
||||
Set("requested_type", requestedType).
|
||||
Set("output", runtime.Str("output"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -82,9 +101,25 @@ var DrivePreview = common.Shortcut{
|
||||
body["version"] = version
|
||||
}
|
||||
|
||||
if requestedType == "source_file" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading source file artifact: %s\n", common.MaskToken(fileToken))
|
||||
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, drivePreviewTypeSourceFile, version, outputPath, ifExists, drivePreviewFallbackExt("source_file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["mode"] = "download"
|
||||
result["file_token"] = fileToken
|
||||
result["selected_type"] = "source_file"
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
|
||||
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
|
||||
if err != nil {
|
||||
if runtime.Bool("list-only") {
|
||||
return withDrivePreviewSourceFileHint(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("list-only") {
|
||||
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
drivePreviewIfExistsError = "error"
|
||||
drivePreviewIfExistsOverwrite = "overwrite"
|
||||
drivePreviewIfExistsRename = "rename"
|
||||
drivePreviewTypeSourceFile = "16"
|
||||
drivePreviewSourceFileHint = "Preview candidates are unavailable for this file. To fetch the source file artifact, rerun with --type source_file --output <path>."
|
||||
)
|
||||
|
||||
type drivePreviewCandidate struct {
|
||||
@@ -88,7 +90,9 @@ var drivePreviewMimeToExt = map[string]string{
|
||||
"image/webp": ".webp",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/markdown": ".md",
|
||||
"text/plain": ".txt",
|
||||
"text/x-markdown": ".md",
|
||||
"text/xml": ".xml",
|
||||
"video/mp4": ".mp4",
|
||||
"application/octet-stream": "",
|
||||
@@ -464,7 +468,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -492,8 +496,8 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
|
||||
|
||||
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
|
||||
// inference and the selected collision policy.
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists, fallbackName string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := resolveDrivePreviewOutputPathName(runtime, outputPath, header, fallbackExt, fallbackName)
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
@@ -522,6 +526,32 @@ func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath st
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDrivePreviewOutputPathName(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
|
||||
if drivePreviewOutputIsDirectory(runtime, outputPath) {
|
||||
fileName, resolution := drivePreviewDefaultFileName(header, fallbackExt, fallbackName)
|
||||
return filepath.Join(outputPath, fileName), resolution
|
||||
}
|
||||
return autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
}
|
||||
|
||||
func drivePreviewOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func drivePreviewDefaultFileName(header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
|
||||
name := driveDownloadNormalizeFileName(larkcore.FileNameByHeader(header))
|
||||
if name == "" {
|
||||
name = driveDownloadNormalizeFileName(fallbackName)
|
||||
}
|
||||
name = sanitizeExportFileName(name, "preview")
|
||||
name, resolution := autoAppendDrivePreviewExtension(name, header, fallbackExt)
|
||||
return name, resolution
|
||||
}
|
||||
|
||||
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
|
||||
// target output path.
|
||||
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
|
||||
@@ -556,6 +586,15 @@ func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fall
|
||||
if filepath.Ext(outputPath) == "." {
|
||||
normalizedPath = strings.TrimSuffix(outputPath, ".")
|
||||
}
|
||||
if fallbackExt == "" {
|
||||
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
return normalizedPath, nil
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
@@ -804,6 +843,36 @@ func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePrevie
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
|
||||
}
|
||||
|
||||
// withDrivePreviewSourceFileHint adds source_file guidance to preview candidate
|
||||
// API failures without changing their classification or server diagnostics.
|
||||
func withDrivePreviewSourceFileHint(err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryAPI {
|
||||
return err
|
||||
}
|
||||
if problem.Retryable || problem.Subtype == errs.SubtypeRateLimit {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(problem.Hint, "--type source_file") {
|
||||
return err
|
||||
}
|
||||
if !isDrivePreviewCandidatesUnavailableProblem(problem) {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
problem.Hint = drivePreviewSourceFileHint
|
||||
return err
|
||||
}
|
||||
problem.Hint = strings.TrimSpace(problem.Hint) + " " + drivePreviewSourceFileHint
|
||||
return err
|
||||
}
|
||||
|
||||
func isDrivePreviewCandidatesUnavailableProblem(problem *errs.Problem) bool {
|
||||
return problem != nil &&
|
||||
problem.Code == 1 &&
|
||||
strings.Contains(problem.Message, "mGetFilePreviewCore failed")
|
||||
}
|
||||
|
||||
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
|
||||
// spec.
|
||||
func wrapDriveCoverUnavailable(requested string) error {
|
||||
|
||||
@@ -147,6 +147,63 @@ func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult verifies
|
||||
// source_file downloads the source file artifact without first fetching preview
|
||||
// candidates.
|
||||
func TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_source/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
Body: []byte("# markdown\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_source",
|
||||
"--type", "source_file",
|
||||
"--output", "artifacts/",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if _, ok := data["requested_type"]; ok {
|
||||
t.Fatalf("requested_type should be omitted from execute output: %#v", data)
|
||||
}
|
||||
if got := data["selected_type"]; got != "source_file" {
|
||||
t.Fatalf("selected_type=%v, want source_file", got)
|
||||
}
|
||||
if _, ok := data["selected_type_code"]; ok {
|
||||
t.Fatalf("selected_type_code should be omitted from execute output: %#v", data)
|
||||
}
|
||||
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks() error: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(resolvedTmpDir, "artifacts", "README.md")
|
||||
if got := data["output_path"]; got != wantPath {
|
||||
t.Fatalf("output_path=%v, want %s", got, wantPath)
|
||||
}
|
||||
gotBody, err := os.ReadFile(wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error: %v", wantPath, err)
|
||||
}
|
||||
if string(gotBody) != "# markdown\n" {
|
||||
t.Fatalf("saved body=%q, want markdown source", string(gotBody))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
|
||||
// return an actionable validation error.
|
||||
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
|
||||
@@ -434,6 +491,72 @@ func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunSourceFileDocumentsDirectDownload verifies source_file
|
||||
// dry-run documents the direct source artifact download path.
|
||||
func TestDrivePreviewDryRunSourceFileDocumentsDirectDownload(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_source",
|
||||
"type": "source_file",
|
||||
"version": "7",
|
||||
"output": "source",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
if got := data["mode"]; got != "download" {
|
||||
t.Fatalf("mode=%v, want download", got)
|
||||
}
|
||||
if got := data["requested_type"]; got != "source_file" {
|
||||
t.Fatalf("requested_type=%v, want source_file", got)
|
||||
}
|
||||
if got := data["selected_type"]; got != "source_file" {
|
||||
t.Fatalf("selected_type=%v, want source_file", got)
|
||||
}
|
||||
if got := data["selected_type_code"]; got != drivePreviewTypeSourceFile {
|
||||
t.Fatalf("selected_type_code=%v, want %s", got, drivePreviewTypeSourceFile)
|
||||
}
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("len(api)=%d, want 1", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["method"]; got != "GET" {
|
||||
t.Fatalf("method=%v, want GET", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_download" {
|
||||
t.Fatalf("url=%v, want preview_download", got)
|
||||
}
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["preview_type"]; got != drivePreviewTypeSourceFile {
|
||||
t.Fatalf("params.preview_type=%v, want %s", got, drivePreviewTypeSourceFile)
|
||||
}
|
||||
if got := params["version"]; got != "7" {
|
||||
t.Fatalf("params.version=%v, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates verifies only the
|
||||
// explicit source_file request bypasses preview_result.
|
||||
func TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_source",
|
||||
"type": "source",
|
||||
"output": "source",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("len(api)=%d, want 2", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_result" {
|
||||
t.Fatalf("url=%v, want preview_result", got)
|
||||
}
|
||||
if _, ok := data["selected_type_code"]; ok {
|
||||
t.Fatalf("selected_type_code should be omitted for non-source_file dry-run: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
|
||||
// omits the request body when no version is supplied.
|
||||
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
|
||||
@@ -612,6 +735,135 @@ func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewListOnlyErrorAddsSourceFileHint verifies preview_result API
|
||||
// failures keep server diagnostics while guiding callers to source_file.
|
||||
func TestDrivePreviewListOnlyErrorAddsSourceFileHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_markdown/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1,
|
||||
"msg": "fail:mGetFilePreviewCore failed",
|
||||
"log_id": "log-preview-result",
|
||||
"error": map[string]interface{}{
|
||||
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/preview-result",
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server preview_result detail"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_markdown",
|
||||
"--list-only",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected preview_result error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category=%q, want api", problem.Category)
|
||||
}
|
||||
if problem.Code != 1 {
|
||||
t.Fatalf("code=%d, want 1", problem.Code)
|
||||
}
|
||||
if problem.LogID != "log-preview-result" {
|
||||
t.Fatalf("log_id=%q, want log-preview-result", problem.LogID)
|
||||
}
|
||||
if problem.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/preview-result" {
|
||||
t.Fatalf("troubleshooter=%q, want passthrough", problem.Troubleshooter)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "server preview_result detail") {
|
||||
t.Fatalf("hint=%q, want server detail preserved", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output") {
|
||||
t.Fatalf("hint=%q, want source_file output guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewListOnlyRateLimitKeepsOriginalHint verifies retryable API
|
||||
// errors are not reframed as source_file recovery.
|
||||
func TestDrivePreviewListOnlyRateLimitKeepsOriginalHint(t *testing.T) {
|
||||
err := withDrivePreviewSourceFileHint(errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithCode(99991400).WithRetryable())
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Hint != "" {
|
||||
t.Fatalf("hint=%q, want empty hint for rate limit", problem.Hint)
|
||||
}
|
||||
if !problem.Retryable {
|
||||
t.Fatal("retryable=false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewSourceFileHintGuards verifies source_file recovery guidance
|
||||
// only rewrites eligible API errors and preserves existing source_file hints.
|
||||
func TestDrivePreviewSourceFileHintGuards(t *testing.T) {
|
||||
plainErr := errors.New("plain failure")
|
||||
if got := withDrivePreviewSourceFileHint(plainErr); got != plainErr {
|
||||
t.Fatalf("non-API error changed: got %T %v, want original", got, got)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
err *errs.APIError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "already has source file hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed").WithHint("rerun with --type source_file --output <path>"),
|
||||
want: "rerun with --type source_file --output <path>",
|
||||
},
|
||||
{
|
||||
name: "candidate core failure empty hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1),
|
||||
want: drivePreviewSourceFileHint,
|
||||
},
|
||||
{
|
||||
name: "candidate core failure whitespace hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1).WithHint(" \n\t "),
|
||||
want: drivePreviewSourceFileHint,
|
||||
},
|
||||
{
|
||||
name: "generic server error",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed"),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
err: errs.NewAPIError(errs.SubtypeNotFound, "file not found").WithCode(1061044),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "invalid parameters",
|
||||
err: errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid file token").WithCode(1063007),
|
||||
want: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotErr := withDrivePreviewSourceFileHint(tt.err)
|
||||
if gotErr != tt.err {
|
||||
t.Fatalf("API error pointer changed: got %T, want original", gotErr)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(gotErr)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", gotErr, gotErr)
|
||||
}
|
||||
if problem.Hint != tt.want {
|
||||
t.Fatalf("hint=%q, want %q", problem.Hint, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
|
||||
// validation error with available alternatives.
|
||||
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
|
||||
@@ -721,6 +973,21 @@ func TestDrivePreviewCommonHelpers(t *testing.T) {
|
||||
if path != "cover.pdf" || fallback != nil {
|
||||
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
|
||||
}
|
||||
|
||||
header = http.Header{}
|
||||
header.Set("Content-Type", "text/plain")
|
||||
header.Set("Content-Disposition", `attachment; filename="README.md"`)
|
||||
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
|
||||
if path != "source.md" || fallback == nil || fallback.Source != "Content-Disposition" {
|
||||
t.Fatalf("source_file append = (%q, %+v), want source.md from Content-Disposition", path, fallback)
|
||||
}
|
||||
|
||||
header = http.Header{}
|
||||
header.Set("Content-Type", "text/plain")
|
||||
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
|
||||
if path != "source.txt" || fallback == nil || fallback.Source != "Content-Type" {
|
||||
t.Fatalf("source_file content-type append = (%q, %+v), want source.txt from Content-Type", path, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
|
||||
@@ -751,7 +1018,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
header := http.Header{}
|
||||
header.Set("Content-Type", "application/pdf")
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
|
||||
}
|
||||
@@ -759,7 +1026,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
|
||||
}
|
||||
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid if-exists error, got nil")
|
||||
}
|
||||
@@ -771,6 +1038,20 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
|
||||
}
|
||||
|
||||
if err := os.Mkdir("artifacts", 0755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
sourceHeader := http.Header{}
|
||||
sourceHeader.Set("Content-Type", "text/plain")
|
||||
sourceHeader.Set("Content-Disposition", `attachment; filename="README.md"`)
|
||||
dirOutput, _, err := resolveDrivePreviewOutputPath(runtime, "artifacts", sourceHeader, "", drivePreviewIfExistsError, "file_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(directory) error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(dirOutput, filepath.Join("artifacts", "README.md")) {
|
||||
t.Fatalf("dirOutput=%q, want artifacts/README.md suffix", dirOutput)
|
||||
}
|
||||
|
||||
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
|
||||
@@ -779,7 +1060,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
|
||||
}
|
||||
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
|
||||
}
|
||||
@@ -791,7 +1072,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
|
||||
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
runtimeWithStatErr.Factory = f
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
|
||||
if err == nil {
|
||||
t.Fatal("expected stat permission error, got nil")
|
||||
}
|
||||
@@ -876,7 +1157,6 @@ func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
|
||||
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
|
||||
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
|
||||
}
|
||||
|
||||
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
|
||||
if len(aliases) == 0 || aliases[0] != "image" {
|
||||
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
|
||||
|
||||
215
shortcuts/drive/drive_react_reply.go
Normal file
215
shortcuts/drive/drive_react_reply.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveReactReplyOp = driveCommentOp{
|
||||
Label: "reply reaction",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
const (
|
||||
driveReactReplyActionAdd = "add"
|
||||
driveReactReplyActionDelete = "delete"
|
||||
)
|
||||
|
||||
// driveReactReplyReactionTypes mirrors the reaction_type enum from the
|
||||
// platform metadata (file.comment.reply.reactions.update_reaction). The
|
||||
// server does NOT validate this field — an arbitrary string is accepted and
|
||||
// persisted as a broken reaction on the reply, so this local check is the
|
||||
// only guard. Values are case-sensitive.
|
||||
var driveReactReplyReactionTypes = map[string]struct{}{
|
||||
"ANGRY": {}, "APPLAUSE": {}, "ATTENTION": {}, "AWESOME": {}, "BEAR": {}, "BEER": {},
|
||||
"BETRAYED": {}, "BIGKISS": {}, "BLACKFACE": {}, "BLUBBER": {}, "BLUSH": {}, "BOMB": {},
|
||||
"CAKE": {}, "CHUCKLE": {}, "CLAP": {}, "CLEAVER": {}, "COMFORT": {}, "CRAZY": {}, "CRY": {},
|
||||
"CUCUMBER": {}, "DETERGENT": {}, "DIZZY": {}, "DONE": {}, "DONNOTGO": {}, "DROOL": {},
|
||||
"DROWSY": {}, "DULL": {}, "DULLSTARE": {}, "EATING": {}, "EMBARRASSED": {}, "ENOUGH": {},
|
||||
"ERROR": {}, "EYESCLOSED": {}, "FACEPALM": {}, "FINGERHEART": {}, "FISTBUMP": {},
|
||||
"FOLLOWME": {}, "FROWN": {}, "GIFT": {}, "GLANCE": {}, "GOODJOB": {}, "HAMMER": {},
|
||||
"HAUGHTY": {}, "HEADSET": {}, "HEART": {}, "HEARTBROKEN": {}, "HIGHFIVE": {}, "HUG": {},
|
||||
"HUSKY": {}, "INNOCENTSMILE": {}, "JIAYI": {}, "JOYFUL": {}, "KISS": {}, "LAUGH": {},
|
||||
"LIPS": {}, "LOL": {}, "LOOKDOWN": {}, "LOVE": {}, "MONEY": {}, "MUSCLE": {},
|
||||
"NOSEPICK": {}, "OBSESSED": {}, "OK": {}, "PARTY": {}, "PETRIFIED": {}, "POOP": {},
|
||||
"PRAISE": {}, "PROUD": {}, "PUKE": {}, "RAINBOWPUKE": {}, "ROSE": {}, "SALUTE": {},
|
||||
"SCOWL": {}, "SHAKE": {}, "SHHH": {}, "SHOCKED": {}, "SHOWOFF": {}, "SHY": {}, "SICK": {},
|
||||
"SILENT": {}, "SKULL": {}, "SLAP": {}, "SLEEP": {}, "SLIGHT": {}, "SMART": {}, "SMILE": {},
|
||||
"SMIRK": {}, "SMOOCH": {}, "SMUG": {}, "SOB": {}, "SPEECHLESS": {}, "SPITBLOOD": {},
|
||||
"STRIVE": {}, "SWEAT": {}, "TEARS": {}, "TEASE": {}, "TERROR": {}, "THANKS": {},
|
||||
"THINKING": {}, "THUMBSUP": {}, "TOASTED": {}, "TONGUE": {}, "TRICK": {}, "UPPERLEFT": {},
|
||||
"WAIL": {}, "WAVE": {}, "WELLDONE": {}, "WHAT": {}, "WHIMPER": {}, "WINK": {}, "WITTY": {},
|
||||
"WOW": {}, "WRONGED": {}, "XBLUSH": {}, "YAWN": {}, "YEAH": {}, "FIREWORKS": {}, "BULL": {},
|
||||
"CALF": {}, "AWESOMEN": {}, "2021": {}, "CANDIEDHAWS": {}, "REDPACKET": {}, "FORTUNE": {},
|
||||
"LUCK": {}, "FIRECRACKER": {}, "Yes": {}, "No": {}, "Get": {}, "LGTM": {}, "Lemon": {},
|
||||
"EatingFood": {}, "Hundred": {}, "MinusOne": {}, "ThumbsDown": {}, "Fire": {}, "OKR": {},
|
||||
"Drumstick": {}, "BubbleTea": {}, "Loudspeaker": {}, "Pin": {}, "Coffee": {}, "Alarm": {},
|
||||
"Trophy": {}, "Music": {}, "Typing": {}, "Pepper": {}, "CheckMark": {}, "CrossMark": {},
|
||||
}
|
||||
|
||||
type driveReactReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
ReplyID string
|
||||
ReactionType string
|
||||
Action string
|
||||
}
|
||||
|
||||
func (s driveReactReplySpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"action": s.Action,
|
||||
"reaction_type": s.ReactionType,
|
||||
"reply_id": s.ReplyID,
|
||||
}
|
||||
}
|
||||
|
||||
// DriveReactReply adds or removes an emoji reaction on a comment reply
|
||||
// through the Drive comment reaction API (POST /drive/v2/files/:file_token/
|
||||
// comments/reaction), while accepting Wiki URLs/tokens and resolving them to
|
||||
// the underlying object.
|
||||
var DriveReactReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+react-reply",
|
||||
Description: "Add or remove an emoji reaction on a comment reply for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveReactReplyOp),
|
||||
common.Flag{Name: "reply-id", Desc: "reply ID to react to (from drive +list-replies); the root reply carries the comment body", Required: true},
|
||||
common.Flag{Name: "emoji", Desc: "reaction_type value, case-sensitive, e.g. THUMBSUP, HEART, DONE, OK", Required: true},
|
||||
common.Flag{Name: "action", Desc: "add attaches the reaction; delete removes the current identity's reaction", Required: true, Enum: []string{driveReactReplyActionAdd, driveReactReplyActionDelete}},
|
||||
),
|
||||
Tips: []string{
|
||||
"Reply IDs come from `drive +list-replies` (items[].reply_id); reacting to the root reply reacts to the comment itself.",
|
||||
"--emoji is case-sensitive and validated locally against the platform reaction_type list (the server accepts and persists arbitrary strings as broken reactions); the full list is in the lark-drive reactions guide.",
|
||||
"Read reactions back via --need-reaction on `drive +list-replies` / `drive +batch-query-comments`; entries with count=0 are leftovers of removed reactions — filter by count>0.",
|
||||
"add and delete are idempotent: re-adding an existing reaction or deleting an absent one succeeds without change. delete only cancels the current identity's reaction.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveReactReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveReactReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveReactReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveReactReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveReactReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Reaction %s (%s) on reply %s in %s...\n", spec.Action, spec.ReactionType, spec.ReplyID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf("/open-apis/drive/v2/files/%s/comments/reaction", validate.EncodePathSegment(target.FileToken))
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"reply_id": spec.ReplyID,
|
||||
"reaction_type": spec.ReactionType,
|
||||
"action": spec.Action,
|
||||
"updated": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveReactReplySpec(runtime *common.RuntimeContext) (driveReactReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveReactReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveReactReplySpec{}, err
|
||||
}
|
||||
replyID := strings.TrimSpace(runtime.Str("reply-id"))
|
||||
if replyID == "" {
|
||||
return driveReactReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reply-id must not be empty").WithParam("--reply-id")
|
||||
}
|
||||
reactionType, err := parseDriveReactReplyEmoji(runtime.Str("emoji"))
|
||||
if err != nil {
|
||||
return driveReactReplySpec{}, err
|
||||
}
|
||||
action, err := parseDriveReactReplyAction(runtime.Str("action"))
|
||||
if err != nil {
|
||||
return driveReactReplySpec{}, err
|
||||
}
|
||||
return driveReactReplySpec{
|
||||
Ref: ref,
|
||||
ReplyID: replyID,
|
||||
ReactionType: reactionType,
|
||||
Action: action,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseDriveReactReplyEmoji validates the reaction_type against the platform
|
||||
// enum. Case matters: the wire values mix all-caps and CamelCase (THUMBSUP vs
|
||||
// ThumbsDown), and the server persists any unknown string as a broken
|
||||
// reaction instead of rejecting it.
|
||||
func parseDriveReactReplyEmoji(raw string) (string, error) {
|
||||
emoji := strings.TrimSpace(raw)
|
||||
if emoji == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji must not be empty").WithParam("--emoji")
|
||||
}
|
||||
if _, ok := driveReactReplyReactionTypes[emoji]; !ok {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unknown --emoji %q; reaction_type values are case-sensitive (e.g. THUMBSUP, HEART, DONE, OK) — see the lark-drive reactions guide for the full list",
|
||||
emoji,
|
||||
).WithParam("--emoji")
|
||||
}
|
||||
return emoji, nil
|
||||
}
|
||||
|
||||
// parseDriveReactReplyAction normalizes and validates the --action value.
|
||||
// The flag's Enum already rejects unknown values from the CLI, so the error
|
||||
// branch only guards direct callers.
|
||||
func parseDriveReactReplyAction(raw string) (string, error) {
|
||||
action := strings.ToLower(strings.TrimSpace(raw))
|
||||
if action != driveReactReplyActionAdd && action != driveReactReplyActionDelete {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --action %q; allowed: %s, %s", raw, driveReactReplyActionAdd, driveReactReplyActionDelete).WithParam("--action")
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func buildDriveReactReplyDryRun(spec driveReactReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> update reply reaction").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
POST("/open-apis/drive/v2/files/<obj_token from step 1>/comments/reaction").
|
||||
Desc("[2] Add or remove the reaction on the resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: update reply reaction").
|
||||
POST("/open-apis/drive/v2/files/:file_token/comments/reaction").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
386
shortcuts/drive/drive_react_reply_test.go
Normal file
386
shortcuts/drive/drive_react_reply_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveReactReplyExecuteDocxAdd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := mustStringField(t, body, "action", "request.action"); got != "add" {
|
||||
t.Fatalf("request action = %q, want add", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "THUMBSUP" {
|
||||
t.Fatalf("request reaction_type = %q, want THUMBSUP", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reply_id", "request.reply_id"); got != "reply_1" {
|
||||
t.Fatalf("request reply_id = %q, want reply_1", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_1" {
|
||||
t.Fatalf("reply_id = %q, want reply_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reaction_type", "data.reaction_type"); got != "THUMBSUP" {
|
||||
t.Fatalf("reaction_type = %q, want THUMBSUP", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "action", "data.action"); got != "add" {
|
||||
t.Fatalf("action = %q, want add", got)
|
||||
}
|
||||
if got := data["updated"]; got != true {
|
||||
t.Fatalf("updated = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyExecuteViaWikiDelete(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "slidesFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v2/files/slidesFromWiki/comments/reaction",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "slides" {
|
||||
t.Errorf("file_type = %q, want slides", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "ThumbsDown",
|
||||
"--action", "delete",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := mustStringField(t, body, "action", "request.action"); got != "delete" {
|
||||
t.Fatalf("request action = %q, want delete", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "ThumbsDown" {
|
||||
t.Fatalf("request reaction_type = %q, want ThumbsDown (case preserved)", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "slides" {
|
||||
t.Fatalf("file_type = %q, want slides", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "empty reply id",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", " ",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: "--reply-id must not be empty",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "unknown emoji",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "FOOBAR",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: `unknown --emoji "FOOBAR"`,
|
||||
wantParam: "--emoji",
|
||||
},
|
||||
{
|
||||
name: "emoji is case sensitive",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "thumbsup",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: `unknown --emoji "thumbsup"`,
|
||||
wantParam: "--emoji",
|
||||
},
|
||||
{
|
||||
name: "invalid action",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "toggle",
|
||||
},
|
||||
wantErr: `invalid value "toggle" for --action`,
|
||||
wantParam: "--action",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
},
|
||||
wantErr: "reply reaction supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveReactReply, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveReactReplyEmoji(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := []string{"THUMBSUP", "ThumbsDown", "Yes", "2021", " HEART "}
|
||||
for _, in := range valid {
|
||||
got, err := parseDriveReactReplyEmoji(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDriveReactReplyEmoji(%q) unexpected error: %v", in, err)
|
||||
}
|
||||
if got != strings.TrimSpace(in) {
|
||||
t.Fatalf("parseDriveReactReplyEmoji(%q) = %q, want %q", in, got, strings.TrimSpace(in))
|
||||
}
|
||||
}
|
||||
|
||||
for _, in := range []string{"", " ", "YES", "heart", "THUMBS_UP"} {
|
||||
if _, err := parseDriveReactReplyEmoji(in); err == nil {
|
||||
t.Fatalf("parseDriveReactReplyEmoji(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveReactReplyAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for in, want := range map[string]string{"add": "add", " DELETE ": "delete", "Add": "add"} {
|
||||
got, err := parseDriveReactReplyAction(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDriveReactReplyAction(%q) unexpected error: %v", in, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("parseDriveReactReplyAction(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := parseDriveReactReplyAction("toggle"); err == nil {
|
||||
t.Fatal("parseDriveReactReplyAction(toggle) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069301,
|
||||
"msg": "reply not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "reply not found") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
assertDriveCommentAPIError(t, err, 1069301)
|
||||
}
|
||||
|
||||
func TestDriveReactReplyWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "THUMBSUP",
|
||||
"--action", "add",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "HEART",
|
||||
"--action", "add",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "POST" {
|
||||
t.Fatalf("api[0].method = %q, want POST", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/drive/v2/files/docxResource/comments/reaction") {
|
||||
t.Fatalf("api[0].url = %q, want v2 reaction path", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if got := mustStringField(t, body, "reaction_type", "api[0].body.reaction_type"); got != "HEART" {
|
||||
t.Fatalf("body.reaction_type = %q, want HEART", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "action", "api[0].body.action"); got != "add" {
|
||||
t.Fatalf("body.action = %q, want add", got)
|
||||
}
|
||||
if got := mustStringField(t, body, "reply_id", "api[0].body.reply_id"); got != "reply_1" {
|
||||
t.Fatalf("body.reply_id = %q, want reply_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveReactReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveReactReply, []string{
|
||||
"+react-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--reply-id", "reply_1",
|
||||
"--emoji", "OK",
|
||||
"--action", "delete",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
|
||||
t.Fatalf("api[1].method = %q, want POST", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/reaction") {
|
||||
t.Fatalf("api[1].url = %q, want reaction path", got)
|
||||
}
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := mustStringField(t, body, "action", "api[1].body.action"); got != "delete" {
|
||||
t.Fatalf("api[1].body.action = %q, want delete", got)
|
||||
}
|
||||
}
|
||||
168
shortcuts/drive/drive_resolve_comment.go
Normal file
168
shortcuts/drive/drive_resolve_comment.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveCommentSolvedSpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
Solved bool
|
||||
}
|
||||
|
||||
func (s driveCommentSolvedSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"is_solved": s.Solved,
|
||||
}
|
||||
}
|
||||
|
||||
// driveCommentSolvedConfig parameterizes the two solved-state shortcuts:
|
||||
// they share one PATCH endpoint whose body is only {is_solved}, so the
|
||||
// commands differ solely in direction and wording.
|
||||
type driveCommentSolvedConfig struct {
|
||||
Command string
|
||||
Description string
|
||||
Label string // driveCommentOp label used in unsupported-type errors
|
||||
Action string // echoed in output and dry-run descriptions
|
||||
Verb string // progress-line verb
|
||||
Solved bool
|
||||
Tip string // direction-specific tip (counterpart pointer)
|
||||
}
|
||||
|
||||
// DriveResolveComment marks a comment solved through the Drive comment patch
|
||||
// API, while accepting Wiki URLs/tokens and resolving them to the underlying
|
||||
// object. Reopening is the separate +restore-comment command.
|
||||
var DriveResolveComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
|
||||
Command: "+resolve-comment",
|
||||
Description: "Resolve (mark solved) a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Label: "comment resolve",
|
||||
Action: "resolve",
|
||||
Verb: "Resolving",
|
||||
Solved: true,
|
||||
Tip: "To reopen a solved comment, use `drive +restore-comment`.",
|
||||
})
|
||||
|
||||
// DriveRestoreComment reopens a solved comment through the same Drive comment
|
||||
// patch API (is_solved=false).
|
||||
var DriveRestoreComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
|
||||
Command: "+restore-comment",
|
||||
Description: "Restore (reopen) a solved comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Label: "comment restore",
|
||||
Action: "restore",
|
||||
Verb: "Restoring",
|
||||
Solved: false,
|
||||
Tip: "To mark a comment solved, use `drive +resolve-comment`.",
|
||||
})
|
||||
|
||||
func newDriveCommentSolvedShortcut(cfg driveCommentSolvedConfig) common.Shortcut {
|
||||
op := driveCommentOp{
|
||||
Label: cfg.Label,
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
readSpec := func(runtime *common.RuntimeContext) (driveCommentSolvedSpec, error) {
|
||||
ref, err := resolveDriveCommentInput(op, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveCommentSolvedSpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveCommentSolvedSpec{}, err
|
||||
}
|
||||
return driveCommentSolvedSpec{Ref: ref, CommentID: commentID, Solved: cfg.Solved}, nil
|
||||
}
|
||||
|
||||
return common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: cfg.Command,
|
||||
Description: cfg.Description,
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(op),
|
||||
common.Flag{Name: "comment-id", Desc: fmt.Sprintf("comment ID to %s (from drive +list-comments)", cfg.Action), Required: true},
|
||||
),
|
||||
Tips: []string{
|
||||
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
|
||||
cfg.Tip,
|
||||
"Back-to-back solved-state flips on the same comment can hit server rate limiting (HTTP 429); space out consecutive calls or retry after a short delay.",
|
||||
"Wiki URLs/tokens are resolved to the underlying document automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveCommentSolvedDryRun(cfg, spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, op, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s comment %s in %s...\n", cfg.Verb, spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
)
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"PATCH",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"action": cfg.Action,
|
||||
"is_solved": spec.Solved,
|
||||
"updated": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveCommentSolvedDryRun(cfg driveCommentSolvedConfig, spec driveCommentSolvedSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc(fmt.Sprintf("2-step orchestration: resolve wiki -> %s comment", cfg.Action)).
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
PATCH("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id").
|
||||
Desc(fmt.Sprintf("[2] %s comment (is_solved=%t) on resolved document", cfg.Verb, cfg.Solved)).
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(fmt.Sprintf("1-step request: %s comment (is_solved=%t)", cfg.Action, cfg.Solved)).
|
||||
PATCH("/open-apis/drive/v1/files/:file_token/comments/:comment_id").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID)
|
||||
}
|
||||
376
shortcuts/drive/drive_resolve_comment_test.go
Normal file
376
shortcuts/drive/drive_resolve_comment_test.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveResolveCommentExecute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["is_solved"]; got != true {
|
||||
t.Fatalf("request is_solved = %#v, want true", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "action", "data.action"); got != "resolve" {
|
||||
t.Fatalf("action = %q, want resolve", got)
|
||||
}
|
||||
if got := data["is_solved"]; got != true {
|
||||
t.Fatalf("is_solved = %#v, want true", got)
|
||||
}
|
||||
if got := data["updated"]; got != true {
|
||||
t.Fatalf("updated = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveRestoreCommentExecuteViaWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_9",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveRestoreComment, []string{
|
||||
"+restore-comment",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_9",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
if got := body["is_solved"]; got != false {
|
||||
t.Fatalf("request is_solved = %#v, want false", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "action", "data.action"); got != "restore" {
|
||||
t.Fatalf("action = %q, want restore", got)
|
||||
}
|
||||
if got := data["is_solved"]; got != false {
|
||||
t.Fatalf("is_solved = %#v, want false", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentExecuteWikiResolvesToBitable(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "baseFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/baseFromWiki/comments/comment_3",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "bitable" {
|
||||
t.Errorf("file_type = %q, want bitable", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_3",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("file_type = %q, want bitable", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCommentSolvedValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
shortcut: "resolve",
|
||||
args: []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "empty comment id",
|
||||
shortcut: "resolve",
|
||||
args: []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", " ",
|
||||
},
|
||||
wantErr: "must not be empty",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "restore rejects unsupported url type",
|
||||
shortcut: "restore",
|
||||
args: []string{
|
||||
"+restore-comment",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
},
|
||||
wantErr: "comment restore supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
shortcut := DriveResolveComment
|
||||
if tt.shortcut == "restore" {
|
||||
shortcut = DriveRestoreComment
|
||||
}
|
||||
err := mountAndRunDrive(t, shortcut, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentInputConflict(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--token", "docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual-exclusion error, got %v", err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, "--url")
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069303,
|
||||
"msg": "no comment permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "no comment permission") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentPropagatesWikiResolveError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230005,
|
||||
"msg": "wiki node not found",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki node not found") {
|
||||
t.Fatalf("expected wiki resolve error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveResolveCommentDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveResolveComment, []string{
|
||||
"+resolve-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PATCH" {
|
||||
t.Fatalf("api[1].method = %q, want PATCH", got)
|
||||
}
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := body["is_solved"]; got != true {
|
||||
t.Fatalf("api[1].body.is_solved = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveRestoreCommentDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveRestoreComment, []string{
|
||||
"+restore-comment",
|
||||
"--url", "https://example.larksuite.com/sheets/sheetResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "PATCH" {
|
||||
t.Fatalf("api[0].method = %q, want PATCH", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/sheetResource/comments/comment_1") {
|
||||
t.Fatalf("api[0].url = %q, want resolved file and comment tokens", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
if got := body["is_solved"]; got != false {
|
||||
t.Fatalf("api[0].body.is_solved = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveRestoreCommentDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveRestoreComment, []string{
|
||||
"+restore-comment",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
body := mustMapValue(t, step2["body"], "api[1].body")
|
||||
if got := body["is_solved"]; got != false {
|
||||
t.Fatalf("api[1].body.is_solved = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -25,7 +26,46 @@ const (
|
||||
secureLabelOperationUpdate secureLabelOperation = "update"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
type secureLabelResourceKind struct {
|
||||
Type string
|
||||
Path string
|
||||
}
|
||||
|
||||
// secureLabelResourceKinds is intentionally independent from apply-permission:
|
||||
// the two endpoints accept different resource type contracts.
|
||||
var secureLabelResourceKinds = []secureLabelResourceKind{
|
||||
{Type: "doc", Path: "/doc/"},
|
||||
{Type: "sheet", Path: "/sheets/"},
|
||||
{Type: "file", Path: "/file/"},
|
||||
{Type: "wiki", Path: "/wiki/"},
|
||||
{Type: "bitable", Path: "/base/"},
|
||||
{Type: "bitable", Path: "/bitable/"},
|
||||
{Type: "docx", Path: "/docx/"},
|
||||
{Type: "mindnote", Path: "/mindnote/"},
|
||||
{Type: "slides", Path: "/slides/"},
|
||||
}
|
||||
|
||||
var secureLabelTypes = func() []string {
|
||||
types := make([]string, 0, len(secureLabelResourceKinds))
|
||||
seen := make(map[string]struct{}, len(secureLabelResourceKinds))
|
||||
for _, resourceKind := range secureLabelResourceKinds {
|
||||
if _, ok := seen[resourceKind.Type]; ok {
|
||||
continue
|
||||
}
|
||||
seen[resourceKind.Type] = struct{}{}
|
||||
types = append(types, resourceKind.Type)
|
||||
}
|
||||
return types
|
||||
}()
|
||||
|
||||
func secureLabelTypeAllowed(docType string) bool {
|
||||
for _, allowedType := range secureLabelTypes {
|
||||
if docType == allowedType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
var DriveSecureLabelList = common.Shortcut{
|
||||
@@ -81,6 +121,7 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
|
||||
"When --token is a URL, its path determines --type; a conflicting --type is rejected.",
|
||||
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
|
||||
"When updating many files, serialize requests and back off on rate_limit errors.",
|
||||
},
|
||||
@@ -146,8 +187,94 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
|
||||
return params
|
||||
}
|
||||
|
||||
// resolveSecureLabelTarget owns secure-label URL inference and type errors so
|
||||
// changes to another endpoint cannot widen this command's accepted resources.
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
raw = strings.TrimSpace(raw)
|
||||
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && !secureLabelTypeAllowed(explicitType) {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(secureLabelTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
ref, ok := parseSecureLabelResourceURL(raw)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
token, docType = ref.Token, ref.Type
|
||||
if explicitType != "" && explicitType != docType {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
docType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
} else {
|
||||
token = raw
|
||||
docType = explicitType
|
||||
}
|
||||
|
||||
if docType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(secureLabelTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validateSecureLabelToken(token); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return token, docType, nil
|
||||
}
|
||||
|
||||
func parseSecureLabelResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
escapedPath := parsed.EscapedPath()
|
||||
for _, resourceKind := range secureLabelResourceKinds {
|
||||
if !strings.HasPrefix(escapedPath, resourceKind.Path) {
|
||||
continue
|
||||
}
|
||||
escapedToken := strings.TrimSuffix(strings.TrimPrefix(escapedPath, resourceKind.Path), "/")
|
||||
if escapedToken == "" || strings.Contains(escapedToken, "/") {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
token, err := url.PathUnescape(escapedToken)
|
||||
if err != nil || token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: resourceKind.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func validateSecureLabelToken(token string) error {
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
if token == "." || strings.Contains(token, "/") {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token must be a non-dot single path segment",
|
||||
).WithParam("--token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSecureLabelID trims a label id and rejects display names before the
|
||||
|
||||
@@ -159,6 +159,256 @@ func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecureLabelTarget_URLAndBareToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
explicitType string
|
||||
wantToken string
|
||||
wantType string
|
||||
}{
|
||||
{"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"},
|
||||
{"docx URL", "https://example.feishu.cn/docx/doxTok", "", "doxTok", "docx"},
|
||||
{"sheet URL", "https://example.feishu.cn/sheets/shtTok", "", "shtTok", "sheet"},
|
||||
{"base URL", "https://example.feishu.cn/base/basTok", "", "basTok", "bitable"},
|
||||
{"bitable URL", "https://example.feishu.cn/bitable/bitTok", "", "bitTok", "bitable"},
|
||||
{"file URL", "https://example.feishu.cn/file/boxTok", "", "boxTok", "file"},
|
||||
{"mindnote URL", "https://example.feishu.cn/mindnote/mndTok", "", "mndTok", "mindnote"},
|
||||
{"slides URL", "https://example.feishu.cn/slides/sldTok", "", "sldTok", "slides"},
|
||||
{"legacy doc URL", "https://example.feishu.cn/doc/docTok", "", "docTok", "doc"},
|
||||
{"bare token with explicit type", "doxBareTok", "docx", "doxBareTok", "docx"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolveSecureLabelTarget(tt.raw, tt.explicitType)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve target: %v", err)
|
||||
}
|
||||
if token != tt.wantToken || docType != tt.wantType {
|
||||
t.Fatalf("token/type = %q/%q, want %q/%q", token, docType, tt.wantToken, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecureLabelTarget_RejectsUnsafeOrAmbiguousTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
explicitType string
|
||||
wantParam string
|
||||
}{
|
||||
{"bare traversal token", "..", "docx", "--token"},
|
||||
{"bare dot token", ".", "docx", "--token"},
|
||||
{"URL traversal token", "https://example.feishu.cn/docx/../victim", "", "--token"},
|
||||
{"marker outside resource root", "https://example.feishu.cn/share/docx/doxUnexpected", "", "--token"},
|
||||
{"encoded path separator", "https://example.feishu.cn/docx/doxTarget%2Fother", "", "--token"},
|
||||
{"encoded fragment separator", "https://example.feishu.cn/docx/doxTarget%23other", "", "--token"},
|
||||
{"conflicting URL type", "https://example.feishu.cn/docx/doxTok", "wiki", "--type"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := resolveSecureLabelTarget(tt.raw, tt.explicitType)
|
||||
if err == nil {
|
||||
t.Fatalf("resolveSecureLabelTarget(%q, %q) unexpectedly succeeded", tt.raw, tt.explicitType)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecureLabelTarget_RejectsInvalidInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "empty token",
|
||||
raw: " \t ",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "apps page URL is unsupported",
|
||||
raw: "https://example.feishu.cn/page/appMetaTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "could not infer token from URL",
|
||||
},
|
||||
{
|
||||
name: "bare token requires type",
|
||||
raw: "doxBareTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required when --token is a bare token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolveSecureLabelTarget(tt.raw, "")
|
||||
if err == nil {
|
||||
t.Fatal("resolve target error = nil, want validation error")
|
||||
}
|
||||
if token != "" || docType != "" {
|
||||
t.Fatalf("token/type = %q/%q, want empty values", token, docType)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %q/%q, want %q/%q",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
errs.CategoryValidation,
|
||||
errs.SubtypeInvalidArgument,
|
||||
)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want message containing %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RejectsAppsTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "apps page URL",
|
||||
args: []string{
|
||||
"--token", "https://example.feishu.cn/page/appMetaTok",
|
||||
},
|
||||
wantMessage: "could not infer token from URL",
|
||||
},
|
||||
{
|
||||
name: "explicit apps type",
|
||||
args: []string{
|
||||
"--token", "appBareTok",
|
||||
"--type", "apps",
|
||||
},
|
||||
wantMessage: `invalid value "apps" for --type`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
args := append([]string{
|
||||
"+secure-label-update",
|
||||
}, tt.args...)
|
||||
args = append(args,
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
)
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want message containing %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RejectsURLMarkersOutsidePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "query",
|
||||
url: "https://example.feishu.cn/share?redirect=/docx/doxQueryTok",
|
||||
},
|
||||
{
|
||||
name: "fragment",
|
||||
url: "https://example.feishu.cn/share#/docx/doxFragmentTok",
|
||||
},
|
||||
{
|
||||
name: "empty host",
|
||||
url: "https:///docx/doxNoHostTok",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", tt.url,
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected URL validation error for %q", tt.url)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %q/%q, want %q/%q",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
errs.CategoryValidation,
|
||||
errs.SubtypeInvalidArgument,
|
||||
)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--token" {
|
||||
t.Fatalf("error param = %q, want %q", validationErr.Param, "--token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
|
||||
152
shortcuts/drive/drive_update_reply.go
Normal file
152
shortcuts/drive/drive_update_reply.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveUpdateReplyOp = driveCommentOp{
|
||||
Label: "reply update",
|
||||
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
|
||||
}
|
||||
|
||||
type driveUpdateReplySpec struct {
|
||||
Ref driveCommentRef
|
||||
CommentID string
|
||||
ReplyID string
|
||||
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
|
||||
}
|
||||
|
||||
func (s driveUpdateReplySpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"elements": driveReplyV1Elements(s.ReplyElements),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveUpdateReply replaces the content of an existing comment reply through
|
||||
// the Drive comment reply update API (PUT .../comments/:comment_id/replies/:reply_id),
|
||||
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
|
||||
var DriveUpdateReply = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+update-reply",
|
||||
Description: "Update the content of a comment reply on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.comment:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: append(driveCommentTargetFlags(driveUpdateReplyOp),
|
||||
common.Flag{Name: "comment-id", Desc: "comment ID that owns the reply (from drive +list-comments)", Required: true},
|
||||
common.Flag{Name: "reply-id", Desc: "reply ID to update (from drive +list-replies)", Required: true},
|
||||
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
),
|
||||
Tips: []string{
|
||||
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
|
||||
"The update replaces the whole reply content; there is no partial edit.",
|
||||
"Reply IDs come from `drive +list-replies` (items[].reply_id); updating a comment's root reply rewrites the comment body itself.",
|
||||
"Only the identity that created a reply can update it; other identities get API error 1069303 (forbidden). Use the same --as identity that created the reply.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveUpdateReplySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveUpdateReplySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveUpdateReplyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveUpdateReplySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveCommentTarget(ctx, runtime, driveUpdateReplyOp, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Updating reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
|
||||
validate.EncodePathSegment(target.FileToken),
|
||||
validate.EncodePathSegment(spec.CommentID),
|
||||
validate.EncodePathSegment(spec.ReplyID),
|
||||
)
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"PUT",
|
||||
path,
|
||||
map[string]interface{}{"file_type": target.FileType},
|
||||
spec.RequestBody(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
|
||||
"comment_id": spec.CommentID,
|
||||
"reply_id": spec.ReplyID,
|
||||
"updated": true,
|
||||
}), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveUpdateReplySpec(runtime *common.RuntimeContext) (driveUpdateReplySpec, error) {
|
||||
ref, err := resolveDriveCommentInput(driveUpdateReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
commentID := strings.TrimSpace(runtime.Str("comment-id"))
|
||||
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
replyID := strings.TrimSpace(runtime.Str("reply-id"))
|
||||
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return driveUpdateReplySpec{}, err
|
||||
}
|
||||
return driveUpdateReplySpec{
|
||||
Ref: ref,
|
||||
CommentID: commentID,
|
||||
ReplyID: replyID,
|
||||
ReplyElements: replyElements,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDriveUpdateReplyDryRun(spec driveUpdateReplySpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> update comment reply").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
PUT("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
|
||||
Desc("[2] Update reply content on resolved document").
|
||||
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: update comment reply").
|
||||
PUT("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
|
||||
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
|
||||
Body(spec.RequestBody()).
|
||||
Set("file_token", spec.Ref.Token).
|
||||
Set("comment_id", spec.CommentID).
|
||||
Set("reply_id", spec.ReplyID)
|
||||
}
|
||||
356
shortcuts/drive/drive_update_reply_test.go
Normal file
356
shortcuts/drive/drive_update_reply_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveUpdateReplyExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"更新后的回复"},{"type":"mention_user","mention_user":"ou_123"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v", err)
|
||||
}
|
||||
content := mustMapValue(t, body["content"], "request.content")
|
||||
elements := mustSliceValue(t, content["elements"], "request.content.elements")
|
||||
if len(elements) != 2 {
|
||||
t.Fatalf("len(request.content.elements) = %d, want 2", len(elements))
|
||||
}
|
||||
first := mustMapValue(t, elements[0], "request.content.elements[0]")
|
||||
if got := mustStringField(t, first, "type", "request.content.elements[0].type"); got != "text_run" {
|
||||
t.Fatalf("request element type = %q, want text_run", got)
|
||||
}
|
||||
firstText := mustMapValue(t, first["text_run"], "request.content.elements[0].text_run")
|
||||
if got := mustStringField(t, firstText, "text", "request.content.elements[0].text_run.text"); got != "更新后的回复" {
|
||||
t.Fatalf("text_run.text = %q, want 更新后的回复", got)
|
||||
}
|
||||
second := mustMapValue(t, elements[1], "request.content.elements[1]")
|
||||
if got := mustStringField(t, second, "type", "request.content.elements[1].type"); got != "person" {
|
||||
t.Fatalf("request element type = %q, want person", got)
|
||||
}
|
||||
secondPerson := mustMapValue(t, second["person"], "request.content.elements[1].person")
|
||||
if got := mustStringField(t, secondPerson, "user_id", "request.content.elements[1].person.user_id"); got != "ou_123" {
|
||||
t.Fatalf("person.user_id = %q, want ou_123", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
|
||||
t.Fatalf("comment_id = %q, want comment_1", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
|
||||
t.Fatalf("reply_id = %q, want reply_2", got)
|
||||
}
|
||||
if got := data["updated"]; got != true {
|
||||
t.Fatalf("updated = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyExecuteViaWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "sheetFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/comment_1/replies/reply_2",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("file_type"); got != "sheet" {
|
||||
t.Errorf("file_type = %q, want sheet", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"updated from wiki"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
|
||||
t.Fatalf("file_type = %q, want sheet", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
|
||||
t.Fatalf("wiki_token = %q, want wikiResource", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The reply-update endpoint does not declare the 100-element cap (only
|
||||
// reply-create does), so +update-reply must NOT reject >100 elements locally.
|
||||
func TestDriveUpdateReplyDoesNotCapElements(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
elems := make([]string, 101)
|
||||
for i := range elems {
|
||||
elems[i] = `{"type":"text","text":"x"}`
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", "[" + strings.Join(elems, ",") + "]",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("+update-reply must not cap element count locally, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "unsafe comment id",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "../admin",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--comment-id",
|
||||
},
|
||||
{
|
||||
name: "unsafe reply id",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "../reply",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "path traversal",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "empty reply id",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", " ",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "--reply-id must not be empty",
|
||||
wantParam: "--reply-id",
|
||||
},
|
||||
{
|
||||
name: "invalid content json",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `not-json`,
|
||||
},
|
||||
wantErr: "--content is not valid JSON",
|
||||
wantParam: "--content",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
args: []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/drive/folder/folderResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
},
|
||||
wantErr: "reply update supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
|
||||
wantParam: "--url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, append(tt.args, "--as", "user"), f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCommentValidationError(t, err, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyPropagatesAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1069307,
|
||||
"msg": "no permission to edit reply",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "no permission to edit reply") {
|
||||
t.Fatalf("expected API error to propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyWikiNodeIncompleteResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_token": "tokenOnly"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete-node error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyDryRunDirect(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/docx/docxResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"更新后的回复"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, call, "method", "api[0].method"); got != "PUT" {
|
||||
t.Fatalf("api[0].method = %q, want PUT", got)
|
||||
}
|
||||
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[0].url = %q, want resolved path segments", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
content := mustMapValue(t, body["content"], "api[0].body.content")
|
||||
elements := mustSliceValue(t, content["elements"], "api[0].body.content.elements")
|
||||
if len(elements) != 1 {
|
||||
t.Fatalf("api[0].body.content.elements length = %d, want 1", len(elements))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUpdateReplyDryRunWiki(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveUpdateReply, []string{
|
||||
"+update-reply",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiResource",
|
||||
"--comment-id", "comment_1",
|
||||
"--reply-id", "reply_2",
|
||||
"--content", `[{"type":"text","text":"x"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
step1 := mustMapValue(t, api[0], "api[0]")
|
||||
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
|
||||
t.Fatalf("api[0].url = %q, want wiki get_node", got)
|
||||
}
|
||||
step2 := mustMapValue(t, api[1], "api[1]")
|
||||
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PUT" {
|
||||
t.Fatalf("api[1].method = %q, want PUT", got)
|
||||
}
|
||||
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
|
||||
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveCover,
|
||||
DriveAddComment,
|
||||
DriveListComments,
|
||||
DriveBatchQueryComments,
|
||||
DriveResolveComment,
|
||||
DriveRestoreComment,
|
||||
DriveAddReply,
|
||||
DriveListReplies,
|
||||
DriveUpdateReply,
|
||||
DriveDeleteReply,
|
||||
DriveReactReply,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
|
||||
@@ -23,6 +23,14 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+cover",
|
||||
"+add-comment",
|
||||
"+list-comments",
|
||||
"+batch-query-comments",
|
||||
"+resolve-comment",
|
||||
"+restore-comment",
|
||||
"+add-reply",
|
||||
"+list-replies",
|
||||
"+update-reply",
|
||||
"+delete-reply",
|
||||
"+react-reply",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
|
||||
markdownUploadFinishAction = "finalize markdown multipart upload failed"
|
||||
markdownFetchNameAction = "fetch existing markdown file name failed"
|
||||
markdownSourceFilePreviewType = "16"
|
||||
)
|
||||
|
||||
var markdownUploadRetryBackoffs = []time.Duration{
|
||||
@@ -192,9 +193,14 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd
|
||||
}
|
||||
|
||||
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
|
||||
query, err := markdownSourceFilePreviewQuery("", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapMarkdownDownloadError(err)
|
||||
@@ -230,15 +236,15 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (*http.Response, string, error) {
|
||||
query, err := markdownSourceFilePreviewQuery(version, versionParam)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
req.QueryParams = larkcore.QueryParams{
|
||||
"version": []string{strings.TrimSpace(version)},
|
||||
}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: query,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, req)
|
||||
@@ -248,6 +254,58 @@ func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeCon
|
||||
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewQuery(version, versionParam string) (larkcore.QueryParams, error) {
|
||||
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := larkcore.QueryParams{
|
||||
"preview_type": []string{markdownSourceFilePreviewType},
|
||||
}
|
||||
if version != "" {
|
||||
query["version"] = []string{version}
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewDryRunParams(version, versionParam string) (map[string]interface{}, error) {
|
||||
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"preview_type": markdownSourceFilePreviewType,
|
||||
}
|
||||
if version != "" {
|
||||
params["version"] = version
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewDryRunParamsForValidatedVersion(version, versionParam string) map[string]interface{} {
|
||||
params, err := markdownSourceFilePreviewDryRunParams(version, versionParam)
|
||||
if err != nil {
|
||||
// Shortcut validation runs before DryRun. If a caller bypasses that
|
||||
// contract, preserve the supplied value instead of silently dropping it.
|
||||
params = map[string]interface{}{
|
||||
"preview_type": markdownSourceFilePreviewType,
|
||||
"version": version,
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func validateMarkdownSourceFilePreviewVersion(version, flagName string) error {
|
||||
if version == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
return nil
|
||||
}
|
||||
if flagName == "" {
|
||||
flagName = "--version"
|
||||
}
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
|
||||
@@ -112,9 +112,8 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
|
||||
}
|
||||
|
||||
func validateMarkdownDiffVersionValue(value, flagName string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
if err := validateMarkdownSourceFilePreviewVersion(value, flagName); err != nil {
|
||||
return err
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
|
||||
@@ -134,31 +133,33 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
if spec.FromVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the specified remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the specified remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the latest remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
|
||||
}
|
||||
dry.Set("local_file", spec.FilePath)
|
||||
dry.Set("mode", markdownDiffModeRemoteVsLocal)
|
||||
default:
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the base remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the base remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
|
||||
if spec.ToVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the target remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the target remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.ToVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.ToVersion, "--to-version"))
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the latest remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
|
||||
}
|
||||
dry.Set("mode", markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
@@ -166,8 +167,8 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
return dry
|
||||
}
|
||||
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version, versionParam)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -446,8 +447,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
Format: runtime.Format,
|
||||
@@ -456,8 +457,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return markdownDiffDryRun(markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
})
|
||||
@@ -465,8 +466,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
}
|
||||
@@ -487,7 +488,7 @@ var MarkdownDiff = common.Shortcut{
|
||||
} else {
|
||||
fromLabel += "@latest"
|
||||
}
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -499,17 +500,17 @@ var MarkdownDiff = common.Shortcut{
|
||||
}
|
||||
default:
|
||||
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.ToVersion != "" {
|
||||
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion, "--to-version")
|
||||
} else {
|
||||
toLabel = "b/" + spec.FileToken + "@latest"
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "", "")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -48,6 +48,73 @@ func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsBlankVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "from version",
|
||||
args: []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", " \t",
|
||||
"--file", "./local.md",
|
||||
},
|
||||
wantParam: "--from-version",
|
||||
},
|
||||
{
|
||||
name: "to version",
|
||||
args: []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", " ",
|
||||
},
|
||||
wantParam: "--to-version",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, tt.args, f, stdout)
|
||||
requireMarkdownValidationParam(t, err, tt.wantParam)
|
||||
if !strings.Contains(err.Error(), "cannot be empty") {
|
||||
t.Fatalf("expected empty version validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownSourceFilePreviewParamsValidateAndPreserveVersion(t *testing.T) {
|
||||
version := " 7633658129540910621 "
|
||||
|
||||
query, err := markdownSourceFilePreviewQuery(version, "--from-version")
|
||||
if err != nil {
|
||||
t.Fatalf("markdownSourceFilePreviewQuery() error: %v", err)
|
||||
}
|
||||
if got := query["version"]; len(got) != 1 || got[0] != version {
|
||||
t.Fatalf("query version = %#v, want original %q", got, version)
|
||||
}
|
||||
|
||||
params, err := markdownSourceFilePreviewDryRunParams(version, "--from-version")
|
||||
if err != nil {
|
||||
t.Fatalf("markdownSourceFilePreviewDryRunParams() error: %v", err)
|
||||
}
|
||||
if got := params["version"]; got != version {
|
||||
t.Fatalf("dry-run version = %#v, want original %q", got, version)
|
||||
}
|
||||
|
||||
_, err = markdownSourceFilePreviewQuery(" \n", "--from-version")
|
||||
requireMarkdownValidationParam(t, err, "--from-version")
|
||||
_, err = markdownSourceFilePreviewDryRunParams(" \t", "--to-version")
|
||||
requireMarkdownValidationParam(t, err, "--to-version")
|
||||
}
|
||||
|
||||
func TestMarkdownDiffMissingVersionAndFileNamesCandidateParams(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
@@ -79,7 +146,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
|
||||
Headers: http.Header{
|
||||
@@ -88,7 +155,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
|
||||
Headers: http.Header{
|
||||
@@ -151,7 +218,7 @@ func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\nhello old\n"),
|
||||
Headers: http.Header{
|
||||
@@ -191,7 +258,7 @@ func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
|
||||
})
|
||||
@@ -218,7 +285,7 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
@@ -337,7 +404,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
|
||||
Headers: http.Header{
|
||||
@@ -346,7 +413,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
|
||||
Headers: http.Header{
|
||||
@@ -398,13 +465,13 @@ func TestMarkdownDiffNoChangesPretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
@@ -445,8 +512,11 @@ func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
|
||||
t.Fatalf("dry-run missing download call: %s", stdout.String())
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/medias/box_md_diff/preview_download") {
|
||||
t.Fatalf("dry-run missing source preview download call: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"preview_type": "16"`) {
|
||||
t.Fatalf("dry-run missing source_file preview_type: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
|
||||
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
|
||||
|
||||
@@ -5,14 +5,10 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -47,8 +43,9 @@ var MarkdownFetch = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("download markdown source file preview artifact bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
|
||||
Set("file_token", runtime.Str("file-token"))
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath)
|
||||
@@ -61,12 +58,9 @@ var MarkdownFetch = common.Shortcut{
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
resp, err := openMarkdownDownload(ctx, runtime, fileToken)
|
||||
if err != nil {
|
||||
return wrapMarkdownDownloadError(err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ var MarkdownPatch = common.Shortcut{
|
||||
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the current Markdown content").
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the current Markdown source file preview artifact").
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
|
||||
Set("file_token", spec.FileToken).
|
||||
POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").
|
||||
|
||||
@@ -85,9 +85,12 @@ func TestMarkdownPatchDryRunLiteral(t *testing.T) {
|
||||
if got := len(dry.API); got != 6 {
|
||||
t.Fatalf("api steps = %d, want 6", got)
|
||||
}
|
||||
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
|
||||
if got := dry.API[0].URL; got != "/open-apis/drive/v1/medias/box_md_patch/preview_download" {
|
||||
t.Fatalf("download url = %q", got)
|
||||
}
|
||||
if got := dry.API[0].Params["preview_type"]; got != markdownSourceFilePreviewType {
|
||||
t.Fatalf("download preview_type = %#v", got)
|
||||
}
|
||||
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metas url = %q", got)
|
||||
}
|
||||
@@ -120,7 +123,7 @@ func TestMarkdownPatchDryRunRegex(t *testing.T) {
|
||||
if got := dry.Mode; got != markdownPatchModeRegex {
|
||||
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
|
||||
}
|
||||
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
|
||||
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown source file preview artifact") {
|
||||
t.Fatalf("download desc = %q", got)
|
||||
}
|
||||
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
|
||||
@@ -144,7 +147,7 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
@@ -187,7 +190,7 @@ func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
@@ -224,7 +227,7 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\nTODO\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -299,7 +302,7 @@ func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -360,7 +363,7 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("Version: 12\nVersion: 34\n"),
|
||||
})
|
||||
@@ -429,7 +432,7 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello world\n"),
|
||||
})
|
||||
@@ -478,7 +481,7 @@ func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello\n"),
|
||||
})
|
||||
@@ -509,9 +512,10 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter
|
||||
type markdownPatchDryRunOutput struct {
|
||||
Mode string `json:"mode"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1984,7 +1984,7 @@ func TestMarkdownFetchReturnsContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2050,7 +2050,7 @@ func TestMarkdownFetchPrettyReturnsContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2078,7 +2078,7 @@ func TestMarkdownFetchSavesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2122,7 +2122,7 @@ func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2151,7 +2151,7 @@ func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2189,7 +2189,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testi
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2226,7 +2226,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testi
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2260,7 +2260,7 @@ func TestMarkdownFetchPrettySavesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2295,7 +2295,7 @@ func TestMarkdownFetchSaveFailure(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
|
||||
358
shortcuts/sheets/batch_key_vocab_test.go
Normal file
358
shortcuts/sheets/batch_key_vocab_test.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// subOp builds a raw +batch-update sub-op for translateBatchOp tests.
|
||||
func subOp(shortcut string, input map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{"shortcut": shortcut, "input": input}
|
||||
}
|
||||
|
||||
// TestBatchOp_UnknownInputKeyRejected pins the key-vocabulary guard: an
|
||||
// off-vocabulary sub-op input key must error with a did-you-mean instead of
|
||||
// being silently ignored (silent ignore surfaced as misleading "missing
|
||||
// required flag" errors — the top batch error cluster in eval traces).
|
||||
func TestBatchOp_UnknownInputKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invented key errors with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"rangee": "A1:B2",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}), testToken, 0)
|
||||
ve := requireValidation(t, err, `unknown input key "rangee"`)
|
||||
if !strings.Contains(ve.Message, `did you mean "range"`) {
|
||||
t.Fatalf("message %q missing did-you-mean", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "input keys:") {
|
||||
t.Fatalf("hint %q missing key contract", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system flag is not sub-op vocabulary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"dry_run": true,
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, `unknown input key "dry_run"`)
|
||||
})
|
||||
|
||||
t.Run("reserved locator in hyphen form still rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"spreadsheet-token": "shtXXX",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "do not pass input.spreadsheet-token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOp_HabitualKeysRewritten pins the silent rewrites: camelCase onto
|
||||
// the declared flag, and the commandFlagAliases table (size → width/height on
|
||||
// the resize pair — the pre-2026-07 vocabulary and the styles-protocol
|
||||
// spelling, the single largest sub-op error cluster).
|
||||
func TestBatchOp_HabitualKeysRewritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("camelCase sheetName resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to width on +cols-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cols-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A:C",
|
||||
"type": "pixel",
|
||||
"size": float64(120),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
width, _ := input["resize_width"].(map[string]interface{})
|
||||
if width["value"] != 120 {
|
||||
t.Fatalf("resize_width = %v, want value 120", input["resize_width"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to height on +rows-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+rows-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "1:3",
|
||||
"type": "pixel",
|
||||
"size": float64(36),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single-entry ranges unwraps onto range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:B2" {
|
||||
t.Fatalf("range = %v, want A1:B2", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-entry ranges prescribes a split", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2", "C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "split them into 2 sub-ops")
|
||||
})
|
||||
|
||||
// A variant next to its canonical key must reject, not silently overwrite:
|
||||
// keys iterate in sorted order, so the variant's rewrite would land after
|
||||
// the canonical value was already accepted and clobber it.
|
||||
t.Run("camelCase variant alongside canonical rejects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "shadow",
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "got both")
|
||||
})
|
||||
|
||||
t.Run("ranges alongside range rejects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"ranges": []interface{}{"C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "got both")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOperations_AggregatesValidationErrors pins the one-pass contract:
|
||||
// several invalid ops come back in a single error (each with its own
|
||||
// operations[i] context) instead of the first only — eval traces show
|
||||
// fix-one-resend loops of up to 7 round trips under first-error-only.
|
||||
func TestBatchOperations_AggregatesValidationErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops both reported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-clear", map[string]interface{}{"range": "A1:B2"}), // missing sheet selector
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}), // missing cells
|
||||
subOp("+cells-clear", map[string]interface{}{"sheet_name": "S1", "range": "A1:B2"}), // valid
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"operations[0] (+cells-clear)", "operations[1] (+cells-set)", "--cells is required"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps the standalone-shaped error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}),
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "--cells is required")
|
||||
if strings.Contains(ve.Message, "failed validation") {
|
||||
t.Fatalf("single-error message must not use the aggregate wrapper: %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetInput_MatrixPrecheck pins the local cells-vs-range guard that
|
||||
// front-runs the server's mid-batch "does not match range" failures.
|
||||
func TestCellsSetInput_MatrixPrecheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
wantContains string // "" = expect success
|
||||
}{
|
||||
{
|
||||
"empty cells prescribes +cells-clear",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2", "cells": []interface{}{}},
|
||||
"+cells-clear",
|
||||
},
|
||||
{
|
||||
"row count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 1 rows but --range \"A1:B3\" spans 3 rows",
|
||||
},
|
||||
{
|
||||
"column count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"has 1 columns but --range \"A1:B1\" spans 2 columns",
|
||||
},
|
||||
{
|
||||
"matching matrix passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
[]interface{}{map[string]interface{}{"value": "c"}, map[string]interface{}{"value": "d"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"bare single-cell range enforces the 1x1 match (07-21: server rejects anchors too)",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 2 columns but --range \"A1\" spans 1 columns",
|
||||
},
|
||||
{
|
||||
"single-cell range with a single cell passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", tc.input), testToken, 0)
|
||||
if tc.wantContains == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenToolErrorMsg_PartialFailureRecovery pins the no-rollback recovery
|
||||
// prescription appended to server-side "N succeeded, M failed" errors.
|
||||
func TestFlattenToolErrorMsg_PartialFailureRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrap := func(inner string) string {
|
||||
return `{"error":` + jsonQuote(inner) + `}`
|
||||
}
|
||||
|
||||
t.Run("single failure prescribes resend-from-index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 4 succeeded, 1 failed","failures":[{"index":4,"tool_name":"set_cell_range","error":"cells is required"}]}`), false, true)
|
||||
for _, want := range []string{"operations[4] (set_cell_range)", "no rollback", "resend only operations[4:]"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("msg %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple failures prescribe failed-only resend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 3 succeeded, 2 failed","failures":[{"index":1,"tool_name":"set_cell_range","error":"e1"},{"index":3,"tool_name":"resize_range","error":"e2"}]}`), false, true)
|
||||
if !strings.Contains(msg, "resend only the failed operations") {
|
||||
t.Fatalf("msg %q missing failed-only prescription", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero succeeded gets no note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 0 succeeded, 1 failed","failures":[{"index":0,"tool_name":"set_cell_range","error":"e"}]}`), false, true)
|
||||
if strings.Contains(msg, "no rollback") {
|
||||
t.Fatalf("msg %q must not carry the note when nothing was applied", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// jsonQuote wraps s as a JSON string literal (escaping quotes), mirroring how
|
||||
// the server double-encodes the inner error payload.
|
||||
func jsonQuote(s string) string {
|
||||
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
// TestBatchOp_SpellingConflictRejected pins the uniqueness half of key
|
||||
// canonicalization: two accepted spellings of the same logical flag must not
|
||||
// both survive into the tool body. The flag view resolves hyphen↔underscore
|
||||
// variants, so a leftover duplicate is silently shadowed — with a sheet
|
||||
// selector that means the write lands on whichever spelling won, and the other
|
||||
// value disappears without a word.
|
||||
func TestBatchOp_SpellingConflictRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("conflicting values reject", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-id": "first",
|
||||
"sheet_id": "second",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "conflicting values")
|
||||
})
|
||||
|
||||
t.Run("identical values under two spellings pass and collapse to one key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-id": "same",
|
||||
"sheet_id": "same",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_id"] != "same" {
|
||||
t.Fatalf("sheet_id = %v, want same", input["sheet_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hyphen spelling alone is normalized to the underscore form", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-name": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1 (hyphen spelling should canonicalize)", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -93,6 +93,15 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
|
||||
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
|
||||
},
|
||||
{
|
||||
// The both-axes form has to hold inside a batch too: it is the only
|
||||
// way to freeze rows AND columns there, since +styles-put (the other
|
||||
// carrier of a combined freeze) is not a batchable sub-op.
|
||||
shortcut: "+dim-freeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--sheet-id", "sh1", "--rows", "1", "--cols", "2"},
|
||||
subInput: `{"sheet-id":"sh1","rows":1,"cols":2}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-group",
|
||||
sc: DimGroup,
|
||||
@@ -763,7 +772,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
|
||||
@@ -84,7 +87,14 @@ func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
|
||||
var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 单元格内容 ──────────────────────────────────────────────────
|
||||
"+cells-set": {"set_cell_range", cellsSetInput},
|
||||
"+cells-set": {"set_cell_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --writes plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range+cells each.
|
||||
if fv.Changed("writes") {
|
||||
return nil, sheetsValidationForFlag("writes", `"writes" is not supported inside +batch-update (it expands into its own batch request); call +cells-set --writes standalone, or give each sub-op a single range + cells`)
|
||||
}
|
||||
return cellsSetInput(fv, token, sid, sname)
|
||||
}},
|
||||
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
|
||||
"+cells-clear": {"clear_cell_range", cellsClearInput},
|
||||
"+cells-replace": {"replace_data", replaceInput},
|
||||
@@ -102,6 +112,11 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
|
||||
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
|
||||
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --ranges plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range each.
|
||||
if fv.Changed("ranges") {
|
||||
return nil, sheetsValidationForFlag("ranges", `"ranges" is not supported inside +batch-update (it expands into its own batch request); call +dim-delete --ranges standalone, or give each sub-op a single "range"`)
|
||||
}
|
||||
return dimRangeOpInput(fv, token, sid, sname, "delete")
|
||||
}},
|
||||
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
@@ -301,6 +316,198 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
|
||||
// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。
|
||||
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
|
||||
// wrappedSubOpInputKeys are nested MCP-body container keys that must never
|
||||
// appear at a sub-op input's top level — their presence means the caller
|
||||
// pasted a shortcut's structured *output* (e.g. a {"cell_styles":{…}} block)
|
||||
// where the flattened flag keys belong. None of the batch sub-op translators
|
||||
// read input under these names, so rejecting them is safe.
|
||||
var wrappedSubOpInputKeys = []string{"cell_styles", "cell_merges", "styles"}
|
||||
|
||||
// subOpKeyVocabulary returns the set of hyphen-canonical flag names a sub-op
|
||||
// input may carry for `sc`: every non-system flag in flag-defs except the
|
||||
// spreadsheet locators (reserved for the batch top level). Nil when the
|
||||
// shortcut has no flag-defs entry (vocabulary checks are then skipped).
|
||||
func subOpKeyVocabulary(sc string) map[string]bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
vocab := make(map[string]bool, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Name == "url" || df.Name == "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
vocab[df.Name] = true
|
||||
}
|
||||
return vocab
|
||||
}
|
||||
|
||||
// camelToKebab converts a lowerCamelCase key to its kebab form
|
||||
// (sheetName → sheet-name). Returns "" when the key carries no uppercase
|
||||
// letter (nothing to convert).
|
||||
func camelToKebab(key string) string {
|
||||
if strings.ToLower(key) == key {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, r := range key {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r + ('a' - 'A'))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeSubOpInputKeys validates every sub-op input key against the
|
||||
// shortcut's flag vocabulary, rewriting habitual spellings in place and
|
||||
// rejecting anything that matches nothing. Eval traces show unknown keys were
|
||||
// previously ignored silently, which turned "wrong key" (size for width,
|
||||
// camelCase sheetName, an invented styles object) into misleading
|
||||
// "missing required flag" errors downstream — the single largest batch error
|
||||
// cluster. Rewrites applied, in order:
|
||||
//
|
||||
// - underscore ↔ hyphen forms of a declared flag (already tolerated by
|
||||
// mapFlagView — accepted here as-is)
|
||||
// - lowerCamelCase → the declared flag (sheetName → sheet_name)
|
||||
// - the command's intuitive-alias table (size → width/height on the resize
|
||||
// pair) — the same commandFlagAliases the cobra path applies
|
||||
// - "ranges" with a single-entry array unwraps onto "range"; a multi-entry
|
||||
// array gets a split-into-sub-ops prescription instead
|
||||
//
|
||||
// Anything else errors with a did-you-mean. Returns a bare error; the caller
|
||||
// wraps it with the operations[i] (<shortcut>) context and key contract.
|
||||
func normalizeSubOpInputKeys(sc string, input map[string]interface{}) error {
|
||||
vocab := subOpKeyVocabulary(sc)
|
||||
if vocab == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(input))
|
||||
for k := range input {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
aliases := commandFlagAliases[sc]
|
||||
// canonical tracks which raw key already claimed each logical key, so two
|
||||
// spellings of the same flag (sheet-id / sheet_id / sheetId) can never both
|
||||
// survive into the tool body — the flag view resolves hyphen↔underscore
|
||||
// variants, so a leftover duplicate would be silently shadowed and could
|
||||
// send the write to the wrong sheet.
|
||||
canonical := map[string]string{}
|
||||
claim := func(logical, raw string) error {
|
||||
if prev, taken := canonical[logical]; taken {
|
||||
if jsonEqual(input[prev], input[raw]) {
|
||||
return nil // same value under two spellings: harmless
|
||||
}
|
||||
return fmt.Errorf("%s got conflicting values for %q under two spellings (%q and %q) — keep one", sc, strings.ReplaceAll(logical, "-", "_"), prev, raw) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
canonical[logical] = raw
|
||||
return nil
|
||||
}
|
||||
for _, k := range keys {
|
||||
hv := strings.ReplaceAll(k, "_", "-")
|
||||
if vocab[hv] {
|
||||
if err := claim(hv, k); err != nil {
|
||||
return err
|
||||
}
|
||||
// Normalize the surviving spelling to the underscore form the tool
|
||||
// bodies use, so exactly one key reaches the flag view.
|
||||
if target := strings.ReplaceAll(hv, "-", "_"); target != k {
|
||||
if _, taken := input[target]; !taken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
canonical[hv] = target
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if kebab := camelToKebab(k); kebab != "" && vocab[kebab] {
|
||||
if err := claim(kebab, k); err != nil {
|
||||
return err
|
||||
}
|
||||
target := strings.ReplaceAll(kebab, "-", "_")
|
||||
if _, taken := input[target]; taken {
|
||||
return fmt.Errorf("%s got both %q and %q — keep %q and drop the other", sc, k, target, target) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if _, taken := input[kebab]; taken && kebab != target {
|
||||
return fmt.Errorf("%s got both %q and %q — keep %q and drop the other", sc, k, kebab, kebab) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
canonical[kebab] = target
|
||||
continue
|
||||
}
|
||||
if target, ok := aliases[strings.ToLower(hv)]; ok && vocab[target] {
|
||||
if err := claim(target, k); err != nil {
|
||||
return err
|
||||
}
|
||||
underscored := strings.ReplaceAll(target, "-", "_")
|
||||
_, hyphenTaken := input[target]
|
||||
_, underscoreTaken := input[underscored]
|
||||
if !hyphenTaken && !underscoreTaken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
// The alias AND its target are both present. This key is recognized,
|
||||
// so it must not fall through to the generic "unknown input key"
|
||||
// below — the claim() conflict message never fires here either,
|
||||
// because keys are walked in sorted order and the alias can sort
|
||||
// before its target ("size" < "width"), so nothing has claimed the
|
||||
// logical key yet. Name both spellings and the survivor.
|
||||
taken := target
|
||||
if underscoreTaken {
|
||||
taken = underscored
|
||||
}
|
||||
if jsonEqual(input[k], input[taken]) {
|
||||
delete(input, k) // same value under two names: drop the alias.
|
||||
// Hand the logical key over to the surviving spelling, or the
|
||||
// claim recorded above would still point at the deleted alias
|
||||
// and make that spelling's own turn read as a conflict.
|
||||
canonical[target] = taken
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s got both %q and %q, which are two names for the same flag, with different values — keep %q", sc, k, taken, taken) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if strings.ToLower(hv) == "ranges" && vocab["range"] && !vocab["ranges"] {
|
||||
if _, taken := input["range"]; taken {
|
||||
return fmt.Errorf("%s got both %q and \"range\" — keep \"range\" and drop %q", sc, k, k) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if arr, isArr := input[k].([]interface{}); isArr {
|
||||
if len(arr) == 1 {
|
||||
if s, isStr := arr[0].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s takes a single \"range\" per sub-op, got %d entries in %q — split them into %d sub-ops (one per range)", sc, len(arr), k, len(arr)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if s, isStr := input[k].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("unknown input key %q", k)
|
||||
display := make([]string, 0, len(vocab))
|
||||
for name := range vocab {
|
||||
display = append(display, strings.ReplaceAll(name, "-", "_"))
|
||||
}
|
||||
sort.Strings(display)
|
||||
if match := suggest.Closest(strings.ToLower(hv), display, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
|
||||
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
|
||||
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
|
||||
@@ -312,6 +519,7 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
// - input 不是 object
|
||||
// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch)
|
||||
// - input 里手填了 excel_id / spreadsheet_token / url
|
||||
// - input 顶层出现 cell_styles / cell_merges / styles(误贴 MCP body 包裹结构)
|
||||
// - 子操作的 translator 报错(如缺必填字段)
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
@@ -335,7 +543,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
"(read ops / fan-out wrappers like +batch-update / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
index, sc,
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
}
|
||||
@@ -358,11 +566,30 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
)
|
||||
}
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
// 连字符 / 下划线两种写法都算命中(spreadsheet-token 与 spreadsheet_token 同罪)。
|
||||
for userKey := range input {
|
||||
normalized := strings.ReplaceAll(userKey, "-", "_")
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if normalized == k {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, userKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reject a "wrapped structure" sub-op input: agents copy a shortcut's nested
|
||||
// output container (e.g. +workbook-create --styles' {"cell_styles":{…}}) into
|
||||
// the op input, but the op input is the shortcut's own flags flattened into
|
||||
// JSON keys, not that wrapper. Left unflagged this surfaces far downstream as
|
||||
// an unrelated "at least one style flag is required" (helpers.go), which never
|
||||
// points at the real mistake.
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
`operations[%d] (%s): op input is the shortcut's flags flattened as JSON keys (e.g. "background_color": "#EBF1F8"); do not wrap in %s`,
|
||||
index, sc, k,
|
||||
)
|
||||
}
|
||||
@@ -373,6 +600,16 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
// Reject / rewrite off-vocabulary input keys BEFORE any value reads: an
|
||||
// unknown key silently ignored surfaces later as a misleading
|
||||
// "missing required flag" error (the top batch error cluster in evals).
|
||||
if err := normalizeSubOpInputKeys(sc, input); err != nil {
|
||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
if contract := subOpInputContract(sc); contract != "" {
|
||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
||||
}
|
||||
return nil, verr
|
||||
}
|
||||
fv := newMapFlagViewForCommand(sc, input)
|
||||
// operations is skipped by parse-time schema validation, so type-check the
|
||||
// sub-op's scalar fields here before the translator reads them via
|
||||
@@ -410,7 +647,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
// batchOpErrorDisplayLimit bounds how many per-op validation failures ride
|
||||
// on one aggregated --operations error, mirroring the schema validator's
|
||||
// display cap.
|
||||
const batchOpErrorDisplayLimit = 5
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组。逐 op 校验并**收集全部失败**
|
||||
// 一次性返回(不再 fail-fast)——agent 一轮就能修完所有坏 op,而不是
|
||||
// 修一个、重试、再撞下一个。cell 安全上限仍是全局判定,命中即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
@@ -422,10 +666,15 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
var totalCells int64
|
||||
var opErrs []error
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
opErrs = append(opErrs, err)
|
||||
continue
|
||||
}
|
||||
if len(opErrs) > 0 {
|
||||
continue // already failing — keep scanning for more bad ops, skip cell math.
|
||||
}
|
||||
totalCells += translatedCellCount(translated)
|
||||
if totalCells > maxStampMatrixCells {
|
||||
@@ -435,7 +684,31 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
switch len(opErrs) {
|
||||
case 0:
|
||||
return out, nil
|
||||
case 1:
|
||||
return nil, opErrs[0] // single failure keeps the historical error byte-for-byte.
|
||||
}
|
||||
shown := opErrs
|
||||
truncated := false
|
||||
if len(shown) > batchOpErrorDisplayLimit {
|
||||
shown = shown[:batchOpErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
// aggregatedIssueText keeps each op's own hint (the "<shortcut> input
|
||||
// keys: …" contract) inline: folding N errors leaves one Hint slot, so
|
||||
// without this the multi-op error would carry LESS guidance than the
|
||||
// single-op one it replaces.
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, aggregatedIssueText(e)))
|
||||
}
|
||||
msg := fmt.Sprintf("%d of %d operations failed validation: %s", len(opErrs), len(rawOps), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
msg += fmt.Sprintf("; (%d more not shown — fix these first)", len(opErrs)-batchOpErrorDisplayLimit)
|
||||
}
|
||||
return nil, sheetsValidationForFlag("operations", "%s", msg).WithCause(opErrs[0])
|
||||
}
|
||||
|
||||
func translatedCellCount(op map[string]interface{}) int64 {
|
||||
|
||||
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCellsSetWrites pins the --writes plural form: scattered (cross-sheet)
|
||||
// regions fan into ONE atomic batch_update, each item self-carrying its
|
||||
// sheet selector (no top-level fallback — same convention as +batch-update
|
||||
// sub-ops and +styles-put items), with per-item errors aggregated.
|
||||
func TestCellsSetWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writes := func(items string, extra ...string) (string, string, error) {
|
||||
args := append([]string{
|
||||
"--url", testURL, "--dry-run", "--writes", items,
|
||||
}, extra...)
|
||||
return runShortcutCapturingErr(t, CellsSet, args)
|
||||
}
|
||||
|
||||
t.Run("cross-sheet items expand into one batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[
|
||||
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
|
||||
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(C:C)"}]]}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"batch_update", "明细", "汇总", "IFERROR"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("dry-run body missing %q: %s", want, stdout[:min(len(stdout), 400)])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item without sheet selector errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
requireValidation(t, err, "sheet-id or --sheet-name")
|
||||
})
|
||||
|
||||
t.Run("top-level sheet selector rejected with prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--sheet-name", "S1")
|
||||
requireValidation(t, err, "put sheet_name (or sheet_id) inside each writes item")
|
||||
})
|
||||
|
||||
t.Run("writes and range are mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--range", "A1")
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("per-item errors aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Both items pass the --writes schema (range+cells present) but fail
|
||||
// deeper: item 0 a matrix mismatch, item 1 a missing sheet selector.
|
||||
_, _, err := writes(`[
|
||||
{"sheet_name":"S1","range":"A1:B2","cells":[[{"value":"x"}]]},
|
||||
{"range":"C1","cells":[[{"value":"y"}]]}
|
||||
]`)
|
||||
ve := requireValidation(t, err, "--writes has 2 issues")
|
||||
for _, want := range []string{"--writes[0]", "--writes[1]", "sheet-name"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item keys go through the vocabulary layer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[{"sheetName":"S1","range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("camelCase sheetName must normalize: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "S1") {
|
||||
t.Fatalf("normalized item missing sheet: %s", stdout[:min(len(stdout), 300)])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot nest inside batch-update", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"writes": []interface{}{map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1", "cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
|
||||
t.Run("styles flag gets the layering prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Ergonomics (FlagErrorFunc hints) mount via the registry, not the
|
||||
// bare shortcut var — mirror the real CLI wiring.
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL, "--dry-run",
|
||||
"--writes", `[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--styles", `{"styles":[]}`,
|
||||
})
|
||||
ve := requireValidation(t, err, "unknown flag")
|
||||
if !strings.Contains(ve.Hint, "+styles-put") || !strings.Contains(ve.Hint, "cell_styles") {
|
||||
t.Fatalf("want the styles-put layering hint, got hint=%q", ve.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
149
shortcuts/sheets/chart_examples.go
Normal file
149
shortcuts/sheets/chart_examples.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─── +chart-create --print-example ─────────────────────────────────────
|
||||
//
|
||||
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
|
||||
// traces show agents paging through the full --print-schema dump for every
|
||||
// chart (25 round trips in one 35-task batch) and still missing deep
|
||||
// required fields. A ready-to-edit minimal template per chart type answers
|
||||
// the actual question ("what does a valid payload look like") in one local
|
||||
// call. Wired through PostMount, same pattern as +csv-put's flag-group
|
||||
// tweaks — no framework change.
|
||||
//
|
||||
// Templates mirror the canonical examples in the lark-sheets-chart
|
||||
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
|
||||
// inline headerMode with refs covering the header row, 1-based indices,
|
||||
// quoted sheet prefix in refs.
|
||||
|
||||
var chartExampleTemplates = map[string]string{
|
||||
"column": chartSimpleExample("column"),
|
||||
"bar": chartSimpleExample("bar"),
|
||||
"line": chartSimpleExample("line"),
|
||||
"area": chartSimpleExample("area"),
|
||||
"radar": chartSimpleExample("radar"),
|
||||
"scatter": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": "scatter"}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B20"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"pie": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 450},
|
||||
"snapshot": {
|
||||
"title": {"text": "占比标题"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "pie",
|
||||
"series": [{
|
||||
"index": 1,
|
||||
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
|
||||
}]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B11"}],
|
||||
"dim1": {"serie": {"index": 1, "aggregate": true}},
|
||||
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"combo": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 700, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "柱线组合"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "combo",
|
||||
"series": [
|
||||
{"index": 2, "comboType": "column"},
|
||||
{"index": 3, "comboType": "line"}
|
||||
]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C13"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
// chartSimpleExample renders the shared minimal shape for plot types that
|
||||
// need nothing beyond plot.type (column / bar / line / area / radar).
|
||||
func chartSimpleExample(typ string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": %q}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C10"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`, typ)
|
||||
}
|
||||
|
||||
func chartExampleTypes() []string {
|
||||
types := make([]string, 0, len(chartExampleTemplates))
|
||||
for t := range chartExampleTemplates {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// withChartPrintExample wraps +chart-create's PostMount so --print-example
|
||||
// short-circuits execution and prints a minimal ready-to-edit --properties
|
||||
// template — purely local, no identity or network. The flag itself is
|
||||
// declared in flag-defs.json like every other own flag (so it shows up in the
|
||||
// generated reference tables); only the interception lives here.
|
||||
// --properties' cobra-level required annotation is relaxed (the input builder
|
||||
// still enforces it on the real path, same trick as +csv-put's --csv).
|
||||
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
// Only --properties carries a cobra-level required annotation (the
|
||||
// locator flags are xor pairs, enforced later); the input builder
|
||||
// still errors "--properties is required" on the real path.
|
||||
if fl := cmd.Flags().Lookup("properties"); fl != nil {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
prevRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
typ, _ := c.Flags().GetString("print-example")
|
||||
if typ == "" {
|
||||
return prevRunE(c, args)
|
||||
}
|
||||
tmpl, ok := chartExampleTemplates[typ]
|
||||
if !ok {
|
||||
return common.ValidationErrorf("no example for chart type %q; available: %s",
|
||||
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
|
||||
}
|
||||
fmt.Fprintln(c.OutOrStdout(), tmpl)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
109
shortcuts/sheets/chart_examples_test.go
Normal file
109
shortcuts/sheets/chart_examples_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChartPrintExample pins the --print-example contract: a known type
|
||||
// prints its template and skips execution entirely; an unknown type lists
|
||||
// the available ones.
|
||||
func TestChartPrintExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("prints template without locator flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
parent, _, _, _ := newTestRig(t, sc)
|
||||
var buf bytes.Buffer
|
||||
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
|
||||
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("print-example should run standalone, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"sectors"`) {
|
||||
t.Errorf("pie template should carry sectors, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type lists available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
|
||||
ve := requireValidation(t, err, `no example for chart type "donut"`)
|
||||
if !strings.Contains(ve.Message, "pie") {
|
||||
t.Errorf("message should list available types, got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--print-example" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--print-example")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
|
||||
// template against the embedded chart-create properties schema — a template
|
||||
// the CLI itself would reject is worse than none.
|
||||
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
for typ, tmpl := range chartExampleTemplates {
|
||||
t.Run(typ, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
|
||||
t.Fatalf("template is not valid JSON: %v", err)
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
|
||||
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
|
||||
t.Errorf("template rejected by embedded schema: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeChartHexColors_Arrays pins color normalization inside arrays:
|
||||
// the chart schema uses colorTheme / colorScale / highlight_colors, whose
|
||||
// values are LISTS of bare hex strings. Recursing without the key context
|
||||
// dropped the "#" prefix and the server rejected a payload its own schema
|
||||
// allows.
|
||||
func TestNormalizeChartHexColors_Arrays(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"colorTheme": []interface{}{"4472C4", "ED7D31"},
|
||||
"highlight_colors": []interface{}{"FF0000"},
|
||||
"colorScale": []interface{}{map[string]interface{}{"color": "70AD47"}},
|
||||
"backgroundColor": "4472C4",
|
||||
"colorMode": "auto",
|
||||
"title": []interface{}{"4472C4"},
|
||||
}
|
||||
raw, err := json.Marshal(normalizeChartHexColors(in))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
theme := got["colorTheme"].([]interface{})
|
||||
if theme[0] != "#4472C4" || theme[1] != "#ED7D31" {
|
||||
t.Errorf("colorTheme = %v, want both prefixed", theme)
|
||||
}
|
||||
if got["highlight_colors"].([]interface{})[0] != "#FF0000" {
|
||||
t.Errorf("highlight_colors = %v", got["highlight_colors"])
|
||||
}
|
||||
if got["colorScale"].([]interface{})[0].(map[string]interface{})["color"] != "#70AD47" {
|
||||
t.Errorf("colorScale = %v", got["colorScale"])
|
||||
}
|
||||
// Non-hex values under a color-ish key, and hex-looking values under a
|
||||
// non-color key, must both be left alone.
|
||||
if got["colorMode"] != "auto" {
|
||||
t.Errorf("colorMode = %v, want untouched", got["colorMode"])
|
||||
}
|
||||
if got["title"].([]interface{})[0] != "4472C4" {
|
||||
t.Errorf("title = %v, want untouched (not a color key)", got["title"])
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
|
||||
// only when it names a real file (a forgotten @), while leaving genuine inline
|
||||
// content alone — including the case the old name-shape heuristic got wrong:
|
||||
// prose that merely ends in or mentions a filename.
|
||||
// TestGuardCSVValueIsNotFilePath covers the existing-file tier: a bare --csv
|
||||
// value naming a real file is a forgotten "@". The prescription names the fix
|
||||
// with a <path> placeholder — the untrusted value must not be spliced into
|
||||
// command-shaped text an agent would copy verbatim.
|
||||
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
@@ -33,23 +33,98 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
ve := requireValidation(t, err, "existing file")
|
||||
if !strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
|
||||
if !strings.Contains(ve.Message, `"data.csv"`) {
|
||||
t.Errorf("message should name the offending value as data, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "--csv @<path>") {
|
||||
t.Errorf("message should prescribe the @ form via placeholder, got: %q", ve.Message)
|
||||
}
|
||||
if strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message must not splice the value into a command fragment, got: %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--csv" {
|
||||
t.Errorf("param = %q, want --csv", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_MissingButPathShaped covers the second tier.
|
||||
// A path that doesn't resolve used to pass through and be written into the
|
||||
// cell verbatim — a wrong value with a success exit code. The common source is
|
||||
// an absolute path: `@` rejects those, so the caller drops the `@` and retries.
|
||||
// Since the file can't be read from cwd, the prescription is stdin.
|
||||
func TestGuardCSVValueIsNotFilePath_MissingButPathShaped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// Content that is not a real file must pass through unchanged.
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // prose ending in a filename — not a real file
|
||||
"remember to update data.csv", // mentions the real file but isn't its name
|
||||
"nope.csv", // relative path from another working directory
|
||||
"./missing.csv", // explicit relative prefix
|
||||
"../sibling/x.tsv", // parent-relative
|
||||
"/tmp/nope.csv", // absolute — the `@`-rejected case
|
||||
"~/data.tsv", // home-relative
|
||||
"/var/tmp/export", // no extension, but an unmistakable path prefix
|
||||
"C:/Users/me/a.csv", // windows-style, still ASCII path shape
|
||||
} {
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v))
|
||||
ve := requireValidation(t, err, "looks like a file path")
|
||||
if !strings.Contains(ve.Hint, "--csv @") || !strings.Contains(ve.Hint, "--csv - <") {
|
||||
t.Errorf("value %q: hint should offer both @file and stdin, got: %q", v, ve.Hint)
|
||||
}
|
||||
// The untrusted value must never appear inside the command-shaped
|
||||
// hint: "--csv - < $(id).csv" copied by an agent would expand in a
|
||||
// POSIX shell. The value is only named as quoted data in the message.
|
||||
if strings.Contains(ve.Hint, v) {
|
||||
t.Errorf("value %q: hint must not splice the raw value into a command fragment, got: %q", v, ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Message, v) {
|
||||
t.Errorf("value %q: message should still name the offending value, got: %q", v, ve.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_SkipsResolvedInput pins the origin rule that
|
||||
// makes the shape heuristic safe: a value that arrived via @file / stdin is
|
||||
// never inspected, however path-shaped its content — so the hint's promise
|
||||
// that stdin writes such text verbatim actually holds, and a correct
|
||||
// `--csv @file` invocation can't be re-rejected for its content.
|
||||
func TestGuardCSVValueIsNotFilePath_SkipsResolvedInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, v := range []string{
|
||||
"nope.csv", // path-shaped, missing — rejected when inline
|
||||
"data.csv", // names an existing file — rejected when inline
|
||||
} {
|
||||
rctx := newCSVGuardRuntime(v)
|
||||
common.TestMarkInputResolved(rctx, "csv")
|
||||
if err := guardCSVValueIsNotFilePath(rctx); err != nil {
|
||||
t.Errorf("resolved value %q must skip the guard, got: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_PassesThrough pins what must still reach the
|
||||
// sheet untouched. The prose cases are why the guard checks a narrow shape
|
||||
// instead of "contains a filename": an earlier name-shape heuristic rejected
|
||||
// them. "N/A" and "README.md" pin the two narrowing rules — a slash alone is
|
||||
// not a path, and a filename alone is not a CSV path.
|
||||
func TestGuardCSVValueIsNotFilePath_PassesThrough(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // CJK prose ending in a filename
|
||||
"remember to update data.csv", // prose mentioning a file
|
||||
"a,b\n1,2", // multi-cell CSV
|
||||
"hello world",
|
||||
"nope.csv", // path-shaped but no such file
|
||||
"N/A", // slash, but no CSV extension and no path prefix
|
||||
"README.md", // filename shape, not a CSV one
|
||||
"report 2026.csv", // has a space: content, not a path
|
||||
"",
|
||||
} {
|
||||
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,7 @@
|
||||
"+float-image-update",
|
||||
"+float-image-delete"
|
||||
],
|
||||
"description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 atomic batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁。"
|
||||
"description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁——美化收尾请单独调 +styles-put,不要拆成子操作数组。"
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
@@ -648,6 +648,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"writes": {
|
||||
"type": "array",
|
||||
"description": "多区域写入项数组(最多 100 项),整批单次批量提交(fail-fast、不回滚);支持跨 sheet。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"range",
|
||||
"cells"
|
||||
],
|
||||
"properties": {
|
||||
"sheet_id": {
|
||||
"type": "string",
|
||||
"description": "目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)。"
|
||||
},
|
||||
"sheet_name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名;与 sheet_id 二选一,必须写在每一项里。"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)。"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_range#/properties/cells)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cells-set-style": {
|
||||
@@ -7748,87 +7777,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+styles-put": {
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
@@ -7856,12 +7805,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8055,7 +8008,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8073,19 +8026,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8104,8 +8070,395 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -8228,12 +8581,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8427,7 +8784,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8445,19 +8802,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8476,8 +8846,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
@@ -50,7 +50,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style): `{ top|bottom|left|right|all: {style,weight,color} }`; style = solid|dashed|dotted|double|none, weight = thin|medium|thick (string), color = hex like #000000", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -59,8 +59,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
|
||||
@@ -72,11 +72,12 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -86,8 +87,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -98,8 +99,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
|
||||
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
|
||||
@@ -115,8 +116,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
@@ -133,10 +134,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE batched request (fail-fast, no rollback), cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
@@ -148,8 +150,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
|
||||
@@ -161,8 +163,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
@@ -175,7 +177,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,weight,color}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides. style = line type (solid|dashed|dotted|double|none); weight = thickness (thin|medium|thick — a string, not a pixel number); color = hex like #000000. { all: {...} } sets all four sides at once. This is the only border flag: no --border-all / --border-top / --border-color exist", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -184,8 +186,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -204,9 +206,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "print-example", Kind: "own", Type: "string", Required: "optional", Desc: "Print a minimal ready-to-edit --properties template for a chart type (area|bar|column|combo|line|pie|radar|scatter) and exit. Purely local: no locator flags, no network; an unknown type lists the available ones"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
@@ -215,8 +218,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -227,8 +230,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -238,8 +241,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -250,10 +253,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"},
|
||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one batched call (fail-fast, no rollback). Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -264,8 +267,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
@@ -277,8 +280,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -289,8 +292,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -300,8 +303,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
@@ -314,10 +317,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Note the file is the data payload as JSON — on +csv-get too, where the CSV text sits in a field inside it — not a ready-to-use .csv; redirect stdout instead if you want a bare CSV file. Omit it to print to stdout as usual."},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
@@ -328,8 +332,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
@@ -342,9 +346,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one batched delete (fail-fast, no rollback) — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -354,10 +359,12 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "optional", Desc: "[legacy] Dimension (row or column), paired with --count; sets one axis only and unfreezes the other. Prefer --rows / --cols", Hidden: true, Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "[legacy] Freeze the first N rows/columns (paired with --dimension); 0 clears all freezing. Equivalent to --rows N / --cols N, and only --rows/--cols can hold both axes at once", Hidden: true},
|
||||
{Name: "rows", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N rows; together with --cols this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen rows)"},
|
||||
{Name: "cols", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N columns; together with --rows this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen columns)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -366,8 +373,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
|
||||
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
@@ -379,8 +386,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -390,9 +397,9 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -403,8 +410,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -415,8 +422,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -427,8 +434,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -448,8 +455,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -459,8 +466,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
@@ -489,8 +496,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -501,8 +508,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -512,8 +519,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -522,8 +529,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -534,8 +541,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
|
||||
@@ -547,8 +554,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -559,8 +566,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -570,8 +577,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
|
||||
@@ -584,8 +591,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"},
|
||||
@@ -605,8 +612,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -617,8 +624,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -628,8 +635,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"},
|
||||
@@ -702,8 +709,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -714,8 +721,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -725,8 +732,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -737,8 +744,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
@@ -751,8 +758,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
|
||||
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
|
||||
@@ -764,8 +771,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
@@ -777,8 +784,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
|
||||
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
|
||||
@@ -798,10 +805,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"},
|
||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one batched call (fail-fast, no rollback). Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -812,8 +819,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -837,8 +844,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -848,8 +855,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -858,8 +865,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -868,8 +875,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -880,8 +887,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -892,8 +899,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -903,8 +910,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -914,8 +921,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -924,8 +931,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -934,8 +941,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -945,8 +952,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -957,8 +964,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -968,13 +975,22 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+styles-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one batched request (fail-fast, no rollback: applied sub-operations stay); ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -983,6 +999,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (cap auto-raises to a bounded 20M chars; explicit --max-chars overrides). Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
|
||||
// enum + default
|
||||
rt := byName("+dim-insert", "inherit-style")
|
||||
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
|
||||
if rt == nil || len(rt.Enum) != 2 || rt.Default != "" {
|
||||
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
|
||||
}
|
||||
// required
|
||||
|
||||
@@ -38,9 +38,134 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
chainFlagAliases(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
|
||||
//
|
||||
// Eval traces show unknown-flag failures cluster on a handful of habitual
|
||||
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
|
||||
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
|
||||
// the enum-normalization contract above: a name whose value semantics are
|
||||
// identical to the real flag is rewritten silently (zero round-trips); a
|
||||
// name whose fix changes the value or moves it into a JSON field gets a
|
||||
// curated prescription on the unknown-flag error instead — never a silent
|
||||
// rewrite.
|
||||
|
||||
// commandFlagAliases maps, per command, habitual flag names onto the flag
|
||||
// actually registered. Only pairs with identical value semantics belong
|
||||
// here: the rewrite is invisible, so it must be safe to apply unread
|
||||
// (+csv-put --file with a path value still trips the file-path guard, which
|
||||
// prescribes @file / stdin).
|
||||
var commandFlagAliases = map[string]map[string]string{
|
||||
"+csv-put": {"file": "csv"},
|
||||
"+sheet-create": {"name": "title"},
|
||||
// The new name is the only name-valued input a rename takes, so the
|
||||
// habitual spellings are unambiguous (unlike +sheet-copy, where a name
|
||||
// could mean the copy's title or the source selector and gets a
|
||||
// prescription instead). 07-28 root-cause report #25: 10/10 wrote
|
||||
// --new-name, 24 occurrences.
|
||||
"+sheet-rename": {"name": "title", "new-name": "title"},
|
||||
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
|
||||
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
|
||||
// here too — the rename is the single largest sub-op error cluster in
|
||||
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
|
||||
"+cols-resize": {"cols": "range", "size": "width"},
|
||||
"+rows-resize": {"rows": "range", "size": "height"},
|
||||
"+range-fill": {"source": "source-range", "target": "target-range"},
|
||||
"+range-copy": {"source": "source-range", "target": "target-range"},
|
||||
"+range-move": {"source": "source-range", "target": "target-range"},
|
||||
}
|
||||
|
||||
// intuitiveFlagHints carries the prescription for habitual names whose fix
|
||||
// is not a 1:1 rename — the value belongs to a different flag or to a field
|
||||
// inside a JSON payload. The hint spells the exact correct form so the
|
||||
// retry needs no --help round trip.
|
||||
var intuitiveFlagHints = map[string]map[string]string{
|
||||
"+sheet-copy": {
|
||||
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
},
|
||||
"+dim-insert": {
|
||||
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
|
||||
},
|
||||
// Must prescribe --rows / --cols, never the retired --dimension/--count
|
||||
// pair (DEPRECATED(phase-2) on dimFreezeLegacyNote): those flags are hidden
|
||||
// from --help, so they do not even appear in the "valid flags" list printed
|
||||
// beside this hint, and using them earns a second note steering back here.
|
||||
"+dim-freeze": {
|
||||
"frozen-rows": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
|
||||
"frozen-cols": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-columns": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-row-count": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
|
||||
"frozen-col-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-column-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
},
|
||||
"+cells-set-style": {
|
||||
"bold": "use --font-weight bold",
|
||||
"italic": "use --font-style italic",
|
||||
"underline": "use --font-line underline",
|
||||
"font-bold": "use --font-weight bold",
|
||||
"bg-color": "use --background-color",
|
||||
// Google Sheets API vocabulary (wrapStrategy).
|
||||
"wrap-strategy": "use --word-wrap (overflow / auto-wrap / word-clip)",
|
||||
// The border family: the only border flag is --border-styles (composite
|
||||
// JSON); color and per-side variants ride inside it.
|
||||
"border-style": `borders take one composite flag: --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right, or "all" for all four)`,
|
||||
"border-color": `border color rides inside --border-styles JSON, e.g. --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-all": `use --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' — the "all" key applies one spec to all four sides`,
|
||||
"border-top": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-bottom": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-left": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"left":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-right": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"right":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
},
|
||||
"+cells-set": {
|
||||
// Predictable prior from +table-put --styles: models will try to
|
||||
// attach range-level styling to a --writes call the same way.
|
||||
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
|
||||
// +workbook-create's untyped-data flag, carried over to the write
|
||||
// command (07-28 root-cause report #9, 63 occurrences; values↔cells
|
||||
// shares no prefix so edit distance never suggests the fix).
|
||||
"values": `cell contents go in --cells as a 2D array of cell objects ('[[{"value":…},…],…]'); --values is +workbook-create's flag for untyped initial data`,
|
||||
},
|
||||
"+table-put": {
|
||||
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
|
||||
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
},
|
||||
}
|
||||
|
||||
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
|
||||
// (on top of any hook a prior PostMount installed, e.g. --token →
|
||||
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
|
||||
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
|
||||
// canonical name), and the command's intuitive-alias table. Either way a
|
||||
// habitual name parses as the real flag with zero round trips. Aliases
|
||||
// never shadow a registered flag and never appear in --help; an alias whose
|
||||
// target vanished (spec-side rename) is dropped, degrading to the
|
||||
// unknown-flag prescription.
|
||||
func chainFlagAliases(cmd *cobra.Command) {
|
||||
aliases := commandFlagAliases[cmd.Name()]
|
||||
usable := make(map[string]string, len(aliases))
|
||||
for alias, target := range aliases {
|
||||
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
|
||||
usable[alias] = target
|
||||
}
|
||||
}
|
||||
prev := cmd.Flags().GetNormalizeFunc()
|
||||
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if strings.Contains(name, "_") {
|
||||
name = strings.ReplaceAll(name, "_", "-")
|
||||
}
|
||||
if target, ok := usable[name]; ok {
|
||||
name = target
|
||||
}
|
||||
return prev(fs, name)
|
||||
})
|
||||
}
|
||||
|
||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
||||
// offending flag on params) and additionally inlines the full valid-flag
|
||||
@@ -50,6 +175,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
|
||||
// top-level sheet locator (each sub-op names its own sheet inside its input),
|
||||
// yet agents reach for --sheet-id / --sheet-name at the top level. An
|
||||
// edit-distance suggestion would only mislead here, so skip it and name the
|
||||
// real contract instead. Underscore spellings (--sheet_id) are matched too:
|
||||
// the error message itself teaches the underscore key names, and sub-op
|
||||
// inputs accept them, so agents mix the two styles.
|
||||
locatorName := strings.ReplaceAll(name, "_", "-")
|
||||
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
|
||||
}
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
@@ -67,6 +205,21 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
// A curated prescription beats both: it spells the exact correct form
|
||||
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
|
||||
// Edit-distance candidates are dropped with it — they can contradict the
|
||||
// prescription (--font-bold ranked --font-color/--font-line/--font-size
|
||||
// while the fix is --font-weight), and a machine-readable suggestion that
|
||||
// disagrees with the hint sends agents down the wrong retry.
|
||||
// The map is keyed hyphenated but the parse error reports the flag as
|
||||
// typed, so --frozen_rows must hit the same entry as --frozen-rows.
|
||||
if rx, ok := intuitiveFlagHints[c.Name()][strings.ReplaceAll(name, "_", "-")]; ok {
|
||||
hint = rx
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = rx + "; valid flags: " + list
|
||||
}
|
||||
suggestions = nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
@@ -139,6 +292,69 @@ var enumAliases = map[string]string{
|
||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
||||
"centre": "center",
|
||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
||||
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
|
||||
// from the API docs; lowercased by canonicalEnumValue before lookup.
|
||||
"merge_all": "all",
|
||||
"merge_rows": "rows",
|
||||
"merge_columns": "columns",
|
||||
// Boolean-style word-wrap habits: true unambiguously means wrap on;
|
||||
// false means "don't wrap", whose Lark default is overflow (word-clip is
|
||||
// a distinct truncation mode nobody spells "false").
|
||||
"true": "auto-wrap",
|
||||
"false": "overflow",
|
||||
// Google Sheets wrapStrategy vocabulary: WRAP / CLIP / OVERFLOW. Only
|
||||
// the first two need mapping — overflow is spelled the same in both.
|
||||
"wrap": "auto-wrap",
|
||||
"clip": "word-clip",
|
||||
}
|
||||
|
||||
// DEPRECATED(phase-2): enum values this CLI used to accept and now expresses
|
||||
// by omitting the flag. They are dropped from the published enum so the docs
|
||||
// and --help stop teaching them, but a caller that still passes one must not
|
||||
// hard-fail: the value was valid — for --inherit-style it was even the
|
||||
// DEFAULT — so existing scripts and any agent carrying older docs would break
|
||||
// on a spelling that never meant anything else.
|
||||
//
|
||||
// Semantics: a retired value is cleared, making the call identical to omitting
|
||||
// the flag (pinned by TestRetiredEnumValueMatchesOmitted). It is deliberately
|
||||
// silent — unlike --dimension/--count there is nothing for the caller to
|
||||
// migrate to, so a note would only be noise.
|
||||
//
|
||||
// Phase 2 removal: drop the entry here and let the normal enum error apply.
|
||||
var retiredEnumValues = map[string]map[string][]string{
|
||||
// +dim-insert --inherit-style dropped "none" when the side mapping was
|
||||
// corrected: no inheritance is what omitting the flag already means, so
|
||||
// the value was pure redundancy.
|
||||
"+dim-insert": {"inherit-style": {"none"}},
|
||||
}
|
||||
|
||||
// clearRetiredFlag makes a retired value indistinguishable from an absent
|
||||
// flag. Resetting Changed matters as much as the value: the batch path
|
||||
// expresses "as if omitted" by deleting the key, so Changed() reports false
|
||||
// there. Leaving cobra's Changed at true would make a flag whose logic reads
|
||||
// Changed() (rather than the value) behave differently standalone than inside
|
||||
// +batch-update — and TestBatchOp_BodyMatchesStandalone only catches such a
|
||||
// split once it reaches the request body.
|
||||
func clearRetiredFlag(cmd *cobra.Command, name string) {
|
||||
_ = cmd.Flags().Set(name, "")
|
||||
if f := cmd.Flags().Lookup(name); f != nil {
|
||||
f.Changed = false
|
||||
}
|
||||
}
|
||||
|
||||
// isRetiredEnumValue reports whether val is a retired spelling for this
|
||||
// command's flag, i.e. one that should be cleared rather than rejected.
|
||||
func isRetiredEnumValue(command, flag, val string) bool {
|
||||
byFlag, ok := retiredEnumValues[command]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, retired := range byFlag[flag] {
|
||||
if strings.EqualFold(retired, val) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
||||
@@ -225,6 +441,10 @@ func chainEnumNormalization(cmd *cobra.Command) {
|
||||
c.Flags().Set(df.Name, canon)
|
||||
continue
|
||||
}
|
||||
if isRetiredEnumValue(cmd.Name(), df.Name, val) {
|
||||
clearRetiredFlag(c, df.Name)
|
||||
continue
|
||||
}
|
||||
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
|
||||
val, df.Name, strings.Join(df.Enum, ", ")).
|
||||
WithParam("--" + df.Name)
|
||||
|
||||
@@ -96,6 +96,54 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
|
||||
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
|
||||
// the per-op locator contract instead of offering a misleading fuzzy guess.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
|
||||
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
|
||||
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
|
||||
}
|
||||
if len(verr.Params[0].Suggestions) != 0 {
|
||||
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
|
||||
// special case is scoped to the two sheet-locator flags: any other unknown
|
||||
// flag on +batch-update keeps the normal did-you-mean behaviour.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if strings.Contains(verr.Message, "no top-level sheet locator") {
|
||||
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
@@ -284,9 +332,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--col-size", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
ve := requireValidation(t, err, `unknown flag "--col-size"`)
|
||||
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
@@ -294,3 +342,289 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
|
||||
// habitual name with identical value semantics parses as the real flag on a
|
||||
// mounted command, costing zero round trips (eval: --cols, --file, --name,
|
||||
// --source/--target each burned an unknown-flag failure plus a --help call).
|
||||
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--width", "100",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "A:D") {
|
||||
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-create")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--name", "汇总",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "汇总") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-rename --new-name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-rename")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--new-name", "授权需求清单",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--new-name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "授权需求清单") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+range-fill")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--source", "B2",
|
||||
"--target", "B3:B10",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{"B2", "B3:B10"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+csv-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--start-cell", "A1",
|
||||
"--file", "a,b\n1,2",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "a,b") {
|
||||
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A:C",
|
||||
"--size", "120",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "120") {
|
||||
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+rows-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "1:3",
|
||||
"--size", "36",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias never shadows a registered flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+csv-put"}
|
||||
c.Flags().String("csv", "", "")
|
||||
c.Flags().String("file", "", "") // hypothetical real flag wins
|
||||
chainFlagAliases(c)
|
||||
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("file"); got != "x" {
|
||||
t.Errorf("registered --file should keep its own value, got %q", got)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("csv"); got != "" {
|
||||
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
|
||||
// names whose fix is not a rename answer with the exact correct form, so the
|
||||
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
|
||||
// --help calls, +dim-insert kept failing even after reading help).
|
||||
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
command string
|
||||
args []string
|
||||
wrong string
|
||||
wantHint []string
|
||||
// rejectHint pins what a prescription must NOT name — used where the
|
||||
// obvious wording would steer into a deprecated flag.
|
||||
rejectHint []string
|
||||
}{
|
||||
{
|
||||
command: "+dim-insert",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
|
||||
wrong: "--dimension",
|
||||
wantHint: []string{"--position", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
|
||||
wrong: "--frozen-rows",
|
||||
// Must prescribe the CURRENT spelling: --dimension/--count is
|
||||
// retired and hidden from --help, so a hint naming it would point at
|
||||
// a flag missing from the same error's valid-flags list.
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
|
||||
wrong: "--bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+sheet-copy",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
|
||||
wrong: "--new-sheet-name",
|
||||
wantHint: []string{"--title", "source sheet"},
|
||||
},
|
||||
{
|
||||
command: "+table-put",
|
||||
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
|
||||
wrong: "--start-cell",
|
||||
wantHint: []string{`"start_cell"`, "+csv-put"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--values", `[["x"]]`},
|
||||
wrong: "--values",
|
||||
wantHint: []string{"--cells", "+workbook-create"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-row-count", "1"},
|
||||
wrong: "--frozen-row-count",
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
// The parse error reports the flag as typed: the underscore
|
||||
// spelling must hit the same curated entry as the hyphenated one.
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen_rows", "2"},
|
||||
wrong: "--frozen_rows",
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--font-bold", "true"},
|
||||
wrong: "--font-bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bg-color", "#FFF"},
|
||||
wrong: "--bg-color",
|
||||
wantHint: []string{"--background-color"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--wrap-strategy", "overflow"},
|
||||
wrong: "--wrap-strategy",
|
||||
wantHint: []string{"--word-wrap"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-all", "thin"},
|
||||
wrong: "--border-all",
|
||||
wantHint: []string{"--border-styles", `"all"`},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-top", "thin"},
|
||||
wrong: "--border-top",
|
||||
wantHint: []string{"--border-styles", `"top"`},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-color", "#000"},
|
||||
wrong: "--border-color",
|
||||
wantHint: []string{"--border-styles", "color"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, tc.command)
|
||||
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
|
||||
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
|
||||
for _, want := range tc.wantHint {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
// The valid-flags list is appended to the same Hint, so only the
|
||||
// prescription itself is checked for banned wording.
|
||||
prescription, _, _ := strings.Cut(ve.Hint, "; valid flags:")
|
||||
for _, banned := range tc.rejectHint {
|
||||
if strings.Contains(prescription, banned) {
|
||||
t.Errorf("prescription must not steer to %q, got %q", banned, prescription)
|
||||
}
|
||||
}
|
||||
// A curated prescription must not ship contradicting edit-distance
|
||||
// candidates (--font-bold used to carry --font-color/--font-line/
|
||||
// --font-size in params while the fix is --font-weight).
|
||||
for _, p := range ve.Params {
|
||||
if len(p.Suggestions) > 0 {
|
||||
t.Errorf("curated prescription should drop edit-distance suggestions, got %v", p.Suggestions)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
|
||||
// listing of introspectable flags; otherwise it returns the schema
|
||||
// subtree JSON for the named flag, or an error if the flag is not
|
||||
// registered.
|
||||
//
|
||||
// flagName also accepts a dotted path (properties.plotArea.axes): the
|
||||
// first segment names the flag, the rest walk the schema's properties
|
||||
// (descending through array items implicitly), returning just that
|
||||
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
|
||||
// lines — otherwise force agents to page through the full dump for one
|
||||
// nested field; eval traces show 25 such round trips in one batch.
|
||||
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return func(flagName string) ([]byte, error) {
|
||||
idx, err := loadFlagSchemas()
|
||||
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return json.MarshalIndent(map[string]interface{}{
|
||||
"shortcut": command,
|
||||
"introspectable_flags": flags,
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
|
||||
}, "", " ")
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
name, path := splitSchemaPath(flagName)
|
||||
schema, ok := entry[name]
|
||||
if !ok {
|
||||
// Tolerate the wire-vocabulary underscore form (--flag-name
|
||||
// border_styles for border-styles) — agents copy field names out
|
||||
// of JSON payloads where underscores are canonical.
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
schema, ok = entry[alt]
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
@@ -114,14 +131,121 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
if err := json.Unmarshal(schema, &pretty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path) > 0 {
|
||||
pretty, err = sliceSchemaByPath(pretty, name, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
return json.MarshalIndent(pretty, "", " ")
|
||||
}
|
||||
}
|
||||
|
||||
// splitSchemaPath splits a --flag-name value into the flag name and the
|
||||
// optional dotted schema path after it.
|
||||
func splitSchemaPath(flagName string) (string, []string) {
|
||||
parts := strings.Split(flagName, ".")
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
|
||||
// Each segment matches a key under "properties"; array levels are descended
|
||||
// implicitly through "items" (an explicit "items" segment also works), and
|
||||
// oneOf branches are searched for the first one carrying the key. A miss
|
||||
// errors with the keys actually available at that level so the caller can
|
||||
// re-issue the path without a full dump.
|
||||
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
|
||||
node := schema
|
||||
walked := flagName
|
||||
for _, seg := range path {
|
||||
next, ok := schemaChild(node, seg)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
node = next
|
||||
walked += "." + seg
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// schemaChild resolves one path segment against a schema node, descending
|
||||
// through items / oneOf wrappers as needed.
|
||||
func schemaChild(node interface{}, seg string) (interface{}, bool) {
|
||||
for depth := 0; depth < 8; depth++ {
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if seg == "items" {
|
||||
if items, ok := m["items"]; ok {
|
||||
return items, true
|
||||
}
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
if child, ok := props[seg]; ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
node = items
|
||||
continue
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
if child, ok := schemaChild(b, seg); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// schemaChildKeys lists the property keys reachable at a schema node (through
|
||||
// items / oneOf wrappers), for the path-miss error.
|
||||
func schemaChildKeys(node interface{}) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var collect func(n interface{}, depth int)
|
||||
collect = func(n interface{}, depth int) {
|
||||
if depth > 8 {
|
||||
return
|
||||
}
|
||||
m, ok := n.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
for k := range props {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
return
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
collect(items, depth+1)
|
||||
return
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
collect(b, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(node, 0)
|
||||
keys := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -204,3 +204,109 @@ func keysOf(m map[string]interface{}) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPrintSchema_DottedPathSlicing covers --flag-name's dotted-path form,
|
||||
// which had no tests at all: disabling the implicit items/oneOf descent, or the
|
||||
// explicit "items" segment, broke nothing.
|
||||
//
|
||||
// The feature exists so agents can pull one subtree out of chart-create's
|
||||
// ~1,750-line properties schema instead of paging the whole dump (SKILL.md
|
||||
// points at it by name). A silent regression pushes them straight back to full
|
||||
// dumps, which is invisible in any output-correctness test.
|
||||
func TestPrintSchema_DottedPathSlicing(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+chart-create")
|
||||
|
||||
decode := func(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var node map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
t.Fatalf("schema slice is not a JSON object: %v", err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
props := func(t *testing.T, node map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
p, ok := node["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("node has no properties: %v", node)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
t.Run("one segment walks into properties", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := print("properties.snapshot")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
if _, has := props(t, decode(t, raw))["plotArea"]; !has {
|
||||
t.Errorf("snapshot subtree should expose plotArea, got %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array levels are descended implicitly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// data.refs is an array; naming the field must land on the ITEM shape,
|
||||
// not force the caller to spell ".items".
|
||||
raw, err := print("properties.snapshot.data.refs")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
node := decode(t, raw)
|
||||
if node["type"] != "array" {
|
||||
t.Errorf("refs should still be the array node, got %v", node["type"])
|
||||
}
|
||||
deeper, err := print("properties.snapshot.data.refs.value")
|
||||
if err != nil {
|
||||
t.Fatalf("descending through array items failed: %v", err)
|
||||
}
|
||||
if len(deeper) == 0 {
|
||||
t.Error("expected the item's value field")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an explicit items segment also works", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := print("properties.snapshot.plotArea.axes.items"); err != nil {
|
||||
t.Fatalf("explicit items segment failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a slice is strictly smaller than the whole flag schema", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
full, err := print("properties")
|
||||
if err != nil {
|
||||
t.Fatalf("full dump failed: %v", err)
|
||||
}
|
||||
slice, err := print("properties.snapshot.plotArea.axes")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
if len(slice) >= len(full) {
|
||||
t.Errorf("slice is %d bytes vs %d for the full schema — slicing saves nothing", len(slice), len(full))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a miss names the keys actually available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := print("properties.snapshot.nope")
|
||||
if err == nil {
|
||||
t.Fatal("want an error for an unknown segment")
|
||||
}
|
||||
ve := requireValidation(t, err, `no "nope" under properties.snapshot`)
|
||||
if !strings.Contains(ve.Message, "plotArea") {
|
||||
t.Errorf("the miss must list the reachable keys so the caller can retry without a full dump, got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--flag-name" {
|
||||
t.Errorf("param = %q, want --flag-name", ve.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the underscore spelling of the flag still resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := printFlagSchemaFor("+cells-set-style")("border_styles"); err != nil {
|
||||
t.Fatalf("underscore flag name should resolve to border-styles: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user