Compare commits

...

56 Commits

Author SHA1 Message Date
xiongyuanwen-byted
b449614be9 chore(sheets): resync skill version 3.1.2 from sheet-skill-spec
Consumer-side artifact of sheet-skill-spec 46fb74e ("bump lark-sheets skill
版本至 3.1.2"). Frontmatter version only — the rest of SKILL.md, the references
and shortcuts/sheets/data are unchanged and stay byte-identical to the spec
repo's generated/lark-cli tree.
2026-08-02 10:16:21 +08:00
xiongyuanwen-byted
8e2f517827 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-08-01 00:52:21 +08:00
xiongyuanwen-byted
f6353368d7 fix(sheets): drop the Go 1.24-only t.Chdir, and gofmt the ergonomics test
CI's fast-gate failed on the receipt test added in 01ae4cc5: t.Chdir landed in
Go 1.24, and go.mod declares 1.23.0 — which is what CI resolves its toolchain
from. Locally it compiled against 1.24.4, so the whole suite looked green.
Replaced with the os.Chdir + t.Cleanup pattern the repo already uses elsewhere.

Also gofmt'd flag_ergonomics_test.go, whose struct literal alignment shifted
when the rejectHint field was added.

Verified by reproducing fast-gate under an actual go1.23.0 toolchain: build,
vet ./..., gofmt -l, go mod tidy, plus the -race unit tests CI runs.
2026-08-01 00:37:30 +08:00
xiongyuanwen-byted
c4270566ba test(sheets): pin the flat style flags' wire mapping, found by coverage
Coverage over the changed lines showed six of buildCellStyleFromFlags' eleven
branches never executed. TestStylesAcceptance_VocabularyParity walks the same
vocabulary but drives the PAYLOAD path; the flat --font-color / --number-format
/ … flags are a separate hand-written mapping that nothing exercised.

The failure it admits is quiet: write the wrong wire key and the request still
succeeds, the sheet just does not change. Verified the current mapping is
correct for all eleven fields before pinning it, and confirmed the test is
sensitive (renaming font_color's wire key now fails). Derived from flag-defs, so
a newly declared style flag is covered as soon as it exists.
2026-08-01 00:22:22 +08:00
xiongyuanwen-byted
75e8063bf2 test(sheets): cover --flag-name dotted-path slicing, which had none
Mutation testing found the whole feature unpinned: disabling the implicit
items/oneOf descent, or the explicit "items" segment, broke no test. It is how
SKILL.md tells agents to pull one subtree out of chart-create's ~1,750-line
properties schema instead of paging the full dump, so a regression just pushes
them back to full dumps — invisible to every output-correctness test.

Covers the one-segment walk, implicit descent through array items, the explicit
items segment, the underscore spelling of the flag name, that a slice is
strictly smaller than the full schema, and that a miss lists the keys actually
reachable at that level. Also resyncs the two read-helper script fixes from
sheet-skill-spec.
2026-08-01 00:17:36 +08:00
xiongyuanwen-byted
ae8b1a4cf3 docs(sheets): resync write-cells reference after the scattered-write routing fix 2026-08-01 00:02:53 +08:00
xiongyuanwen-byted
01ae4cc521 test(sheets): pin three guarantees that mutation testing showed nothing held
Third review pass, run as mutation testing rather than reading: break a
behaviour, see whether anything fails. Most of the suite has teeth (removing
the coalesce reordering guard, the descending sort in +dim-delete --ranges, the
sub-op vocabulary check, the cells-vs-range dimension check, the border weight
normalization, the retired-enum tolerance and the batch freeze-collision
warning all get caught). Three did not.

- coalesceStyleStamps' adjacency rule. Widening the touch test from +1 to +2
  fuses ranges separated by one row, painting cells the caller never named,
  and passed every existing case — they are all contiguous, so they only
  constrain the rule from one side. Added the negative cases plus a coverage
  property: the set of cells the expanded stamps touch must equal the set the
  spec named. Coalescing rewrites a declarative spec, so this is the invariant
  that matters, not any single fusion.

- The --output-path receipt. Hard-coding complete:true passed the whole suite,
  yet the receipt is the only completeness signal on that path (the data went
  to a file) and the skill docs tell agents to read it before using the file.
  Now driven end to end for clean / per-range-truncated / has_more reads, with
  the file contents and bytes_written checked against the receipt.

- +table-get's whole-workbook char budget. Removing the per-sheet clamp lets
  each sheet spend --max-chars in full — a 30-sheet workbook pulls 30x what
  was allowed, with every individual request looking compliant. The outer
  loop's exhaustion check is a different mechanism and kept working, which is
  why nothing failed. Now asserted on the wire: sheet 2 must ask for less than
  sheet 1.
2026-07-31 23:53:36 +08:00
xiongyuanwen-byted
99ac5d6906 fix(sheets): resync the profile-script hidden-column fix, drop a tautological test
Skill/data resync from the spec repo's matching commit: safe_append_col now
steps over hidden columns (see sheet-skill-spec), and the +batch-update
shortcut enum names +styles-put among the excluded fan-out wrappers.

Also fixes the --max-chars 0 test added in f619e34d, which asserted the value
equalled maxCharsFallback — the constant it is derived from, so it would have
passed unchanged after the flag's declared default moved in flag-defs.json and
left the two out of step. It now compares the explicit-zero call against the
omitted one, which is the actual contract ("0 means no cap of my own").
2026-07-31 23:39:47 +08:00
xiongyuanwen-byted
fd97e65b55 docs(sheets): record the live +dim-insert side check that the code assumed
The previous commit sent `side` explicitly on +dim-insert and justified it as
guarding a real hazard: that omitting it left placement to an undocumented
backend default, which if it were "after" would insert on the wrong side of
--position. Running it settles the question the other way.

On a sheet with row2 red and row3 blue, inserting at --position 3 places the
blank at row 3 under all four spellings — no `side` field at all (the pre-fix
binary), omitted, `after`, and `before` — and the blank inherits the following
row's blue under omitted/`after`, the preceding row's red under `before`. So
the backend already defaults `side` to "before", the pre-existing behaviour was
correct, and the flag's documented "omit == after" held on its own.

Sending the field explicitly is kept, but as belt-and-braces: it stops the
documented default from depending on an undocumented server-side one, and the
placement is byte-identical either way. Comments and the test doc now say that
instead of claiming a bug that was not there.
2026-07-31 23:29:59 +08:00
xiongyuanwen-byted
4e2c6a2096 docs(sheets): resync skill refs and flag data from sheet-skill-spec
Regenerated from the spec repo's matching commit: the +styles-put failure
guidance (operations[N] indexes are internal, read back and re-send instead),
--dry-run rather than --yes in the two canonical +batch-update snippets, and
the read-cap wording for --max-chars 0 / --output-path's JSON payload.
2026-07-31 23:21:03 +08:00
xiongyuanwen-byted
f619e34d38 fix(sheets): close review gaps in freeze, styles and error reporting
Review of the aggregate diff turned up nine places where the surface did not
do what this PR says it does. Each is small; the theme they share is that a
prescription pointed somewhere the caller could not follow.

Contradictions with this PR's own retirements:

- The six --frozen-* unknown-flag hints prescribed --dimension row --count N.
  Those flags are hidden from --help, so the hint named a flag missing from
  the valid-flags list printed beside it, and following it earned a
  deprecation note steering back. They now prescribe --rows / --cols, as does
  the "nothing to freeze" error.

- A Sheet! range prefix was only stripped when the styles item carried a name.
  +workbook-create --values items need none, so row_sizes like "Sheet1!2:3"
  still failed there as a malformed range — the exact bug this PR reports as
  fixed across all three --styles carriers. Stripping is now unconditional;
  only the "names a different sheet" report needs a name to compare against.

- Two +dim-freeze sub-ops in one batch cancel each other, and only the CLI can
  see it (a batch cannot read current state, and +styles-put is not batchable).
  Per-op "equivalent to --rows 1" notes never said so. A collision note now
  names the colliding ops, the state actually reached, and the single sub-op
  that holds both axes. dimFreezeAxes/dimFreezeSpelling became the shared
  mapping so the request body, the deprecation note and this one cannot drift.

- +dim-insert with --inherit-style omitted sent no `side`, so "omitting is the
  same as after" held only if the backend happened to default it to before —
  and if it defaulted to after, the insert would land on the wrong side of
  --position, silently breaking the command's stated contract. It is now sent
  explicitly; TestDimInsertOmittedMatchesAfter pins the two bodies together.

Errors that misdescribed themselves:

- "resend only operations[N:]" was emitted for every batch_update caller, but
  only +batch-update's array is caller-written. +styles-put coalesces and
  +dim-delete --ranges deliberately re-sorts descending, so the index names
  nothing the caller can locate. Those callers now get a read-back procedure.

- A sub-op carrying both an alias and its target (size + width on
  +cols-resize) was reported as an unknown input key: the alias branch fell
  through, and the conflict check never fired because keys are walked in
  sorted order and "size" sorts first. Identical values now drop the alias;
  differing ones name both spellings.

- Folding per-item failures into one error dropped each inner Hint, so the
  more mistakes a payload had, the less guidance it got — including the
  +workbook-info pointer this PR had just added. A lone issue inherits the
  hint; a folded list inlines each.

- --max-chars 0 sent no cap, which makes the read tool apply its own ~50000
  fallback: asking for no limit produced the smallest one. It now resolves to
  the same ceiling as leaving the flag alone.

Also: --inherit-style before anchors one row/column earlier, so its dry-run
showed a position the caller never typed; a note explains it is not an
off-by-one. The style vocabulary now walks sorted keys everywhere, since
every one of those loops can abort and map order decided which of several bad
fields got reported.
2026-07-31 23:20:48 +08:00
xiongyuanwen-byted
3e1623c631 docs(sheets): resync skill refs after the +styles-put routing fix
Picks up the sheet-skill-spec change that stops range-operations from
mandating +batch-update for multi-region merges, which contradicted the
routing +styles-put introduced.
2026-07-31 20:00:38 +08:00
xiongyuanwen-byted
b2551fd70b docs(sheets): resync skill entry doc after the +dim-freeze example fix
Picks up the sheet-skill-spec change that replaces the retired
--dimension/--count example in SKILL.md's quick reference with
--rows/--cols.
2026-07-31 19:50:58 +08:00
xiongyuanwen-byted
50b1f54bfa fix(sheets): make a retired enum value read as absent on both paths
Clearing a retired value left the two paths disagreeing about Changed():
the standalone path went through cobra's Set, which marks a flag as
changed, while the batch path deletes the raw key and so reports false.

--inherit-style is unaffected because its only consumers switch on the
value, but retiredEnumValues is a generic table — the next flag added to
it whose logic reads Changed() rather than the value would behave
differently standalone than inside +batch-update, and the body-parity
contract only catches such a split once it reaches the request body.

Both paths now report the flag as absent, pinned by a test that asserts
Changed() directly instead of only comparing bodies.
2026-07-31 19:41:18 +08:00
xiongyuanwen-byted
f8ed143bde fix(sheets): accept the retired --inherit-style none instead of failing
Dropping "none" from the +dim-insert enum was the only value removal in
the whole flag surface, and it broke callers: "none" was valid AND the
default before the side mapping was corrected, so existing scripts and
any agent carrying older docs pass it.

A retired value is now cleared rather than rejected, making the call
byte-identical to omitting the flag — on the standalone path and inside
+batch-update alike, since +dim-insert is batchable and a divergence
there would be invisible. It stays out of the published enum so the docs
and --help do not start teaching the redundant spelling again, and a
genuinely invalid value is still rejected.

The test mounts the shortcut through Shortcuts() rather than the exported
var: the rewrite rides the PostMount ergonomics layer, which the raw var
does not carry, so a test built on it would not exercise what ships.
2026-07-31 19:22:50 +08:00
xiongyuanwen-byted
92c5b0f26d revert: keep the event +subscribe marker out of this sheets PR
The DEPRECATED(phase-2) convention is introduced here, but retrofitting
it onto shortcuts/event/subscribe.go widened a sheets-scoped PR into an
unrelated package for a comment-only change. That retrofit moves to its
own change; the convention and the two sheets sites stay.
2026-07-31 19:01:33 +08:00
xiongyuanwen-byted
47f4fda1d3 fix(sheets): steer legacy +dim-freeze on dry-run and inside +batch-update
The phase-1 deprecation note for --dimension/--count only fired from the
standalone Execute, missing the two paths that matter most.

--dry-run is how a caller previews before committing to a spelling, so a
note that arrives only on execute arrives too late. It now rides along as
the dry-run warning_message.

+batch-update never reached the note at all, yet the batch is where the
legacy form does the most damage: freeze is full-state replacement, so
two per-axis sub-ops both report success while only the last axis stays
frozen, and +styles-put — the other way to set both axes — is not
batchable. Sub-op notes are rendered from the same helper as the
standalone one, prefixed with the operations[i] index, and DryRun and
Execute now share one batchWarnings collector so they cannot drift apart
on which advisories they report.
2026-07-31 18:57:14 +08:00
liangshuo-1
003d0f42f8 chore: release v1.0.81 (#2136) 2026-07-31 18:47:19 +08:00
xiongyuanwen-byted
7866801115 chore: tag phase-2 deprecation sites with a greppable marker
Three surfaces are in phase-1 deprecation — executable but superseded —
and each described that state in its own prose, so there was no way to
enumerate what is pending removal.

DEPRECATED(phase-2) tags all three uniformly, each carrying its removal
checklist including the cross-repo spec step.
2026-07-31 18:41:28 +08:00
xiongyuanwen-byted
3624499bcb feat(sheets): +dim-freeze --rows/--cols for both axes in one call
Freeze is full-state replacement server-side, so --dimension/--count
unfreezes the axis it does not name. Two calls cannot hold both axes, and
inside +batch-update there was no way at all: sub-ops are a static array
that cannot read the current state, and +styles-put is not batchable.

--rows/--cols state the complete freeze state in one operation, so the
standalone command and the batch sub-op keep producing identical bodies.
The legacy pair still works and prints the exact --rows/--cols equivalent
on use, but is hidden from --help and from the skill docs.
2026-07-31 18:41:23 +08:00
xiongyuanwen-byted
13d4350557 fix(sheets): unify --styles range prefixes, merge freeze into one op
A "Sheet!" prefix on a --styles range was only normalized for +styles-put
cell_styles. row_sizes like "Sheet1!2:3" therefore failed parseA1Range on
all three carriers, while a prefix naming another sheet was rejected only
after the section parsers had already reported their own errors.

Normalization now runs in the shared item parser, so +workbook-create,
+table-put and +styles-put behave alike; a foreign prefix is stripped and
reported alongside the item's other issues instead of masking them.
Prefix stripping for visual ops moved into workbookCreateVisualOpInput,
which every caller goes through.

Freeze rows and columns now travel in one operation: the backend replaces
the whole freeze state, so two per-axis operations left only the last
axis frozen.
2026-07-31 18:41:17 +08:00
wangweiming-01
7946e5c81d feat: support source file preview artifacts (#2085) 2026-07-31 17:52:31 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +08:00
chenxingyang1019
41692b7041 feat(apps): add cache debug commands (+cache-get/-delete/-clear) (#1896)
* feat(apps): add cache debug commands (+cache-get/-delete/-clear)

Add three apps-domain cache debug shortcuts for inspecting/clearing an app's
runtime cache:
- +cache-get: read a business key's value + metadata (hit/miss)
- +cache-delete: delete a single key (idempotent, write)
- +cache-clear: clear all cache in an environment (high-risk-write, --yes)

value renders raw on --format json, deserialized on --format pretty;
value_size_bytes is computed CLI-side; --environment auto-selects the branch
when omitted. Includes unit tests (hit/miss/dry-run/confirmation) and the
lark-apps cache skill reference.

* fix(apps): normalize cache numeric output fields and tidy comments

Follow-up hardening for the cache debug commands (+cache-get/-delete/-clear):

- Normalize ttl_ms / deleted_key_count via a new cacheInt() helper so
  --format json emits a stable JSON number (or null) regardless of whether
  the server sends the value as a number or a string. Aligns with the
  repo convention that numeric wire fields may arrive as strings; previously
  these were passed through raw, leaving the output type at the server's mercy.
- Add unit tests locking the string-wire -> JSON number contract for both
  cache-get ttl_ms and cache-delete deleted_key_count.
- Tidy two comments: soften cacheBool's speculative "historical wire form"
  claim to a defensive-tolerance note, and drop implementation jargon from
  cache-delete's risk-level rationale.
2026-07-31 14:13:56 +08:00
dc-bytedance
b79827d60a fix: drop stale target version from root upgrade prompt (#2100) 2026-07-31 12:45:43 +08:00
zhaojiaxing-coding
0f35676a28 feat(drive): extend permission shortcuts for Miaoda (#2070)
* feat(drive): support Miaoda apps in permission shortcuts

Extend Drive permission shortcuts to accept Miaoda page URLs and the apps resource type while keeping each endpoint's accepted resource contract explicit.

Key features:

- Infer apps from /page/ URLs and accept explicit --type=apps in +apply-permission, +member-add, +member-list, and +permission-get-setting

- Decouple secure-label target parsing so expanding apply-permission does not widen secure-label support

- Align skill guidance and unit/dry-run coverage with the new resource type

* test(drive): cover apps permission target validation

Add focused coverage for Miaoda apps target handling across apply-permission and secure-label boundaries.

Exercise malformed page URLs, explicit apps bare tokens, typed validation errors, and command-level rejection so future resource-type changes cannot silently widen unsupported secure-label behavior.

* fix(drive): parse permission markers from URL paths

Keep drive +apply-permission resource inference aligned with URL component boundaries. Parse and validate URL inputs before extracting tokens so query strings and fragments cannot redirect permission requests to a different resource.

Key fixes:

- Match document and apps markers only against the parsed URL path

- Reject malformed URLs with a typed --token validation error

- Cover /page/ markers found only in query strings or fragments

* docs(skills): redact Miaoda page token example

Replace the concrete Miaoda page token with a representative pagcn placeholder. This keeps the token shape recognizable while avoiding exposure of a real resource identifier in the skill documentation.

* fix(drive): harden permission target resolution

Make Drive shortcut targets unambiguous before they reach read or write API paths. URL inputs now bind to a recognized root path and a single validated token segment, preventing encoded separators, dot segments, and type conflicts from silently changing the addressed resource.

Key fixes:

- Reject non-root URLs, dot/traversal tokens, and URL/type conflicts for secure-label and permission-apply writes

- Keep permission-setting URL parsing and pretty output reversible for every supported command-local resource kind

- Add unit and dry-run E2E regressions plus aligned permission-apply guidance
2026-07-31 12:16:22 +08:00
wangweiming-01
946964e093 fix(drive): use title for default download filename (#2089) 2026-07-31 12:12:11 +08:00
xiongyuanwen-byted
daeab10755 fix(sheets): completeness classifier, freeze alias determinism, color arrays
Fifth-round review findings, each reproduced before fixing:

P1:
- readResultTruncated missed ranges[] (the shape +cells-get / +csv-get
  return), so a clipped read could be written to a file and reported as
  complete:true. It now checks every level — top, ranges[], sheets[], and
  nesting — under all three flag names the tools use. readSheetAsSpec also
  keeps the truncation flag on the empty-grid path, where a read clipped
  before its first row used to be reported as a complete empty sheet.
- freeze accepted "cols" and "columns" as aliases and wrote both through a
  Go map range, so {"cols":1,"columns":2} froze 1 or 2 columns depending on
  randomized iteration order (observed both across 2000 runs). Conflicting
  values now reject; identical values and either alias alone still pass.
  Iteration is sorted so error text is stable too.

P2:
- the --styles / --writes aggregators returned the raw inner error when
  there was exactly one issue, leaving Param empty until a second issue
  appeared. They now attribute the outer flag and keep the cause in every
  case. checkBatchStampBudget takes the caller's flag name instead of
  always blaming --ranges.
- chart color normalization lost key context when descending into arrays,
  so schema-supported colorTheme / colorScale / highlight_colors lists kept
  bare hex and the server rejected them. isColorKey also covers plural and
  color-prefixed names; the strict hex value check keeps that safe.
- detect_subtables split records on any physical line starting with
  [row=N], so that text inside a multi-line quoted cell forged a record
  boundary and polluted row numbers. Splitting is now quote-state aware,
  and annotated mode is entered only when the first meaningful line really
  carries a prefix.
- docs: --output-path no longer described as unlimited anywhere; the
  read-data reference documents the per-sheet truncated flag and the
  offload receipt's complete / unread_sheets fields.
2026-07-31 12:08:23 +08:00
HanShaoshuai-k
cfe76ad56a ci: add protected public domain allowlists (#2111)
Co-authored-by: HanShaoshuai-k <268785735+HanShaoshuai-k@users.noreply.github.com>
2026-07-31 11:02:04 +08:00
calendar-assistant
fa9c30c690 docs(calendar): confirm scope before editing recurring events (#2119)
Promote the recurring-event rule to a pre-routing gate so it is read
before the specific operation flow, and require confirming the scope
(this event / all / this-and-following) when the user is ambiguous
instead of defaulting to this-event-only. Removes the redundant and
conflicting "edit existing event" row that hard-coded the single-
instance default.
2026-07-30 21:59:08 +08:00
zcc
ba95252019 feat(drive): add comment-operation shortcuts (#1898)
Add comment-domain shortcuts: +batch-query-comments, +resolve-comment,
+restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply
and +react-reply, sharing one target resolver with per-endpoint file_type
sets.

Flatten the comment reference docs by dropping the comments-guide routing
layer and folding its cross-command knowledge into the command refs:
comment-card model, comment/reply/interaction counting and sorting rules
into lark-drive-list-comments.md; the --solved-status prerequisite into
lark-drive-restore-comment.md; the apps exception into
lark-drive-add-comment.md. Comment intents now route straight from the
drive SKILL.md Shortcuts table to each command ref.

Cover the new shortcuts with unit tests, dry-run e2e and live workflow
e2e behind LARK_DRIVE_MD_COMMENT_E2E=1, and register them in
tests/cli_e2e/drive/coverage.md.
2026-07-30 21:53:21 +08:00
zhouyue-bytedance
4a16139348 fix(base): resolve Base URL block types accurately (#2099)
* fix: resolve Base URL block types accurately

* fix: resolve Base block selection from Wiki URLs

* fix(base): guide resolved folder and docx blocks

* fix(base): avoid field fallback for untyped URL blocks

* docs(base): specify URL example fence language

* test(base): cover unmatched URL block resolution
2026-07-30 20:29:47 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +08:00
xiongyuanwen-byted
6402cb6a3a chore: drop remaining local trace report artifacts
The sheet_cli_trace_report_*.xml files are local eval artifacts swept in by
git add -A alongside the review fixes; they are not part of the PR.
2026-07-30 17:08:16 +08:00
xiongyuanwen-byted
8491775659 chore: drop accidentally committed local trace report
sheet_cli_trace_report_20260730.xml is a local eval artifact that predates
this branch; it was picked up by git add -A and does not belong in the PR.
2026-07-30 17:03:11 +08:00
xiongyuanwen-byted
f5e0d14ca9 fix(sheets): key uniqueness, order-safe style coalescing, bounded read budget
Fourth-round review findings, all reproduced before fixing:

P1 — silent wrong results:
- batch/writes key canonicalization now guarantees ONE logical key per flag.
  Both sheet-id and sheet_id used to pass the vocabulary check and survive
  into the body, where the flag view resolves either spelling — so one value
  silently won and the write could land on the wrong sheet. Conflicting
  values now reject with a typed error; identical values collapse.
- coalesceStyleStamps no longer groups globally by style content. Grouping
  reordered same-style stamps around differing ones, turning red → blue →
  red into red → blue and silently changing the final color. Two stamps are
  merged only when nothing between them touches the cells whose write would
  move earlier; adjacent and disjoint-intermediate merges still happen.
- --output-path is no longer described or treated as an unlimited read. The
  cap bounds the WHOLE read: +table-get spends one budget across all sheets
  instead of per sheet, names sheets left unread, and the stdout receipt
  now reports complete / truncated so a caller need not reopen the file.
  dry-run echoes the same max_chars / cell_limit execute sends.

P2 — contract violations:
- style payloads (--styles, typed --cells) assert the scalar types declared
  in flag-defs, so {"font_weight": true} is rejected instead of reaching
  the server as a boolean.
- aggregated --styles / --writes errors bind their flag as Param and keep
  the first underlying error as Cause, so agents read the typed envelope
  instead of parsing prose.
- detect/profile scripts handle hidden COLUMNS like hidden rows: visible-
  column adjacency stops splitting A|C into two components, and profile
  reports data_col_segments plus a data_range_has_col_gaps warning so a
  caller cannot write gap-free data back as one contiguous range.
2026-07-30 17:02:59 +08:00
xiongyuanwen-byted
240e523dbf fix(sheets): drop remaining atomic claims from --help text and errors
The earlier atomic-wording cleanup covered spec-managed docs and flag
descriptions but missed the Go-side Description / Tips fields (which
render in --help) and two sub-op rejection messages. All of these
batch_update-backed paths are fail-fast without rollback, so they now say
so instead of promising atomicity.
2026-07-30 15:46:38 +08:00
xiongyuanwen-byted
9223754af1 docs(sheets): add non-POSIX shell adaptation table for composite JSON inputs
Bash-only patterns in the skill (heredoc, POSIX quote escaping, inline
single-quoted JSON) had no PowerShell/cmd.exe branch, so a Windows agent
copying them would fail and start trial-and-error quote rewriting. One
centralized rule in the stdin section now routes non-POSIX shells:
@file (cwd-relative) as the quote-safe form everywhere, Get-Content
piping for PowerShell stdin, and no inline JSON on cmd.exe. Synced from
sheet-skill-spec.
2026-07-30 11:37:09 +08:00
xiongyuanwen-byted
3788b6f601 fix: revert retry-command renderer and temp @file exception, bound read offload
Third-round review (comprehensive CR on PR #2091) — close the P1s by
reverting the two global protocol changes and tightening the rest:

- cmdutil confirm (F1): drop the argv-rebuilt "re-run:" retry line and
  every heuristic behind it; the hint is the plain "add --yes to
  confirm" again. argv cannot faithfully reproduce the invocation
  (pipelines, stdin, redirections, env, executable path), POSIX quoting
  breaks on PowerShell/cmd.exe, and the name-based secret guard both
  leaked free-form payloads and false-positived on ordinary token
  locators (--spreadsheet-token). Callers append --yes to their own
  saved argv after user consent, per the lark-shared protocol.
- localfileio/validate/cmdutil (F4): remove the SafeTempAbsInputPath
  @file exception — TMPDIR-defined trust roots are not a security
  boundary (TMPDIR=/etc widened the allowed region) and the fast path
  bypassed the caller's FileIO provider. @file is strictly cwd-relative
  again; out-of-tree content goes through stdin.
- csv guard (F5): stop splicing the untrusted --csv value into
  command-shaped hint text; prescriptions use <path> placeholders, the
  value is only named as quoted data.
- read offload (F8): --output-path now raises max_chars to a bounded
  20M-char default instead of the 1e9 sentinel — the read path is not
  streaming, so the cap is the OOM guard; an explicit --max-chars still
  overrides.
- batch-update tips + skill (F2, synced from sheet-skill-spec): replace
  "always pass --yes" / "首次调用就带 --yes" with the consent protocol
  (dry-run, show the plan, get explicit approval, then append --yes).
- skill docs (F12, synced): scripts/lark_*.py are an optional
  enhancement — binary-embedded skills ship without scripts/, so the
  docs now say so and point at the CLI-equivalent fallback paths.
2026-07-30 11:15:53 +08:00
xiongyuanwen-byted
0e95848dd7 fix: address second-round PR #2091 review findings
Reviewer findings (fangshuyu-768) plus one CodeRabbit follow-up:

- sheets batch recovery: thread the batch's continue_on_error mode into
  flattenToolErrorMsg — a single listed failure only implies "nothing
  after it ran" under fail-fast; under continue-on-error the tail already
  executed, so prescribing operations[i:] would double-apply it
- cmdutil confirm: gate the retry line on value shape as well as flag
  name — free-form payloads (--sql, --json, non-ASCII, over-long) fall
  back to the plain add-yes hint instead of copying potential passwords
  or PII into the error envelope
- sheets read offload: map FileIO.Save failures through
  WrapSaveErrorTyped so an escaping --output-path stays a validation
  error with its path-validation cause, not internal/unknown
- styles-put: reject ranges whose sheet prefix names a different sheet
  than their item (silent strip retargeted them); reject unknown
  top-level item keys with a did-you-mean (typo'd freezee was silently
  dropped) — shared with +workbook-create / +table-put
- e2e: cover --inherit-style after in the live dim workflow
- docs/flag descs (synced from sheet-skill-spec): batch_update-backed
  paths no longer claim atomic/transactional semantics — reworded to
  fail-fast + no-rollback across +styles-put, +cells-set --writes,
  +dim-delete --ranges, resize maps and the related references
2026-07-29 18:55:57 +08:00
xiongyuanwen-byted
e41e36e1d9 chore(sheets): sync lark-sheets skill from sheet-skill-spec
Aligns +styles-put with the +batch-update execution semantics: the spec
repo's canonical reference no longer promises all-or-nothing, since the
expanded batch is fail-fast and does not roll back.

- styles-put reference: use-case + Execute contract now state fail-fast
  without rollback, and how to resend only the remaining sub-operations
  (styles / sizes / freeze are idempotent, cell_merges is not)
- SKILL.md index entry: "原子提交" -> "一次提交"

Generated from sheet-skill-spec 2a95dab via `npm run sync:cli`.
2026-07-29 17:30:25 +08:00
xiongyuanwen-byted
a5032bbb55 fix: address PR #2091 review findings across path safety, batch dispatch and read caps
Review fixes from CodeRabbit / code-quality on PR #2091:

- cmdutil: route the @/tmp fast-path read through vfs.ReadFile so fakes
  keep intercepting it; suppress the confirmation retry line when argv
  carries a credential-bearing flag (secrets stay out of error envelopes);
  extract requireConfirmationFor and pin the composed hint in tests
- localfileio: reject a degenerate TMPDIR (e.g. "/") so the temp-dir
  read exception cannot widen into accepting arbitrary absolute paths
- sheets batch dispatch: duplicate canonical + variant input keys
  (sheetName/sheet_name, ranges/range) now reject with a prescriptive
  error instead of silently overwriting the canonical value
- sheets flag ergonomics: curated unknown-flag hints match underscore
  spellings (--frozen_rows hits the --frozen-rows entry)
- sheets read offload: an explicit --max-chars survives --output-path
  instead of being replaced by the unbounded sentinel
- chart: declare --print-example in flag-defs.json (single source with
  the generated reference tables) instead of imperative registration;
  assert the validation Param in the unknown-type test
- e2e: add live +dim-insert / +dim-delete workflow coverage (insert
  lands before position, --range delete, atomic scattered --ranges)
- docs (synced from sheet-skill-spec): +chart-create flags table gains
  --print-example, freeze docs state the at-least-one-positive rule,
  read-data table gets its MD058 blank line, detect-subtables explains
  its fallback except
2026-07-29 14:24:20 +08:00
xiongyuanwen-byted
d8f6154e2f chore: add license headers to sheets skill scripts, drop redundant loop-var copies 2026-07-29 12:56:34 +08:00
xiongyuanwen-byted
d219d61be0 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	internal/validate/path.go
#	internal/vfs/localfileio/path.go
2026-07-29 12:31:44 +08:00
xiongyuanwen-byted
f79908483d fix(sheets): reject path-shaped --csv values instead of writing them into the sheet
A +csv-put --csv value naming a file that doesn't resolve used to be written
into the anchor cell as literal text, with a success exit code — a wrong value
in the sheet that nothing surfaces, which costs more than a rejection. The
common source is an absolute path: @ only reads relative paths, so the caller
drops the @ and retries, and the path string lands in A1 (07-28 root-cause
audit, finding D1). The existing guard only caught values naming a file that
does exist (the forgotten-@ case).

The guard now also rejects a value that is unmistakably path-shaped: no
separator or whitespace, pure ASCII, and either a .csv/.tsv extension or an
explicit ./ ../ / ~/ prefix. All three conditions are required — that is what
keeps prose that merely mentions a filename, N/A, README.md and CJK content
out of it, the misjudgments that retired the previous name-shape heuristic.
This flips one pinned case: a bare "nope.csv" was previously written verbatim
and now errs with the fix inlined.

To make the shape check safe for correct invocations, resolveInputFlags now
records which flags had their value replaced from @file or stdin, exposed as
RuntimeContext.InputResolvedFromSource; the guard skips resolved values
entirely. By Validate time a piped value is indistinguishable from a typed
one, so without the origin bit the guard would re-reject a correct
`--csv @file` whose content happens to look like a path — and stdin, which
the error text prescribes for verbatim writes, would not actually escape it.
The @@ escape stays inline and guarded. This origin bit is the one
common-layer addition; it carries no domain logic.
2026-07-29 12:00:47 +08:00
xiongyuanwen-byted
52b5910fb1 chore(sheets): sync lark-sheets skill and flag data from sheet-skill-spec
Mirrors `npm run sync:cli` output from the spec repo, which is the source of
truth for skill docs and flag data. Two independent changes ride along:

- The border and sheet-selector flag descriptions from spec commit 3dd7f9c,
  matching the help text already committed here in 0b595562 (data/flag-defs.json
  is byte-identical, so `go generate` is a no-op).
- The upstream read-flow work: SKILL.md 3.1.0 to 3.1.1, a longer read-data
  reference, and five read-side helper scripts under skills/lark-sheets/scripts.

The scripts land as machine resources and are not embedded in the binary
(content_embed.go whitelists docs only). make unit-test passes.
2026-07-28 21:06:13 +08:00
xiongyuanwen-byted
0b59556207 feat(sheets): name the enum, the required selector and the missing envelope in errors and help
Second batch from the 07-28 root-cause analysis, all aimed at the retry that
follows a rejection.

Enum-bearing type mismatches now answer with the allowed values instead of a
whole-payload skeleton. --border-styles with weight:1 used to reply "expected
type string, got number; expected shape: {"bottom": {…}, "left": {…}, …}",
which never mentions thin/medium/thick; the skeleton is for container-shape
confusion, so a field that declares an enum falls through to the hint that
names it. The --border-styles help now inlines both vocabularies (style is the
line type, weight the thickness and a string, not a pixel number), spells the
{all:{…}} shorthand, and states that no --border-all / --border-top /
--border-color exist. --word-wrap additionally accepts the Google Sheets
wrapStrategy words wrap and clip.

The sheet selector states that one of the pair is required rather than only
that they are mutually exclusive, in help across all shortcuts that take it,
and the rejection hints where the name comes from: a fresh workbook has one
sheet named Sheet1, any other needs a +workbook-info lookup. Eval traces
recover on the very next call, so the gap was which name to pass.

A --sheets payload written as a bare array now says the top level must be
{"sheets":[…]} instead of quoting Go's "cannot unmarshal array into Go value
of type struct { Sheets []sheets.tableSheetIn }", which names the internal
type rather than the fix. The skeleton hint is unchanged.
2026-07-28 20:47:48 +08:00
xiongyuanwen-byted
91743bba99 fix(sheets): make border vocabulary normalization reachable, prescribe flag and style-field fixes
Three fixes from the 07-28 root-cause analysis of failed agent traces, all
aimed at the first-try success rate rather than the recovery loop.

Border acceptance layer was unreachable on two of its three carrier paths.
expandBorderAllShorthand already moves a weight word out of the style slot
({"style":"thin"} -> style solid + weight thin), but on --border-styles and
typed --cells it ran after parseJSONFlag's schema check, so the enum error
fired first and the rewrite never happened. Move it ahead of validation via
the jsonFlagNormalizers seam; --styles already validated post-expansion and
is unchanged. An explicitly conflicting weight still takes the enum error.

Unknown-flag prescriptions for the names agents reach for most: +cells-set
--values, +dim-freeze --frozen-row-count and siblings, +cells-set-style
--font-bold / --bg-color / --wrap-strategy and the whole --border-* family
(no such flags; borders take one composite --border-styles). +sheet-rename
--new-name / --name alias to --title, matching +sheet-create.

Unsupported cell_styles field names now name the right field instead of the
nearest string: bold / font_bold -> font_weight, text_align ->
horizontal_alignment, a nested openpyxl-style font object -> the flat font_*
fields. Where no prescription applies, the edit-distance fallback is capped
at two edits, so a concept-swap neighbour (font_bold -> font_color, three
edits) stays silent rather than sending the retry the wrong way; a curated
prescription also drops the contradicting machine-readable suggestions.
2026-07-28 20:30:23 +08:00
xiongyuanwen-byted
ca7135f582 fix(sheets): correct +dim-insert --inherit-style side mapping, drop redundant none
The flag name was inverted relative to actual behavior, and `none` was redundant.

- Map --inherit-style onto the modify_sheet_structure backend so the name
  matches behavior (verified on a live sheet): `before` inherits the preceding
  row/column (side=after, position-1); `after` inherits the following
  (side=before). Insertion always lands before --position.
- Warn only when `before` is used at the first row/column (no preceding
  dimension to copy from); `after` has no such edge.
- Drop the `none` enum value: it was identical to omitting the flag and
  misleadingly implied "no inheritance" (the backend always copies a
  neighbour). Omitting the flag inherits the following row/column; `none` is
  now rejected by enum validation. Clear formats afterwards for a blank one.
- Regenerate flag-defs, update tests and the skill reference.
2026-07-24 19:14:18 +08:00
xiongyuanwen-byted
7b58ba1b1d feat(sheets): batch-update contract hardening and style-vocabulary acceptance (#2028)
* feat(sheets): harden +batch-update sub-op contract (P0-1/2/3/5)

Eval-driven fixes for the top +batch-update error clusters (137 errors
across 7 eval batches, attribution in the optimization plan doc):

- Reject unknown sub-op input keys with did-you-mean + full key contract
  instead of silently ignoring them (silent ignore surfaced as misleading
  'missing required flag' errors — the largest cluster, ~35 hits).
  Habitual spellings are rewritten in place: camelCase -> snake_case,
  commandFlagAliases (new: size -> width/height on the resize pair, the
  pre-July vocabulary and the --styles protocol spelling, 15+ hits),
  single-entry ranges unwraps onto range.
- Aggregate per-op validation errors into one pass (each op's first
  error) instead of fail-fast first-error-only; single-error batches
  keep the standalone-shaped error (contract tests unchanged).
- Precheck cells matrix vs range locally: empty cells (prescribes
  +cells-clear) and row/column count mismatches no longer reach the
  server mid-batch.
- Correct the atomicity story: execution is fail-fast without rollback
  (verified against live batches; docs promised a rollback that does
  not happen). Partial-failure errors now spell out that succeeded
  operations stay applied and prescribe resending only the failed
  tail, preventing double-apply on retry.

* feat(cli): accept @file payload reads from the system temp dir (P0-6)

Agents stage generated payloads (batch operations JSON, CSV) in /tmp as
a matter of course; the relative-to-cwd-only policy pushed every
--operations @/tmp/ops.json through an extra python/stdin round trip
(recurring friction cluster in eval traces).

Scope is deliberately narrow: a new SafeTempAbsInputPath accepts an
absolute READ path only when it resolves (symlinks included) under the
canonical os.TempDir(), and only the @file expansion in
cmdutil.ReadInputFile uses it. SafeInputPath stays strict — uploads,
drive sync and the CI quality gates treat 'absolute paths rejected' as
a load-bearing invariant. Absolute paths outside the temp dir now get
a prescriptive error naming all three options (relative path, temp-dir
path, stdin).

* feat(sheets): add +styles-put, +dim-delete --ranges, freeze in --styles

Batch-B of the +batch-update overhaul (attribution: ~73% of real batch
calls were pure formatting finishers hand-built as operations arrays).

- +styles-put: declarative visual spec for existing spreadsheets.
  Reuses the workbook-create/table-put --styles parser (identical
  vocabulary and aggregate-all-issues errors), expands client-side into
  ONE atomic batch_update per spec: cell_merges -> cell_styles ->
  row_sizes -> col_sizes -> freeze. Style stamps are safe to re-run.
  Verified live: 6/6 sub-ops applied, frozen rows / merges / row
  heights confirmed by read-back.
- freeze section added to the shared --styles pipeline ({rows, cols}),
  so +workbook-create and +table-put gain it too — closes the one gap
  that still forced a separate +dim-freeze call.
- +dim-delete --ranges: scattered row/column ranges in one atomic
  batch, ordered DESCENDING so earlier deletions never shift later
  indexes (the recurring failure of hand-built dim-delete batches);
  same-dimension and non-overlap enforced, nesting inside
  +batch-update rejected with a prescription.
- +cells-batch-set-style enters phase-1 deprecation: kept working,
  docs point at +styles-put, an in-band note steers new usage.

Skill docs regenerated from sheet-skill-spec (new
lark-sheets-styles-put reference, three-way dispatch in guideline 6,
fail-fast-no-rollback wording).

* feat(sheets): accept height/width as one-way aliases for size in --styles row/col_sizes

size stays the canonical dimension key: it keeps row_sizes and col_sizes
items shape-uniform (the array name already carries the dimension), it is
what shipped with workbook-create/table-put and what models demonstrably
converge on, and it matches the dimension-neutral precedent of comparable
APIs. The Excel-vocabulary words are accepted silently only where
unambiguous — height inside row_sizes, width inside col_sizes; the wrong
dimension's word gets a targeted error instead of a rewrite, and giving
both size and the alias is rejected. Shared parser, so +workbook-create /
+table-put / +styles-put all gain it.

* chore(sheets): sync skill docs — +cells-batch-set-style fully exits the skill surface

Regenerated from sheet-skill-spec: the deprecated command no longer
appears anywhere in SKILL.md or the references (multi-range styling
routes to +styles-put); its flag-defs entry is untouched, so the command
and --help keep working for compatibility callers, with the in-band
supersedence note steering them to +styles-put.

* fix(sheets): forgive habitual vocabulary on the --styles payload path

07-20 rerun attribution: the batch-update dispatch worked (calls 105->25,
errors 21->8; +styles-put adopted by 16/35 tasks) but +styles-put itself
hit a 56% stateful error rate — the redesign moved traffic from the flag
path onto the payload path, and the round-2 forgiveness layers (key
aliases, enum-value canonicalization) only existed on the flag path.
Both dominant clusters are fixed in the shared styles pipeline, so
+styles-put / +table-put / +workbook-create / typed --cells all gain it:

- border family folding (largest cluster, up to 88 issues in one retry):
  borders/border objects, border_top..right objects, border_style/color/
  weight scalars, and flattened border_<side>_<attr> keys all fold into
  the canonical nested border_styles; border_style:"thin" reads as a
  thin solid line (weight vocabulary in the style slot).
- enum VALUE canonicalization inside cell_styles (~10 server-side
  round trips: vertical_alignment "center" -> "middle"), sourced from
  the +cells-set-style flag enums; off-enum values now fail client-side
  with a did-you-mean instead of failing the whole batch server-side.
- wrap family: wrap_text/text_wrap -> word_wrap, boolean -> enum.
- bare-string cell_merges entries read as {range, merge_type:all}.
- fore_color gets a prescription (openpyxl fgColor is the FILL color;
  a silent pick could color the wrong thing).

Replayed the eval-failing payload shapes live: 2/2 applied.

* feat(sheets): resize type optional in --styles; acceptance-surface contract tests

- {range, size} in row/col_sizes now means a pixel resize (type stays
  for standard/auto) — the payload path matches the flag path, where
  --width never required --type.
- Two closure tests turn the --styles acceptance surface into a locked
  contract instead of open-ended patching:
  * vocabulary parity — every +cells-set-style flag (iterated from
    flag-defs) must be accepted verbatim by the payload path, so a
    future flag can never again ship without payload-path support;
  * prior corpus — every model spelling observed across the 07-08..07-20
    eval batches must either normalize to canonical or produce a
    targeted prescription; silent ignoring and bare rejection both fail
    the suite. New eval finding -> add a corpus row -> fix -> locked.

Skill docs regenerated: the border shorthand ({style,weight,color} on
all four sides) is now the teaching form, border_styles demoted to
per-side differences; resize examples drop the type ceremony.

* fix(sheets): close the 07-21 rerun residuals — full-form thin, range coalescing, typed-cells style key

Valid rerun (skill injection verified at ~31k chars/task): +styles-put
stateful error rate fell 56.5% -> 34.3% and batch-update stayed at its
post-dispatch low. Three residual clusters, all closed:

- weight vocabulary in the FULL nested form's style slot
  (border_styles.<side>.style:"thin" — 8 tasks, the dominant residual;
  the earlier rewrite only covered the shorthand scalar path). Now
  normalized in expandBorderAllShorthand, the single border touchpoint
  shared by the flag, typed-cells and styles-payload paths.
- per-row specs blowing the 100-op cap (184/203/861-op expansions):
  coalesceStyleStamps fuses identical-style entries into rectangles
  (vertical fixpoint merge on same column span, horizontal on same row
  span) before the cap — a declarative spec describes intent, execution
  shape is the CLI's to optimize. Cap message now also routes
  alternating-row banding to +cond-format-create.
- typed --cells habitual keys (recurring server-side 900015206 in both
  reruns): cells[][].style object rewrites to cell_styles;
  cells[][].type gets a prescription instead of a server round trip.
  The content-in-styles message is also neutral now (was
  workbook-create-specific).

Corpus + coalescing + typed-cells tests added; live replay: full-form
thin accepted and 3 same-style rows fused, all applied.

* refactor(sheets): give the style-vocabulary acceptance layer its own home

Pure mechanical move, zero behavior change (locked by the acceptance
contract tests). The acceptance layer had grown as an accretion across
helpers.go and lark_sheet_workbook.go — deliberate design (one canonical
form + wide acceptance, per the divergent-priors evidence), accidental
placement.

style_vocab.go now holds the whole subsystem — flag-path style builders,
key aliases, enum-value canonicalization, border folding/normalization,
typed-cells cell-object rewrites — under a header that states the design
contract: rewrites must be unambiguous, ambiguity prescribes, silence
and bare rejection are both bugs, closure is enforced by the parity +
prior-corpus tests. helpers.go shrinks back to generic plumbing
(818 -> 542 lines).

The other two acceptance surfaces keep their own homes: cobra flag
ergonomics in flag_ergonomics.go, batch sub-op key vocabulary in
batch_op_dispatch.go.

* fix(sheets): enforce the cells-vs-range match on single-cell ranges too

The precheck deliberately skipped bare single-cell ranges when server
behavior was unverified; the 07-21 rerun supplied the evidence (12 rows
against range "A1" failing server-side with row count 1) — +cells-set
has no anchor semantics, the strict match applies everywhere. Corpus
updated accordingly.

* feat(sheets): make +csv-get --range optional — omitted reads the whole sheet

The tool requires a range but clips past-grid references and reports the
clip in actual_range, so the CLI sends an over-wide whole-columns range
(A:ZZZ) when --range is omitted: one call reads the entire sheet, no
workbook-info pre-flight to size it first. Eval evidence: 'required
flag(s) range not set' was the most-missed required flag on +csv-get
(4 tasks in the 07-21 batch) — the models' intent was always 'read it
all'. Blank --range now means the same as omitting it.

Skill docs updated (quick-reference row, read-data full-read example,
flag desc); round-3 backlog item A5.

* feat(sheets): add editing rule #10 — never fabricate missing values

补齐 / 扩展 / 按原表格式续填时,查不到或无法确定的值一律留空 +
备注注明,禁止用推算 / 估算 / 凭空数据充数;原表已示范缺失值写法时照抄。

Synced from sheet-skill-spec (SoT).

* fix(sheets): accept side-first border word order and wrap_strategy

07-21 evening batch (first run carrying the previous fixes — thin
full-form / style/type keys / csv-get range all at zero): the corpus
loop caught the next spelling permutations. bottom_border /
bottom_border_style (side-first word order, alongside the border_bottom
family already folded) and wrap_strategy (the Google Sheets API word)
now normalize; three corpus rows lock them.

* feat(sheets): universal did-you-mean on unknown style fields; formalize the silent-alias admission bar

The alias table was drifting toward per-permutation entries with fast-
falling marginal value (first aliases covered 15+ errors each, the last
ones 1-2). The asymmetry that matters: rejection is universal, aliases
are per-word. So:

- unknown style fields now reject with did-you-mean + the full canonical
  field list (this error had neither — the actual reason word-order
  permutations turned into multi-issue retry loops). Any future
  permutation costs one self-healing retry, zero new code, and corrects
  the whole session (silent aliases never correct the model in-session).
- the acceptance-layer contract now states the admission bar for silent
  aliases: real external vocabularies only (Excel/openpyxl, CSS, Google
  Sheets API — a finite set), recurring across batches or ≥3 tasks,
  zero ambiguity. Permutations go to the universal rejection. Existing
  permutation aliases are grandfathered.

* feat(sheets): +cells-set --writes — scattered multi-region writes in one atomic call

The last compressible batch-update scenario: eval traces show 'fix all
broken formulas across ranges/sheets' (~6 calls / 3 tasks per batch)
still hand-assembled as +batch-update operations arrays. --writes takes
[{sheet_name|sheet_id, range, cells}, ...] (up to 100 items, cross-sheet)
and fans it into ONE atomic batch_update of set_cell_range ops.

Design decisions:
- the sheet selector LIVES IN EACH ITEM — no top-level fallback, no
  precedence table; same convention models already learned from
  +batch-update sub-ops and +styles-put items. A top-level
  --sheet-name/--sheet-id with --writes is rejected with the fix.
- every item runs the exact standalone pipeline via a per-item flag
  view: key vocabulary (camelCase, aliases, did-you-mean), the style
  acceptance layer for inline cell_styles, matrix precheck, schema
  validation; item errors aggregate so one retry fixes all.
- XOR with --range/--cells/--copy-to-range; top-level --allow-overwrite
  propagates to items that don't override it.
- nesting inside +batch-update rejected (expands into its own batch).
- predictable prior handled: --styles on +cells-set now hints the
  layering (range-level styling -> +styles-put; per-cell styles ride in
  the cells objects) instead of a bare unknown-flag error.

Live smoke: value + formula regions in one call, 2/2 applied.
Expected effect: batch-update calls drop another ~30% to its
heterogeneous-atomic-chain steady state.

* chore(sheets): bump lark-sheets skill version to 3.1.0

Version roll-up for the batch-update optimization series: cells-set
--writes, universal did-you-mean on style fields, border word-order and
wrap_strategy acceptance, editing rule #10, and optional +csv-get
--range.
2026-07-24 13:18:03 +08:00
anunwu-byted
765b097d44 Merge pull request #2027 from larksuite/feat/error-schema-hints
Feat/error schema hints
2026-07-23 16:40:10 +08:00
wuyanchun.anunwu
4a5e2c519a feat(sheets): 校验失败全量收集报错(一次报出所有错误)
为什么:fail-fast 单错报错导致「挤牙膏」修复回路——修一处、重试、
撞下一处。评测实测(turbo 三批 247 次失败)约 32% 的失败轮次是
挤牙膏,其中 local→local 类(39 次)本可一次报出。

怎么改:
- validateAgainstSchema 重构为 collectSchemaErrors 收集器版:命中
  错误后继续遍历,全部问题一次报出(每条带各自的教学信息)
- translateBatchOperations 同步做 op 级聚合:多个坏 op 一条报错
  编号列出,不再 fail-fast 在第一个
- 三条护栏:单错误输出逐字不变(向后兼容);显示 cap 5 条 + 收集
  到 6 即全树短路(病态大数组不爆炸);类型错节点不下钻、oneOf
  用一次性探测器(防级联噪音/误报泄漏)

验证:gofmt/vet 干净,go test -count=1 全过(既有测试零改动,
新增 5 个聚合测试:多错编号、cap 截断、oneOf 不泄漏、batch 双 op
聚合、单 op 保持原文)。
2026-07-20 21:11:00 +08:00
xiongyuanwen-byted
67fc870582 feat(sheets): add --output-path for full-read file offload on cells/csv/table-get
Reads are capped by max_chars (default 500000; the backend tool also truncates
at ~50000 when unset). Add --output-path to +cells-get / +csv-get / +table-get:
when set, the result is written to a cwd-relative path as JSON and the char cap
is lifted to unbounded, so a large sheet lands on disk in full instead of being
clipped for stdout.

+table-get previously never sent max_chars, so it silently dropped rows past the
backend ~50000 default with no signal. It now takes --max-chars (default 500000,
sent explicitly) and surfaces truncated / truncation_warning when the read is
clipped, steering callers to --output-path for a lossless full read.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
af8e027269 feat(sheets): support --include truncation on +cells-get
Map the new `truncation` value in --include to include_truncation_info on
the get_cell_ranges tool input, so +cells-get can return per-cell
isRowTruncated / isColTruncated. Flag metadata and reference synced from
sheet-skill-spec; flag_defs_gen.go regenerated.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
2efadec335 feat(sheets): cut agent error rate and --help lookups (#1911)
## Background

Round 2 of eval-driven sheets optimization, rebased onto the latest `feat/lark-sheets-develop` (`8897196d`).

## Changes

- **feat(sheets): cut agent error rate and --help lookups (eval round 2)** — targets the top failure modes from round 2 evals, reducing agent error rate and the number of `--help` lookups.
- **chore(sheets): sync skill docs and flag data from sheet-skill-spec** — syncs skill docs and flag data from sheet-skill-spec.

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
wuyanchun.anunwu
5fb70d326a feat(sheets): 校验失败报错内联 schema 提示(报错即教学)
为什么:豆包 Excel Agent 案例中模型 7 次参数错误,报错只说"错了"不说
"怎么改对",模型反复试错并静默降级交付(5 张饼图丢数据标签)。

怎么改:
- strict unexpected-property 报错附该节点合法 key 列表(cap 15 截断)
  + did-you-mean(复用 internal/suggest)
- required-missing 报错附缺失字段的 type/description/enum 一行提示
- 深层 type mismatch 报错附该字段 enum/description 后缀
- +batch-update 顶层 --sheet-id/--sheet-name(含下划线拼写)特判,
  直接指明 per-op locator 契约,不给误导性 fuzzy 建议
- batch sub-op input 拒收 cell_styles/styles/cell_merges 包裹结构,
  报错教学扁平 flags 写法
- 守护测试锁定 wrappedSubOpInputKeys 与 batchOpDispatch 的互斥假设

约束:不改 legacy 报错措辞前缀、保留 --print-schema 指针、不动宽松
AdditionalProperties 设计(内嵌 schema 当前无 strict 节点,该路径为预置)。

验证:gofmt -l 无输出、go vet 干净、go test -count=1 ./shortcuts/sheets/
./internal/suggest/ 全部通过。
2026-07-15 18:13:22 +08:00
202 changed files with 26990 additions and 1772 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

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

View File

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

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

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

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

View File

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

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

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

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

View File

@@ -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
View File

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

View File

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

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

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

View 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时不带 valuettl_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_bytesCLI 算),未命中时 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"))
}
}

View 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_HitPrettypretty 把 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无 valuettl_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_PrettyNonJSONFallbackpretty 下 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 numbercacheInt不得随 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 numbercacheInt
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=1pretty 打 "✓ 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=0pretty 措辞区分。
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_DryRunDELETE 方法、/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=128pretty 打 "✓ 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_RequiresConfirmationhigh-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_DryRunBodyWithEnvdry-run POST /cache/clearbody 带 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
// 复用本包规范的 dryRunAPIEnvelopeapi 现嵌在 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]
}

View 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 家族的旧名 --envenv 值经 dbEnv 读、
// 经 dbEnvParams 注入——get/delete 放 queryclear 放 body省略即服务端自动选分支
// appCachePath 返回缓存单 key 读/删 URLcacheGET 读、DELETE 删,靠方法区分)。
func appCachePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
}
// appCacheClearPath 返回清空指定环境缓存 URLcache/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)
}

View File

@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
AppsFileUpload,
AppsFileDelete,
AppsFileQuotaGet,
AppsCacheGet,
AppsCacheDelete,
AppsCacheClear,
AppsGitCredentialInit,
AppsGitCredentialList,
AppsGitCredentialRemove,

View File

@@ -20,13 +20,14 @@ import (
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 cacheget/delete/clear
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79
func TestAppsShortcuts_Returns79(t *testing.T) {
// - 9 rolerole 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))
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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&lt;b" {
t.Fatalf("elements[0].text_run.text = %#v, want escaped a&lt;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)
}
}

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

@@ -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: ..."

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

View File

@@ -16,6 +16,14 @@ func Shortcuts() []common.Shortcut {
DriveCover,
DriveAddComment,
DriveListComments,
DriveBatchQueryComments,
DriveResolveComment,
DriveRestoreComment,
DriveAddReply,
DriveListReplies,
DriveUpdateReply,
DriveDeleteReply,
DriveReactReply,
DriveExport,
DriveExportDownload,
DriveImport,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

View File

@@ -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": "二维单元格数组,结构同 --cellsvalue / 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 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 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:Ctype 为 pixel/standardpixel 需要 size。",
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽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:3type 为 pixel/standard/autopixel 需要 size。",
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高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 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 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给 sizepx即像素列宽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给 sizepx即像素行高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:Ctype 为 pixel/standardpixel 需要 size。",
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽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:3type 为 pixel/standard/autopixel 需要 size。",
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高type 可省略);type 为 standard/auto 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8476,8 +8846,7 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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