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
392 changed files with 27402 additions and 15983 deletions

View File

@@ -82,56 +82,6 @@ jobs:
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
extended-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build both editions and verify identity
run: |
set -euo pipefail
go build -o /tmp/lark-cli-standard .
go build -tags extended -o /tmp/lark-cli-extended .
test "$(/tmp/lark-cli-standard version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-extended version --json | jq -r '.capabilities[]')" = "external-credential-platform"
- name: Cross-compile Extended platform-specific security code
run: |
set -euo pipefail
GOOS=darwin GOARCH=arm64 go build -tags extended -o /tmp/lark-cli-extended-darwin .
GOOS=windows GOARCH=amd64 go build -tags extended -o /tmp/lark-cli-extended-windows.exe .
- name: Verify edition source isolation
run: go test -count=1 ./internal/externalcredential -run '^TestEditionSourceIsolation$'
- name: Run Extended tests
run: make extended-test
extended-platform-security:
needs: fast-gate
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run native helper isolation and path trust tests
run: go test -tags extended -count=1 ./internal/externalcredential -run '^(TestNativeAdminControlledPath|TestCredentialProcessEnvironmentUsesExplicitAllowlist|TestCredentialProcessCommandRunsWithIsolatedEnvironment)$'
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -192,28 +142,6 @@ jobs:
node-version: '22'
- name: Run script tests
run: make script-test
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
install-only: true
- name: Validate GoReleaser configuration
run: goreleaser check
- name: Check Extended installer syntax
shell: pwsh
run: |
sh -n scripts/install-extended.sh
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path scripts/install-extended.ps1),
[ref]$tokens,
[ref]$errors
) | Out-Null
if ($errors.Count -ne 0) {
$errors | ForEach-Object { Write-Error $_ }
exit 1
}
deterministic-gate:
needs: fast-gate
@@ -288,20 +216,16 @@ jobs:
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
go test -race -coverprofile=coverage-standard.txt -covermode=atomic $packages
# Extended implementation files are selected by build tags and would
# otherwise be absent from the uploaded report. Their race-enabled
# suite runs in extended-integration; this pass contributes coverage.
go test -tags extended -coverprofile=coverage-extended.txt -covermode=atomic $packages
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage-standard.txt,coverage-extended.txt
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
@@ -311,31 +235,21 @@ jobs:
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
report_coverage() {
profile="$1"
label="$2"
echo "### ${label} edition" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ ! -f "$profile" ]; then
echo "No ${label} coverage data available." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
return
fi
total=$(go tool cover -func="$profile" | grep total | awk '{print $3}')
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func="$profile" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
}
report_coverage coverage-standard.txt Standard
report_coverage coverage-extended.txt Extended
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
deadcode:
needs: fast-gate
@@ -606,7 +520,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration, extended-integration, extended-platform-security]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -628,8 +542,6 @@ jobs:
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-integration | ${{ needs.extended-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-platform-security | ${{ needs.extended-platform-security.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
@@ -653,9 +565,7 @@ jobs:
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}" \
"${{ needs.extended-integration.result }}" \
"${{ needs.extended-platform-security.result }}"; do
"${{ needs.license-header.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi

View File

@@ -46,8 +46,6 @@ jobs:
runs-on: ubuntu-22.04
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -70,7 +68,7 @@ jobs:
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Build and upload draft release with GoReleaser
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
@@ -82,41 +80,14 @@ jobs:
run: |
set -euo pipefail
test -s dist/checksums.txt
cp scripts/install-extended.sh scripts/install-extended.ps1 dist/
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Verify release edition identities
run: |
set -euo pipefail
mkdir -p /tmp/lark-cli-standard /tmp/lark-cli-extended
tar -xzf "dist/lark-cli-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-standard lark-cli
tar -xzf "dist/lark-cli-extended-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-extended lark-cli
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
- name: Verify release platform asset matrix
run: bash scripts/verify-release-assets.sh dist "${GITHUB_REF_NAME#v}"
- name: Attest release archives
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/install-extended.sh
dist/install-extended.ps1
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt \
dist/install-extended.sh dist/install-extended.ps1 \
npm-publish-asset/
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -126,76 +97,6 @@ jobs:
if-no-files-found: error
overwrite: true
- name: Publish verified GitHub release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
RELEASE_TAG: ${{ github.ref_name }}
with:
github-token: ${{ github.token }}
script: |
const crypto = require("node:crypto");
const fs = require("node:fs");
const tag = process.env.RELEASE_TAG;
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const matches = releases.filter((release) => release.tag_name === tag);
if (matches.length !== 1) {
throw new Error(`expected exactly one draft release for ${tag}, found ${matches.length}`);
}
const release = matches[0];
if (!release.draft) {
throw new Error(`release ${tag} became public before verification completed`);
}
const checksumPath = "dist/checksums.txt";
const checksumBody = fs.readFileSync(checksumPath, "utf8");
const expectedDigests = new Map();
for (const line of checksumBody.split(/\r?\n/)) {
if (!line.trim()) continue;
const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (!match) throw new Error(`invalid checksums.txt line: ${line}`);
const name = match[2];
if (expectedDigests.has(name)) {
throw new Error(`duplicate checksums.txt entry: ${name}`);
}
expectedDigests.set(name, `sha256:${match[1].toLowerCase()}`);
}
expectedDigests.set(
"checksums.txt",
`sha256:${crypto.createHash("sha256").update(checksumBody).digest("hex")}`,
);
const actualNames = release.assets.map((asset) => asset.name).sort();
const expectedNames = [...expectedDigests.keys()].sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
throw new Error(
`draft release asset set mismatch: expected ${expectedNames.join(", ")}, got ${actualNames.join(", ")}`,
);
}
for (const asset of release.assets) {
const expected = expectedDigests.get(asset.name);
if (!asset.digest) {
throw new Error(`GitHub did not report a digest for draft asset ${asset.name}`);
}
if (asset.digest.toLowerCase() !== expected) {
throw new Error(
`draft asset digest mismatch for ${asset.name}: expected ${expected}, got ${asset.digest}`,
);
}
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: false,
make_latest: "true",
});
publish-npm:
needs: build-release
runs-on: ubuntu-22.04

View File

@@ -5,8 +5,7 @@ before:
- python3 scripts/fetch_meta.py
builds:
- id: standard
binary: lark-cli
- binary: lark-cli
env:
- CGO_ENABLED=0
ldflags:
@@ -19,54 +18,12 @@ builds:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
- id: extended
binary: lark-cli
tags:
- extended
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
archives:
- id: standard
ids:
- standard
name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats:
- zip
files:
- README.md
- LICENSE
- CHANGELOG.md
- id: extended
ids:
- extended
name_template: "lark-cli-extended-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats:
- zip
format: zip
files:
- README.md
- LICENSE
@@ -74,18 +31,6 @@ archives:
checksum:
name_template: checksums.txt
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
release:
# Keep assets undiscoverable by releases/latest until the workflow has
# independently verified checksums, edition identity, and platform coverage.
draft: true
replace_existing_draft: true
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
changelog:
sort: asc

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

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test extended-test
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
all: test
@@ -50,7 +50,6 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/release-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
@@ -122,12 +121,6 @@ sidecar-test:
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# extended-test compiles and exercises the separately distributed Extended
# edition. The default build remains the Standard npm/npx binary.
extended-test:
go build -tags extended -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags extended ./cmd/... ./internal/... ./shortcuts/... ./extension/... ./tests/externalcredential_e2e
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -18,7 +18,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -31,23 +30,21 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
login := NewCmdAuthLogin(f, nil)
logout := NewCmdAuthLogout(f, nil)
status := NewCmdAuthStatus(f, nil)
scopes := NewCmdAuthScopes(f, nil)
list := NewCmdAuthList(f, nil)
check := NewCmdAuthCheck(f, nil)
qrcode := NewCmdAuthQRCode(f, nil)
for _, diagnostic := range []*cobra.Command{status, scopes, check, qrcode} {
cmdutil.SetRuntimeCapabilities(diagnostic)
}
cmd.AddCommand(login, logout, status, scopes, list, check, qrcode)
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -530,7 +530,10 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
}{
{"login", []string{"login"}},
{"logout", []string{"logout"}},
{"status", []string{"status"}},
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
{"list", []string{"list"}},
{"scopes", []string{"scopes"}},
}
for _, tt := range tests {
@@ -555,19 +558,3 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
})
}
}
func TestAuthReadOnlyCommandsAllowedByExternalProvider(t *testing.T) {
f := newFactoryWithExternalProvider(t)
for _, name := range []string{"status", "check", "scopes", "qrcode"} {
t.Run(name, func(t *testing.T) {
cmd := NewCmdAuth(f)
matched, _, err := cmd.Find([]string{name})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("read-only command blocked: %v", err)
}
})
}
}

View File

@@ -4,7 +4,6 @@
package auth
import (
"context"
"fmt"
"strings"
@@ -13,7 +12,6 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
)
@@ -35,7 +33,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
if runF != nil {
return runF(opts)
}
return authCheckRunContext(cmd.Context(), opts)
return authCheckRun(opts)
},
}
@@ -48,10 +46,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
func authCheckRun(opts *CheckOptions) error {
return authCheckRunContext(context.Background(), opts)
}
func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
f := opts.Factory
required := strings.Fields(opts.Scope)
@@ -63,74 +57,18 @@ func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
if err != nil {
return err
}
if f.Credential == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "credential inspection is unavailable")
}
inspection, err := f.Credential.InspectToken(ctx, credential.TokenInspectionRequest{
TokenSpec: credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: config.AppID,
},
IncludeScopes: true,
})
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"failed to inspect user authorization: %v", err).
WithCause(err)
}
if inspection == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source returned no authorization inspection")
}
if inspection.Status == credential.TokenInspectionNotLoggedIn && !inspection.Source.Managed {
if config.UserOpenId == "" {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
return output.ErrBare(1)
}
if !inspection.Present {
if inspection.Source.Managed {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"credential source %q did not provide a user access token", inspection.Source.Name).
WithHint("authorize the user through the selected credential source")
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
return output.ErrBare(1)
}
switch inspection.ScopeState {
case credential.ScopeUnsupported:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check is unsupported by credential source %q because granted scopes are unavailable", inspection.Source.Name).
WithHint("the credential source must expose trusted scope metadata before `auth check` can evaluate --scope")
case credential.ScopeUnknown:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check result is unknown because credential source %q returned no scope metadata", inspection.Source.Name).
WithHint("configure the credential source to return trusted scopes for user access tokens")
case credential.ScopeKnown:
// Continue below.
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source %q returned invalid scope inspection state %q", inspection.Source.Name, inspection.ScopeState)
}
suggestion := ""
missing := larkauth.MissingScopes(inspection.Scopes, required)
if inspection.Source.Managed {
if len(missing) > 0 {
suggestion = fmt.Sprintf("grant these scopes through credential source %s: %s", inspection.Source.Name, strings.Join(missing, " "))
}
} else {
suggestion = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
return writeAuthCheckResult(f, required, inspection.Scopes, suggestion)
}
func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes, suggestion string) error {
missing := larkauth.MissingScopes(availableScopes, required)
missing := larkauth.MissingScopes(stored.Scope, required)
missingSet := make(map[string]bool, len(missing))
for _, s := range missing {
missingSet[s] = true
@@ -144,8 +82,8 @@ func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes
ok := len(missing) == 0
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
if len(missing) > 0 && suggestion != "" {
result["suggestion"] = suggestion
if len(missing) > 0 {
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
output.PrintJson(f.IOStreams.Out, result)
if !ok {

View File

@@ -4,20 +4,14 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -152,128 +146,6 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
}
}
type authCheckExternalProvider struct {
token *extcred.Token
capabilities credential.ProviderCapabilities
resolveCalls int
}
func (p *authCheckExternalProvider) Name() string { return "external-check-test" }
func (p *authCheckExternalProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app", Brand: extcred.BrandFeishu}, nil
}
func (p *authCheckExternalProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
p.resolveCalls++
return p.token, nil
}
func (p *authCheckExternalProvider) CredentialCapabilities() credential.ProviderCapabilities {
return p.capabilities
}
func externalAuthCheckFactory(t *testing.T, canInspectScopes bool, token *extcred.Token) (*cmdutil.Factory, *authCheckExternalProvider) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app",
Brand: core.BrandFeishu,
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
provider := &authCheckExternalProvider{
token: token,
capabilities: credential.ProviderCapabilities{
ProvidesOnDemandAuth: true,
CanInspectScopes: canInspectScopes,
},
}
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{provider},
nil,
nil,
nil,
)
return f, provider
}
func TestAuthCheckRun_ExternalDirectUsesProviderScopes(t *testing.T) {
f, provider := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
Scopes: "im:message docx:document",
})
stdout := f.IOStreams.Out.(*bytes.Buffer)
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
if err != nil {
t.Fatalf("authCheckRun() error = %v", err)
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveToken calls = %d, want 1", provider.resolveCalls)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Fatalf("stdout.ok = %v, want true; payload=%v", payload["ok"], payload)
}
granted, ok := payload["granted"].([]any)
if !ok || len(granted) != 1 || granted[0] != "im:message" {
t.Fatalf("stdout.granted = %v, want [im:message]", payload["granted"])
}
}
func TestAuthCheckRun_ExternalProxyReturnsTypedUnknown(t *testing.T) {
f, provider := externalAuthCheckFactory(t, false, &extcred.Token{
Value: "proxy-placeholder",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unsupported") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unsupported result with actionable hint and no param", problem)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveToken calls = %d, want 0 for proxy scope check", provider.resolveCalls)
}
}
func TestAuthCheckRun_ExternalDirectWithoutScopeMetadataReturnsTypedUnknown(t *testing.T) {
f, _ := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unknown") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unknown result with actionable hint and no param", problem)
}
}
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
// Scope validation is a real input error, not a predicate negative
// answer — it must surface as a typed ValidationError with the normal

View File

@@ -56,7 +56,6 @@ For ASCII output, the result is printed to stdout with fixed size.`,
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -44,10 +44,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
func authStatusRun(opts *StatusOptions) error {
f := opts.Factory
editionStatus, err := inspectEditionStatus(f)
if err != nil {
return err
}
config, err := f.Config()
if err != nil {
@@ -68,9 +64,7 @@ func authStatusRun(opts *StatusOptions) error {
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addEffectiveVerification(result, diagnostics)
if !applyEditionStatus(result, diagnostics, editionStatus) {
addStatusNote(result, diagnostics)
}
addStatusNote(result, diagnostics)
output.PrintJson(f.IOStreams.Out, result)
return nil

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"context"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct {
provider string
variant string
}
func inspectEditionStatus(f *cmdutil.Factory) (editionStatusState, error) {
if f == nil || f.Credential == nil {
return editionStatusState{}, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return editionStatusState{}, err
}
if source == nil || !source.Managed {
return editionStatusState{}, nil
}
state := editionStatusState{provider: source.Name}
description := f.RuntimeDescription()
if description.Managed {
state.variant = description.Variant
}
return state, nil
}
func applyEditionStatus(result map[string]interface{}, diagnostics identitydiag.Result, state editionStatusState) bool {
if state.provider == "" {
return false
}
result["source"] = "external"
result["credentialProvider"] = state.provider
if state.variant != "" {
result["externalCredentialMode"] = state.variant
}
switch {
case !diagnostics.User.Available && diagnostics.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(diagnostics.User.Status) +
"; bot identity is ready. Update authorization through external credential provider " + state.provider + "."
case diagnostics.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh. Check external credential provider " + state.provider + "."
case !diagnostics.User.Available && !diagnostics.Bot.Available:
result["note"] = "No usable identity is available. Check external credential provider " + state.provider + "."
}
return true
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedAuthStatusReportsManagedSource(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
},
}))
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["source"] != "external" ||
got["credentialProvider"] != "env" ||
got["externalCredentialMode"] != "managed-test" ||
got["identity"] != "bot" {
t.Fatalf("output = %#v", got)
}
if note, _ := got["note"].(string); strings.Contains(note, "auth login") ||
!strings.Contains(note, "external credential provider env") {
t.Fatalf("note = %q", note)
}
}

View File

@@ -1,21 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct{}
func inspectEditionStatus(*cmdutil.Factory) (editionStatusState, error) {
return editionStatusState{}, nil
}
func applyEditionStatus(map[string]interface{}, identitydiag.Result, editionStatusState) bool {
return false
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardAuthStatusPreservesExistingProjection(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := got[field]; exists {
t.Fatalf("Standard auth status contains edition field %q: %s", field, stdout.String())
}
}
var note string
if err := json.Unmarshal(got["note"], &note); err != nil {
t.Fatal(err)
}
if !strings.Contains(note, "lark-cli auth login") {
t.Fatalf("Standard note = %q, want established login guidance", note)
}
}

View File

@@ -35,15 +35,6 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
t.Fatalf("json.Unmarshal(raw) error = %v", err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := raw[field]; exists {
t.Fatalf("local auth status unexpectedly contains edition field %q: %s", field, stdout.String())
}
}
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {

View File

@@ -29,7 +29,6 @@ import (
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -46,7 +45,6 @@ type buildConfig struct {
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
runtime *runtimebootstrap.Result
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -60,14 +58,6 @@ func WithStartupBrand(brand core.LarkBrand) BuildOption {
}
}
// withRuntimeBootstrap shares one invocation snapshot across registry,
// credentials, transports, and command capabilities.
func withRuntimeBootstrap(runtime *runtimebootstrap.Result) BuildOption {
return func(c *buildConfig) {
c.runtime = runtime
}
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
@@ -153,9 +143,9 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
return rootCmd
}
// buildInternal assembles the command tree from one immutable startup
// configuration snapshot. Profile selection happens before any registry
// network decision and the same result is passed to the Factory.
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
@@ -178,29 +168,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
startup := cfg.runtime
if startup == nil {
startup = runtimebootstrap.Resolve(inv.Profile)
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
// Runtime policy can close direct metadata egress before any command is
// registered, without exposing a concrete credential mode here.
registryBrand := cfg.startupBrand
if registryBrand == "" {
registryBrand = resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
}
if !startup.Plan.AllowsRemoteMetadata() {
if registryBrand == "" {
registryBrand = core.BrandFeishu
}
registry.InitEmbeddedWithBrand(registryBrand)
} else if registryBrand != "" {
registry.InitWithBrand(registryBrand)
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
}
f := cmdutil.NewDefaultWithRuntimePlan(cfg.streams, inv, startup.ProfileConfig, startup.Plan)
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
@@ -246,7 +220,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
registerEditionCommands(rootCmd, f)
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
if !cfg.skipService {

View File

@@ -1,176 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
func TestStartupProfileSnapshotUsesDetectedWorkspace(t *testing.T) {
previousWorkspace := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
tests := []struct {
name string
workspace core.Workspace
signalName string
signalValue string
expectedAppID string
expectedBrand core.LarkBrand
}{
{
name: "local",
workspace: core.WorkspaceLocal,
expectedAppID: "cli_local",
expectedBrand: core.BrandFeishu,
},
{
name: "openclaw",
workspace: core.WorkspaceOpenClaw,
signalName: "OPENCLAW_CLI",
signalValue: "1",
expectedAppID: "cli_openclaw",
expectedBrand: core.BrandLark,
},
{
name: "hermes",
workspace: core.WorkspaceHermes,
signalName: "HERMES_HOME",
signalValue: "/managed/hermes",
expectedAppID: "cli_hermes",
expectedBrand: core.BrandLark,
},
{
name: "lark_channel",
workspace: core.WorkspaceLarkChannel,
signalName: "LARK_CHANNEL",
signalValue: "1",
expectedAppID: "cli_lark_channel",
expectedBrand: core.BrandLark,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configRoot := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configRoot)
t.Setenv(envvars.CliExternalCredentialConfig,
filepath.Join(configRoot, "missing-external-credential.json"))
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
if tt.signalName != "" {
t.Setenv(tt.signalName, tt.signalValue)
}
writeWorkspaceProfile(t, core.WorkspaceLocal, "local", "cli_local", core.BrandFeishu)
if !tt.workspace.IsLocal() {
writeWorkspaceProfile(t, tt.workspace, tt.name, tt.expectedAppID, tt.expectedBrand)
}
// Execute resolves the registry brand before entering
// buildInternal. Pin that ordering independently.
core.SetCurrentWorkspace(core.WorkspaceLocal)
if got := selectInvocationWorkspace(); got != tt.workspace {
t.Fatalf("selected workspace = %q, want %q", got, tt.workspace)
}
if got := ResolveStartupBrand(""); got != tt.expectedBrand {
t.Fatalf("startup brand = %q, want %q", got, tt.expectedBrand)
}
// Build/buildInternal is also a public construction path. Reset the
// process state to local so the test proves it establishes the
// workspace before SelectProfile captures the immutable snapshot.
core.SetCurrentWorkspace(core.WorkspaceLocal)
factory, _, _ := buildInternal(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), io.Discard, io.Discard),
WithoutPlugins(),
WithoutServiceCommands(),
)
if got := core.CurrentWorkspace(); got != tt.workspace {
t.Fatalf("workspace after build = %q, want %q", got, tt.workspace)
}
config, err := factory.Config()
if err != nil {
t.Fatalf("Factory.Config() error = %v", err)
}
if config.AppID != tt.expectedAppID || config.Brand != tt.expectedBrand {
t.Fatalf("resolved config = app %q (%s), want app %q (%s)",
config.AppID, config.Brand, tt.expectedAppID, tt.expectedBrand)
}
})
}
}
func clearWorkspaceSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
"OPENCLAW_CLI",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_SERVICE_MARKER",
"OPENCLAW_SERVICE_VERSION",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_SHELL",
"HERMES_HOME",
"HERMES_QUIET",
"HERMES_EXEC_ASK",
"HERMES_GATEWAY_TOKEN",
"HERMES_SESSION_KEY",
"LARK_CHANNEL",
} {
t.Setenv(name, "")
}
}
func clearCredentialSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
envvars.CliAppID,
envvars.CliAppSecret,
envvars.CliBrand,
envvars.CliUserAccessToken,
envvars.CliTenantAccessToken,
envvars.CliDefaultAs,
envvars.CliStrictMode,
} {
t.Setenv(name, "")
}
}
func writeWorkspaceProfile(
t *testing.T,
workspace core.Workspace,
name string,
appID string,
brand core.LarkBrand,
) {
t.Helper()
previous := core.CurrentWorkspace()
core.SetCurrentWorkspace(workspace)
defer core.SetCurrentWorkspace(previous)
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: name,
Apps: []core.AppConfig{{
Name: name,
AppId: appID,
AppSecret: core.PlainSecret("test-secret-" + name),
Brand: brand,
Users: []core.AppUser{},
}},
}); err != nil {
t.Fatalf("save %s workspace profile: %v", workspace.Display(), err)
}
}

View File

@@ -6,7 +6,6 @@ package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/spf13/cobra"
)
@@ -20,38 +19,22 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
initCmd := NewCmdConfigInit(f, nil)
bind := NewCmdConfigBind(f, nil)
remove := NewCmdConfigRemove(f, nil)
show := NewCmdConfigShow(f, nil)
defaultAs := NewCmdConfigDefaultAs(f)
strictMode := NewCmdConfigStrictMode(f)
riskControl := NewCmdConfigRiskControl(f)
policy := NewCmdConfigPolicy(f)
plugins := NewCmdConfigPlugins(f)
keychainDowngrade := NewCmdConfigKeychainDowngrade(f)
// Identity preferences live in the Profile, but external providers have
// historically treated these config commands as credential management.
// Check Profile ownership first so a managed runtime gives the actionable
// deployment-managed Profile error, then retain the credential capability
// so Standard external-provider behavior stays unchanged.
for _, identitySetting := range []*cobra.Command{defaultAs, strictMode} {
cmdutil.SetRuntimeCapabilities(
identitySetting,
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
)
}
for _, sourceNeutral := range []*cobra.Command{show, riskControl, policy, plugins} {
cmdutil.SetRuntimeCapabilities(sourceNeutral)
}
cmd.AddCommand(initCmd, bind, remove, show, defaultAs, strictMode, riskControl, policy, plugins, keychainDowngrade)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
return cmd
}

View File

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimeplan"
)
type noopConfigKeychain struct{}
@@ -453,16 +452,10 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
}
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
type stubConfigExtProvider struct {
name string
err error
}
type stubConfigExtProvider struct{ name string }
func (s *stubConfigExtProvider) Name() string { return s.name }
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
if s.err != nil {
return nil, s.err
}
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
@@ -488,6 +481,7 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
@@ -515,63 +509,6 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}
}
func TestConfigIdentityCommandsCheckProfileOwnershipBeforeCredentialOwnership(t *testing.T) {
profileDenied := errors.New("Profile identity settings are deployment-managed")
credentialChecks := 0
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
switch capability {
case runtimeplan.CapabilityLocalProfileMutation:
return profileDenied
case runtimeplan.CapabilityLocalCredentialManagement:
credentialChecks++
}
return nil
},
})
for _, args := range [][]string{
{"default-as", "bot"},
{"strict-mode", "bot"},
} {
t.Run(args[0], func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdConfig(f)
cmd.SetArgs(args)
err := cmd.Execute()
if !errors.Is(err, profileDenied) {
t.Fatalf("Execute(%v) error = %v, want Profile ownership denial", args, err)
}
})
}
if credentialChecks != 0 {
t.Fatalf("credential capability checked %d times after Profile denial, want 0", credentialChecks)
}
}
func TestConfigIdentityCommandsRetainCredentialOwnershipCapability(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := NewCmdConfig(f)
for _, name := range []string{"default-as", "strict-mode"} {
t.Run(name, func(t *testing.T) {
leaf, _, err := root.Find([]string{name})
if err != nil {
t.Fatal(err)
}
got := cmdutil.GetRuntimeCapabilities(leaf)
want := []runtimeplan.Capability{
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("%s capabilities = %v, want %v", name, got, want)
}
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.

View File

@@ -27,6 +27,13 @@ func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
Use: "plugins",
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
Short: "Inspect installed plugins and their hook contributions",
// Same leaf-level no-op as config policy: the parent `config`
// group's PersistentPreRunE requires builtin credential, but
// this is a read-only diagnostic that must work everywhere.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPluginsShow(f))
return cmd

View File

@@ -16,6 +16,12 @@ func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
Use: "policy",
Hidden: true,
Short: "Inspect the user-layer command policy",
// Override parent's RequireBuiltinCredentialProvider check; this
// group is read-only diagnostic and must work under any provider.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPolicyShow(f))
return cmd

View File

@@ -132,16 +132,19 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
}
}
// The policy group explicitly overrides the config parent's local credential
// management capability because it is source-neutral diagnostics.
func TestConfigPolicyOverridesCredentialManagementCapability(t *testing.T) {
// Regression: the parent `config` command declares a PersistentPreRunE
// that calls RequireBuiltinCredentialProvider; env credentials cause
// it to return external_provider. `config policy` is a diagnostic
// group that must not be blocked by that check. The group declares
// its own no-op PersistentPreRunE so cobra's "first walking up from
// leaf" picks ours over the config parent's.
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
f, _, _ := newPolicyTestFactory()
root := NewCmdConfig(f)
leaf, _, err := root.Find([]string{"policy", "show"})
if err != nil {
t.Fatal(err)
group := NewCmdConfigPolicy(f)
if group.PersistentPreRunE == nil {
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
}
if capabilities := cmdutil.GetRuntimeCapabilities(leaf); len(capabilities) != 0 {
t.Fatalf("policy capabilities = %v, want source-neutral", capabilities)
if err := group.PersistentPreRunE(group, nil); err != nil {
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
}
}

View File

@@ -23,6 +23,11 @@ func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {

View File

@@ -42,16 +42,6 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
// config show describes the effective invocation configuration, not merely
// the bytes in config.json. Preserve the typed bootstrap failure so a
// Standard binary cannot present a local Profile as active when the system
// requires Extended runtime support.
if startupErr := f.RuntimeStartupError(); startupErr != nil {
return startupErr
}
if handled, editionErr := showEditionConfig(f); handled {
return editionErr
}
config, err := core.LoadMultiAppConfig()
if err != nil {

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
type editionConfigShowResult struct {
Source string `json:"source"`
CredentialProvider string `json:"credentialProvider"`
Manageable bool `json:"manageable"`
Workspace string `json:"workspace"`
AppID string `json:"appId"`
Brand string `json:"brand"`
DefaultAs string `json:"defaultAs"`
Profile *string `json:"profile,omitempty"`
ExternalCredentialMode *string `json:"externalCredentialMode,omitempty"`
RemoteEndpoint *string `json:"remoteEndpoint,omitempty"`
}
func showEditionConfig(f *cmdutil.Factory) (bool, error) {
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return true, typedEditionProviderError("determine the active credential provider", err)
}
if source == nil || !source.Managed {
return false, nil
}
if source.AppID == "" {
return true, errs.NewInternalError(errs.SubtypeInvalidResponse,
"external credential provider %q returned no account", source.Name)
}
result := editionConfigShowResult{
Source: "external",
CredentialProvider: source.Name,
Manageable: false,
Workspace: core.CurrentWorkspace().Display(),
AppID: source.AppID,
Brand: string(source.Brand),
DefaultAs: string(source.DefaultAs),
}
description := f.RuntimeDescription()
if source.ProfileName != "" {
result.Profile = &source.ProfileName
}
if description.Managed && description.Variant != "" {
result.ExternalCredentialMode = &description.Variant
if description.ProxiesRequests {
result.RemoteEndpoint = &description.DataPlaneEndpoint
}
}
output.PrintJson(f.IOStreams.Out, result)
return true, nil
}
func typedEditionProviderError(action string, err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s: %v", action, err).WithCause(err)
}

View File

@@ -1,93 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedConfigShowAllowedWithManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
cmd := NewCmdConfig(f)
matched, _, err := cmd.Find([]string{"show"})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("config show blocked: %v", err)
}
}
func TestExtendedConfigShowProjectsManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
var stdout bytes.Buffer
f.IOStreams.Out = &stdout
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
ProxiesRequests: true,
DataPlaneEndpoint: "https://managed.example.test",
},
}))
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun() error = %v", err)
}
var got editionConfigShowResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Source != "external" ||
got.CredentialProvider != "env" ||
got.Manageable ||
got.AppID != "test-app" ||
got.ExternalCredentialMode == nil ||
*got.ExternalCredentialMode != "managed-test" ||
got.RemoteEndpoint == nil ||
*got.RemoteEndpoint != "https://managed.example.test" {
t.Fatalf("output = %#v", got)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &fields); err != nil {
t.Fatal(err)
}
if _, ok := fields["appSecret"]; ok {
t.Fatalf("managed output must not invent appSecret: %s", stdout.String())
}
if _, ok := fields["users"]; ok {
t.Fatalf("managed output must not invent users: %s", stdout.String())
}
}
func TestExtendedConfigShowTypesManagedSourceFailure(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
providerErr := errors.New("provider failed")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&stubConfigExtProvider{name: "broken", err: providerErr}},
nil, nil, nil,
)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("error = %#v, want internal/unknown", err)
}
if !errors.Is(err, providerErr) {
t.Fatalf("error does not preserve provider failure: %v", err)
}
}

View File

@@ -1,12 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import "github.com/larksuite/cli/internal/cmdutil"
func showEditionConfig(*cmdutil.Factory) (bool, error) {
return false, nil
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestStandardConfigShowPreservesLocalConfigPath(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryConfig ||
problem.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("error = %#v, want established config/not_configured result", err)
}
}
func TestStandardConfigShowReturnsTypedRuntimeStartupError(t *testing.T) {
startupErr := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"system external credential configuration requires the lark-cli Extended edition",
).WithHint("install lark-cli Extended or ask the administrator to remove external-credential.json")
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(
t,
nil,
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly),
)
err := configShowRun(&ConfigShowOptions{Factory: f})
if !errors.Is(err, startupErr) {
t.Fatalf("config show error = %v, want original startup error", err)
}
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryValidation ||
problem.Subtype != errs.SubtypeFailedPrecondition ||
problem.Message != "system external credential configuration requires the lark-cli Extended edition" {
t.Fatalf("config show problem = %#v, want typed Extended-required startup failure", problem)
}
if stdout.Len() != 0 {
t.Fatalf("config show wrote local Profile after bootstrap failure: %s", stdout.String())
}
}

View File

@@ -84,10 +84,6 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, checkCLIUpdate()...)
}
if handled, editionErr := runEditionDoctor(opts, checks); handled {
return editionErr
}
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
@@ -134,7 +130,8 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant.
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
@@ -218,7 +215,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := fetchLatestForEdition()
latest, err := update.FetchLatest()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}

View File

@@ -1,94 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
)
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
f := opts.Factory
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(opts.Ctx)
if err != nil {
checks = append(checks, fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)))
return true, finishDoctor(f, checks)
}
if source == nil || !source.Managed {
return false, nil
}
provider := source.Name
cfg, err := f.Config()
if err != nil {
checks = append(checks,
fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)),
skip("config_file", fmt.Sprintf("local credentials are not used; source is %s", provider)),
)
return true, finishDoctor(f, checks)
}
checks = append(checks, pass("credential_source",
fmt.Sprintf("credentials provided by %s (app %s; token not verified by this check)", provider, cfg.AppID)))
description := f.RuntimeDescription()
if description.Managed {
checks = append(checks, pass("config_file", "config.json found (system external credential mode)"))
} else {
checks = append(checks, skip("config_file",
fmt.Sprintf("local config not used; credentials provided by %s", provider)))
}
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
)
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
if description.ProxiesRequests {
checks = append(checks, editionProxyNetworkCheck(opts, description.DataPlaneEndpoint, diagnostics))
} else {
checks = append(checks, networkChecks(opts.Ctx, opts, core.ResolveEndpoints(cfg.Brand))...)
}
return true, finishDoctor(f, checks)
}
func editionDiagnosticErrorHint(err error) string {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
return blockErr.Reason
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
return cfgErr.Hint
}
return ""
}
func editionProxyNetworkCheck(opts *DoctorOptions, endpoint string, diagnostics identitydiag.Result) checkResult {
if opts.Offline {
return skip("endpoint_external_platform", "skipped (--offline)")
}
verified := func(id identitydiag.Identity) bool { return id.Verified != nil && *id.Verified }
if verified(diagnostics.User) || verified(diagnostics.Bot) {
return pass("endpoint_external_platform", endpoint+" reachable through an authenticated API request")
}
return fail("endpoint_external_platform", endpoint+" could not complete an authenticated API request",
"check the external credential program and platform logs")
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"context"
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestExtendedProxyNetworkCheckUsesAuthenticatedDiagnostics(t *testing.T) {
verified := true
endpoint := "https://credentials.example.com"
got := editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{
User: identitydiag.Identity{Verified: &verified},
})
if got.Status != "pass" || got.Name != "endpoint_external_platform" {
t.Fatalf("check = %#v", got)
}
got = editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{})
if got.Status != "fail" {
t.Fatalf("unverified check = %#v, want fail", got)
}
}
func TestExtendedDoctorManagedSourceDoesNotRequireLocalConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
OK bool `json:"ok"`
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.OK {
t.Fatalf("checks = %#v", got.Checks)
}
assertCheck(t, got.Checks, "credential_source", "pass")
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "skip" ||
!strings.Contains(configCheck.Message, "local config") ||
strings.Contains(configCheck.Message, "config init") {
t.Fatalf("config_file = %#v", configCheck)
}
}

View File

@@ -1,24 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/errs"
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
if opts == nil || opts.Factory == nil {
return false, nil
}
startupErr := opts.Factory.RuntimeStartupError()
if startupErr == nil {
return false, nil
}
hint := ""
if problem, ok := errs.ProblemOf(startupErr); ok {
hint = problem.Hint
}
checks = append(checks, fail("credential_source", startupErr.Error(), hint))
return true, finishDoctor(opts.Factory, checks)
}

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import (
"context"
"encoding/json"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardDoctorPreservesConfigFirstDiagnostics(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want established missing-config failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
assertCheck(t, got.Checks, "config_file", "fail")
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("Standard doctor exposed edition diagnostic: %#v", got.Checks)
}
}
}

View File

@@ -4,6 +4,7 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
@@ -174,44 +175,6 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
return nil, nil
}
type failingDefaultAccountResolver struct {
err error
}
func (r *failingDefaultAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return nil, r.err
}
func TestDoctor_DefaultResolutionFailurePreservesConfigFileCheck(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider(
nil,
&failingDefaultAccountResolver{err: core.NotConfiguredError()},
nil,
nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want not-configured failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "fail" {
t.Fatalf("config_file = %#v, want fail", configCheck)
}
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("default source resolution replaced the legacy config check: %#v", got.Checks)
}
}
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
@@ -232,8 +195,12 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = cred
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")

View File

@@ -1,12 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import "github.com/larksuite/cli/internal/extendedupdate"
func fetchLatestForEdition() (string, error) {
return extendedupdate.FetchLatest()
}

View File

@@ -1,12 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/internal/update"
func fetchLatestForEdition() (string, error) {
return update.FetchLatest()
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
func TestStandardDoctorReportsEditionSentinelWithoutLocalProfile(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configDir := t.TempDir()
systemPath := filepath.Join(configDir, "external-credential.json")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
if err := vfs.WriteFile(systemPath, []byte("sentinel-only"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
root := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), &stdout, &stderr),
WithoutPlugins(),
WithoutServiceCommands(),
)
root.SetArgs([]string{"doctor", "--offline"})
if err := root.ExecuteContext(context.Background()); err == nil {
t.Fatal("doctor returned nil, want failed diagnostic result")
}
var report struct {
Checks []struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Hint string `json:"hint"`
} `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("decode doctor output: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
}
for _, check := range report.Checks {
if check.Name != "credential_source" {
continue
}
if check.Status != "fail" ||
check.Message != "system external credential configuration requires the lark-cli Extended edition" ||
!strings.Contains(check.Hint, "install lark-cli Extended") {
t.Fatalf("credential_source check = %#v", check)
}
if strings.Contains(stdout.String(), "config init") {
t.Fatalf("doctor suggested local credential bootstrap for an edition sentinel: %s", stdout.String())
}
return
}
t.Fatalf("doctor did not report the edition sentinel: %s", stdout.String())
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands owns the Extended-only command surface.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestExtendedRegistersVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Extended edition commands = %v, want [version]", commands)
}
if commands[0].Hidden {
t.Fatal("Extended version command must be visible")
}
}

View File

@@ -1,20 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands keeps the release identity probe callable in
// Standard while cmd/version hides it from help. This preserves the ordinary
// command surface and gives installers/CI one edition-neutral verification
// contract.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestStandardRegistersHiddenVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Standard edition commands = %v, want [version]", commands)
}
if !commands[0].Hidden {
t.Fatal("Standard version command must remain hidden")
}
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
@@ -17,31 +16,14 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
// Without SilenceUsage, RunE errors print the full flag help banner.
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
// This hook shadows root's PersistentPreRun, so preserve the matched
// command for structured error and declared-scope hints.
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityRealtimeEvents)
consume := NewCmdConsume(f)
bus := NewCmdBus(f)
list := NewCmdList(f)
schema := NewCmdSchema(f)
status := NewCmdStatus(f)
stop := NewCmdStop(f)
for _, local := range []*cobra.Command{list, schema, status, stop} {
cmdutil.SetRuntimeCapabilities(local)
}
cmd.AddCommand(consume)
cmd.AddCommand(list)
cmd.AddCommand(schema)
cmd.AddCommand(status)
cmd.AddCommand(stop)
cmd.AddCommand(bus)
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f))
return cmd
}

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"io/fs"
"path/filepath"
"testing"
"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/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
func TestEventCommandsRejectDeniedRuntimeCapability(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_runtime_event_test",
AppSecret: "must-not-be-used",
Brand: core.BrandFeishu,
}
denied := errs.NewValidationError(errs.SubtypeFailedPrecondition,
"real-time events are unavailable in this runtime").
WithHint("use a runtime that supports real-time events")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityRealtimeEvents {
return denied
}
return nil
},
})
t.Run("consume", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"consume", "guarded-before-event-lookup"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
})
t.Run("bus", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"_bus"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
if _, err := vfs.Stat(filepath.Join(configDir, "events")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("event bus created runtime files before guard: %v", err)
}
})
}
func TestEventCommandRuntimeCapabilityMatrix(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdEvents(f)
parentCapabilities := cmdutil.GetRuntimeCapabilities(cmd)
if len(parentCapabilities) != 1 || parentCapabilities[0] != runtimeplan.CapabilityRealtimeEvents {
t.Fatalf("event capabilities = %v, want [%s]", parentCapabilities, runtimeplan.CapabilityRealtimeEvents)
}
wantRealtime := map[string]bool{
"_bus": true,
"consume": true,
"list": false,
"schema": false,
"status": false,
"stop": false,
}
children := make(map[string]*cobra.Command, len(wantRealtime))
for _, child := range cmd.Commands() {
name := child.Name()
want, ok := wantRealtime[name]
if !ok {
t.Fatalf("event command %q is missing from the runtime capability matrix", name)
}
children[name] = child
got := cmdutil.GetRuntimeCapabilities(child)
if want {
if len(got) != 1 || got[0] != runtimeplan.CapabilityRealtimeEvents {
t.Errorf("event %s capabilities = %v, want [%s]", name, got, runtimeplan.CapabilityRealtimeEvents)
}
continue
}
if len(got) != 0 {
t.Errorf("event %s capabilities = %v, want source-neutral local command", name, got)
}
}
if len(children) != len(wantRealtime) {
t.Fatalf("event command matrix covered %d commands, want %d", len(children), len(wantRealtime))
}
// Clearing the parent declaration must also clear both consumers. This
// proves they inherit the fail-closed default instead of duplicating a
// leaf annotation that future event commands could forget.
cmdutil.SetRuntimeCapabilities(cmd)
for _, name := range []string{"consume", "_bus"} {
if got := cmdutil.GetRuntimeCapabilities(children[name]); len(got) != 0 {
t.Errorf("event %s capabilities after clearing parent = %v, want inherited empty declaration", name, got)
}
}
}
func requireExternalEventGuard(t *testing.T, err error) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed problem", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want %s/%s",
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "" {
t.Fatalf("param = %q, want empty", validationErr.Param)
}
if problem.Hint == "" {
t.Fatal("hint is empty")
}
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdProfile creates the profile command with subcommands.
@@ -15,26 +14,13 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// A child PersistentPreRunE shadows root's PersistentPreRun, so retain
// the invocation state used by structured error hints here.
cmd.SilenceUsage = true
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalProfileMutation)
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
})
list := NewCmdProfileList(f)
// Listing profiles is read-only and remains useful for diagnostics under a
// managed credential runtime. Every other profile subcommand mutates local
// profile selection, config, or keychain state and inherits the parent gate.
cmdutil.SetRuntimeCapabilities(list)
cmd.AddCommand(list)
cmd.AddCommand(NewCmdProfileList(f))
cmd.AddCommand(NewCmdProfileUse(f))
cmd.AddCommand(NewCmdProfileAdd(f))
cmd.AddCommand(NewCmdProfileRemove(f))

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"bytes"
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
type recordingProfileKeychain struct {
gets int
sets int
removes int
}
func (k *recordingProfileKeychain) Get(_, _ string) (string, error) {
k.gets++
return "", nil
}
func (k *recordingProfileKeychain) Set(_, _, _ string) error {
k.sets++
return nil
}
func (k *recordingProfileKeychain) Remove(_, _ string) error {
k.removes++
return nil
}
func TestProfileMutationCommandsAreDeniedBeforeLocalStateChanges(t *testing.T) {
denied := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
).WithHint("manage credentials through the active provider")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return denied
}
return nil
},
})
tests := []struct {
name string
args []string
}{
{
name: "add",
args: []string{"add", "--name", "new", "--app-id", "app-new", "--app-secret-stdin"},
},
{
name: "use",
args: []string{"use", "target"},
},
{
name: "rename",
args: []string{"rename", "target", "renamed"},
},
{
name: "remove",
args: []string{"remove", "target"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configDir := setupProfileConfigDir(t)
saveManagedGateFixture(t)
configPath := filepath.Join(configDir, "config.json")
before, err := vfs.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(before) error = %v", err)
}
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
f.IOStreams.In = strings.NewReader("must-not-be-read\n")
keychain := &recordingProfileKeychain{}
f.Keychain = keychain
cmd := NewCmdProfile(f)
cmd.SetArgs(tt.args)
err = cmd.Execute()
if !errors.Is(err, denied) {
t.Fatalf("Execute() error = %v, want denied runtime error", err)
}
after, readErr := vfs.ReadFile(configPath)
if readErr != nil {
t.Fatalf("ReadFile(after) error = %v", readErr)
}
if !bytes.Equal(after, before) {
t.Fatalf("config changed despite runtime denial:\nbefore: %s\nafter: %s", before, after)
}
if keychain.gets != 0 || keychain.sets != 0 || keychain.removes != 0 {
t.Fatalf("keychain calls = get:%d set:%d remove:%d, want none",
keychain.gets, keychain.sets, keychain.removes)
}
})
}
}
func TestProfileListRemainsAvailableWhenLocalMutationIsDenied(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
)
}
return nil
},
})
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdProfile(f)
cmd.SetArgs([]string{"list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list was blocked by mutation capability: %v", err)
}
if !strings.Contains(stdout.String(), `"name": "default"`) {
t.Fatalf("profile list output = %s, want default profile", stdout.String())
}
}
func TestProfileMutationCommandsRemainAvailableByDefault(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
// origin/main allows Profile preparation while an environment/extension
// provider is active. The managed runtime blocks this through its explicit
// plan policy; generic provider ownership must not change Standard.
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{profileEnvironmentProvider{}},
nil,
nil,
nil,
)
cmd := NewCmdProfile(f)
args := []string{"use", "target"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use with default runtime plan error = %v", err)
}
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "target" || saved.PreviousApp != "default" {
t.Fatalf("selection = current:%q previous:%q, want target/default",
saved.CurrentApp, saved.PreviousApp)
}
}
type profileEnvironmentProvider struct{}
func (profileEnvironmentProvider) Name() string { return "env" }
func (profileEnvironmentProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{
AppID: "cli_environment",
Brand: extcred.BrandFeishu,
SupportedIdentities: extcred.SupportsAll,
}, nil
}
func (profileEnvironmentProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: "environment-token"}, nil
}
func saveManagedGateFixture(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.SecretInput{Ref: &core.SecretRef{
Source: "keychain",
ID: "appsecret:app-target",
}},
Brand: core.BrandLark,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -101,12 +100,6 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
// Resolve all startup state from the detected workspace. This must happen
// before ResolveStartupBrand, isSingleAppMode, or buildInternal reads
// workspace-scoped configuration.
selectInvocationWorkspace()
startup := runtimebootstrap.Resolve(inv.Profile)
startupBrand := resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
configureFlagCompletions(os.Args)
ctx := context.Background()
@@ -114,8 +107,7 @@ func Execute() int {
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(startupBrand),
withRuntimeBootstrap(startup),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
)
// --- Notices (non-blocking) ---
@@ -145,7 +137,7 @@ func Execute() int {
// or both may be present in any given envelope.
func setupNotices() {
// Binary update — synchronous cache check + async refresh
if info := checkCachedEditionUpdate(build.Version); info != nil {
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
@@ -155,9 +147,9 @@ func setupNotices() {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
refreshEditionUpdateCache(ver)
update.RefreshCache(ver)
if update.GetPending() == nil {
if info := checkCachedEditionUpdate(ver); info != nil {
if info := update.CheckCached(ver); info != nil {
update.SetPending(info)
}
}

View File

@@ -18,7 +18,6 @@ import (
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -436,25 +435,6 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
}
}
func TestHandleRootError_BlockErrorPreservesUntypedFallback(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
blockErr := &extcred.BlockError{Provider: "env", Reason: "LARKSUITE_CLI_APP_ID is missing"}
exit := handleRootError(f, blockErr)
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if errObj["type"] != "internal" || errObj["subtype"] != "unknown" {
t.Fatalf("error = %#v", errObj)
}
if errObj["message"] != "blocked by env: LARKSUITE_CLI_APP_ID is missing" {
t.Fatalf("error.message = %v", errObj["message"])
}
if exit != int(output.ExitInternal) {
t.Fatalf("exit = %d, want %d", exit, output.ExitInternal)
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -57,14 +58,24 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version from this binary's release channel.
// Standard reads the npm-backed cache; Extended reads its separate
// GitHub-release cache.
info := checkCachedEditionUpdate(build.Version)
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
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

@@ -6,6 +6,7 @@ package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -14,21 +15,13 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
"github.com/spf13/cobra"
)
func updateStateFileForEdition(edition string) string {
if edition == "extended" {
return "update-state-extended.json"
}
return "update-state.json"
}
func writeUpdateState(t *testing.T, dir, edition, latest string) {
func writeUpdateState(t *testing.T, dir, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := vfs.WriteFile(filepath.Join(dir, updateStateFileForEdition(edition)), []byte(data), 0o600); err != nil {
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
@@ -112,7 +105,7 @@ func TestOfferRootUpgrade(t *testing.T) {
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, build.Edition, tc.latest)
writeUpdateState(t, dir, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
@@ -135,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)
}
@@ -142,53 +146,6 @@ func TestOfferRootUpgrade(t *testing.T) {
}
}
func TestOfferRootUpgradeIgnoresOtherEditionCache(t *testing.T) {
origV := build.Version
build.Version = "1.0.0"
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
otherEdition := "extended"
if build.Edition == "extended" {
otherEdition = "standard"
}
writeUpdateState(t, dir, otherEdition, "9.0.0")
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: true,
OutIsTerminal: true,
StderrIsTerminal: true,
}}
offerRootUpgrade(f, &cobra.Command{})
if strings.Contains(errBuf.String(), "available") {
t.Fatalf("%s prompt consumed %s cache: %q", build.Edition, otherEdition, errBuf.String())
}
if called {
t.Fatalf("%s upgrade ran from %s cache", build.Edition, otherEdition)
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })

View File

@@ -10,34 +10,17 @@ import (
"github.com/larksuite/cli/internal/envvars"
)
// selectInvocationWorkspace establishes the workspace before any startup
// consumer reads workspace-scoped configuration. Execute needs this before
// resolving the registry brand, while Build/buildInternal needs it before
// capturing the immutable Profile snapshot passed to the Factory.
func selectInvocationWorkspace() core.Workspace {
workspace := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(workspace)
return workspace
}
// ResolveStartupBrand resolves the brand before the command tree is built, so
// the registry's remote metadata overlay uses the configured brand from the
// first catalog access. It mirrors the credential chain's brand precedence —
// environment, then the active profile's raw config entry — without touching
// the keychain (no secrets are needed to know the brand).
func ResolveStartupBrand(profile string) core.LarkBrand {
config, _ := core.LoadMultiAppConfig()
return resolveStartupBrandFromConfig(profile, config)
}
// resolveStartupBrandFromConfig keeps registry routing on the same immutable
// Profile snapshot used by credentials and runtime policy.
func resolveStartupBrandFromConfig(profile string, config *core.MultiAppConfig) core.LarkBrand {
if raw := os.Getenv(envvars.CliBrand); raw != "" {
return core.ParseBrand(raw)
}
if config != nil {
if app := config.CurrentAppConfig(profile); app != nil {
if cfg, err := core.LoadMultiAppConfig(); err == nil {
if app := cfg.CurrentAppConfig(profile); app != nil {
return core.ParseBrand(string(app.Brand))
}
}

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
)
var (
fetchExtendedLatest = extendedupdate.FetchLatest
installExtended = extendedupdate.Install
)
func updateLongDescription() string {
return `Update lark-cli Extended from the matching GitHub Release.
The command downloads the lark-cli-extended asset for the current platform,
verifies its SHA-256 checksum and compiled edition identity, then replaces the
current binary. It never installs the Standard npm/npx edition.
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}
func runEditionUpdate(opts *UpdateOptions) (bool, error) {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
if !opts.Check {
updater.Brand = resolveSkillsBrand(opts.Factory, io.ErrOut)
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
latest, err := fetchExtendedLatest()
if err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "network", typed)
}
return true, reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to check the latest Extended version: %v", err).WithCause(err))
}
if update.ParseVersion(latest) == nil {
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse,
"invalid Extended version from GitHub Releases: %s", latest))
}
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
}
return true, reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
if opts.Check {
return true, reportCheckResult(opts, io, cur, latest, true)
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli Extended %s %s %s from GitHub Releases ...\n", cur, symArrow(), latest)
}
if err := installExtended(latest); err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "update_error", typed)
}
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeUnknown,
"failed to install lark-cli Extended: %v", err).WithCause(err))
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "edition": build.Edition, "action": "updated",
"message": fmt.Sprintf("lark-cli Extended updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
applySkillsResult(result, skillsResult)
output.PrintJson(io.Out, result)
return true, nil
}
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli Extended from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
emitSkillsTextHints(io, skillsResult)
return true, nil
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
)
func TestExtendedUpdateUsesExtendedReleaseInstaller(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion, oldUpdater, oldSync := currentVersion, newUpdater, syncSkills
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion, newUpdater, syncSkills = oldVersion, oldUpdater, oldSync
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installed := ""
installExtended = func(version string) error {
installed = version
return nil
}
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallManual}
}}
}
syncSkills = func(skillscheck.SyncOptions) *skillscheck.SyncResult { return &skillscheck.SyncResult{} }
var out, errOut bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &errOut), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
if installed != "1.2.4" {
t.Fatalf("installed version = %q, want 1.2.4", installed)
}
var result map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["edition"] != "extended" || result["action"] != "updated" {
t.Fatalf("result = %#v", result)
}
}
func TestExtendedUpdateCheckDoesNotInstall(t *testing.T) {
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion := currentVersion
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion = oldVersion
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installExtended = func(string) error {
t.Fatal("installer called during --check")
return nil
}
var out bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &bytes.Buffer{}), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,20 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
func runEditionUpdate(*UpdateOptions) (bool, error) { return false, nil }
func updateLongDescription() string {
return `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}

View File

@@ -101,7 +101,15 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update lark-cli to the latest version",
Long: updateLongDescription(),
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
@@ -116,9 +124,6 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
}
func updateRun(opts *UpdateOptions) error {
if handled, err := runEditionUpdate(opts); handled {
return err
}
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()

View File

@@ -1,8 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
import (

View File

@@ -1,19 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/update"
)
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return extendedupdate.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
extendedupdate.RefreshCache(currentVersion)
}

View File

@@ -1,16 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import "github.com/larksuite/cli/internal/update"
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return update.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
update.RefreshCache(currentVersion)
}

View File

@@ -1,56 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
type options struct {
factory *cmdutil.Factory
json bool
}
type versionReport struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
// NewCmdVersion reports the immutable edition identity compiled into the
// binary. Root --version remains unchanged for compatibility.
func NewCmdVersion(f *cmdutil.Factory) *cobra.Command {
opts := &options{factory: f}
cmd := &cobra.Command{
Use: "version",
Short: "Show version and edition information",
Hidden: hideVersionCommand(),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.json {
output.PrintJson(opts.factory.IOStreams.Out, versionReport{
Version: build.Version,
Edition: build.Edition,
Capabilities: build.Capabilities(),
})
return nil
}
_, err := fmt.Fprintf(opts.factory.IOStreams.Out, "lark-cli version %s (%s)\n", build.Version, build.Edition)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write version output: %v", err).WithCause(err)
}
return nil
},
}
cmd.Flags().BoolVar(&opts.json, "json", false, "structured JSON output")
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestVersionJSONReportsCompiledEdition(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
var got struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Version != build.Version || got.Edition != build.Edition || !reflect.DeepEqual(got.Capabilities, build.Capabilities()) {
t.Fatalf("version output = %#v, want version=%q edition=%q", got, build.Version, build.Edition)
}
}
func TestVersionVisibilityPreservesStandardHelpSurface(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
wantHidden := build.Edition == "standard"
if cmd.Hidden != wantHidden {
t.Fatalf("version command hidden = %v, want %v for %s", cmd.Hidden, wantHidden, build.Edition)
}
}
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestVersionTextWriteFailureIsTyped(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
writeErr := errors.New("write failed")
f.IOStreams.Out = failingWriter{err: writeErr}
cmd := NewCmdVersion(f)
err := cmd.ExecuteContext(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeSDKError {
t.Fatalf("error = %#v, want internal/sdk_error", err)
}
if !errors.Is(err, writeErr) {
t.Fatalf("error does not preserve write failure: %v", err)
}
}

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package version
func hideVersionCommand() bool { return false }

View File

@@ -1,10 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package version
// Standard keeps its historical help surface unchanged. The command remains
// directly callable for release identity verification.
func hideVersionCommand() bool { return true }

View File

@@ -62,8 +62,6 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.message` | informational | not safe to branch on |
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.origin` | informational | Extended producer: `cli`, `credential_process`, `proxy`, or `lark`; omitted by Standard to preserve its existing envelope; consumers must tolerate absence and unknown future values |
| `error.proxy_request_id` | informational | external credential platform trace id; never stored in `log_id` |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
@@ -106,7 +104,7 @@ already succeeded).
| `config` | local config missing / unbound | 3 | `ConfigError` |
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
| `policy` | security policy denial/challenge, including content safety | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
@@ -274,7 +272,7 @@ legal for framework dynamic paths (e.g. classifier fanout) but the lint
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
| Transport or external dependency failure | `errs.NewNetworkError(subtype, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport` / `credential_source_unavailable` / `upstream_unavailable`) |
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
@@ -515,11 +513,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
typed struct as a documented extension field, not into the envelope's
top level. The external credential platform contract is the single explicit
exception: `origin` and `proxy_request_id` are shared across several error
categories and therefore live in `Problem`. Both fields are optional, and
consumers must ignore them when absent or unknown. Any further shared field
still requires an explicit contract revision and wire-format pin.
top level.
## CI guards

View File

@@ -1,134 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
)
// DiagnosticMetadata carries optional producer diagnostics without changing
// the field layout of Problem or any concrete typed error. Keeping this
// metadata in a wrapper preserves source compatibility for callers that use
// positional literals of the existing exported error structs.
type DiagnosticMetadata struct {
Origin string
ProxyRequestID string
}
type diagnosticMetadataWrapper struct {
err error
typed error
metadata DiagnosticMetadata
}
func (e *diagnosticMetadataWrapper) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *diagnosticMetadataWrapper) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func (e *diagnosticMetadataWrapper) ProblemDetail() *Problem {
if e == nil {
return nil
}
problem, _ := ProblemOf(e.typed)
return problem
}
func (e *diagnosticMetadataWrapper) DiagnosticMetadata() DiagnosticMetadata {
if e == nil {
return DiagnosticMetadata{}
}
return e.metadata
}
// MarshalJSON preserves the concrete typed error's extension fields and adds
// the optional diagnostics as sibling fields in the existing error object.
func (e *diagnosticMetadataWrapper) MarshalJSON() ([]byte, error) {
raw, err := json.Marshal(e.typed)
if err != nil {
return nil, err
}
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if e.metadata.Origin != "" {
origin, err := json.Marshal(e.metadata.Origin)
if err != nil {
return nil, err
}
object["origin"] = origin
}
if e.metadata.ProxyRequestID != "" {
requestID, err := json.Marshal(e.metadata.ProxyRequestID)
if err != nil {
return nil, err
}
object["proxy_request_id"] = requestID
}
return json.Marshal(object)
}
// WithDiagnosticMetadata attaches optional wire diagnostics to a typed error.
// Empty metadata is a no-op. The returned wrapper still participates in
// errors.Is/errors.As and TypedError routing through Unwrap and ProblemDetail.
func WithDiagnosticMetadata(err error, metadata DiagnosticMetadata) error {
if err == nil || (metadata.Origin == "" && metadata.ProxyRequestID == "") {
return err
}
typed, ok := UnwrapTypedError(err)
if !ok {
return err
}
if existing, ok := diagnosticMetadataWrapperForProducer(typed); ok {
merged := existing.metadata
if metadata.Origin != "" {
merged.Origin = metadata.Origin
}
if metadata.ProxyRequestID != "" {
merged.ProxyRequestID = metadata.ProxyRequestID
}
return &diagnosticMetadataWrapper{err: err, typed: existing.typed, metadata: merged}
}
return &diagnosticMetadataWrapper{err: err, typed: typed, metadata: metadata}
}
// DiagnosticMetadataOf returns optional diagnostics attached to the first
// typed producer in err's wrap chain. Metadata on a typed cause belongs to
// that inner producer and must not be projected onto an outer typed error.
func DiagnosticMetadataOf(err error) (DiagnosticMetadata, bool) {
typed, ok := UnwrapTypedError(err)
if !ok {
return DiagnosticMetadata{}, false
}
carrier, ok := diagnosticMetadataWrapperForProducer(typed)
if !ok {
return DiagnosticMetadata{}, false
}
metadata := carrier.DiagnosticMetadata()
if metadata.Origin == "" && metadata.ProxyRequestID == "" {
return DiagnosticMetadata{}, false
}
return metadata, true
}
// diagnosticMetadataWrapperForProducer deliberately checks only the selected
// typed producer. errors.As must not be used here because it would traverse
// into an inner typed cause and associate that cause's metadata with the outer
// producer.
func diagnosticMetadataWrapperForProducer(typed error) (*diagnosticMetadataWrapper, bool) {
wrapper, ok := typed.(*diagnosticMetadataWrapper) //nolint:errorlint // Exact producer identity is the invariant being enforced.
return wrapper, ok
}

View File

@@ -1,118 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"errors"
"fmt"
"testing"
)
func TestDiagnosticMetadataPreservesTypedErrorContract(t *testing.T) {
permission := NewPermissionError(SubtypeMissingScope, "missing scope").
WithMissingScopes("im:message")
wrapped := WithDiagnosticMetadata(permission, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_1",
})
var gotPermission *PermissionError
if !errors.As(wrapped, &gotPermission) || gotPermission != permission {
t.Fatalf("errors.As() = %p, want original permission error %p", gotPermission, permission)
}
problem, ok := ProblemOf(wrapped)
if !ok || problem != &permission.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want original Problem %p", problem, ok, &permission.Problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "proxy" || metadata.ProxyRequestID != "proxy_req_1" {
t.Fatalf("DiagnosticMetadataOf() = (%#v, %v)", metadata, ok)
}
raw, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
if object["origin"] != "proxy" || object["proxy_request_id"] != "proxy_req_1" {
t.Fatalf("metadata missing from JSON: %s", raw)
}
missingScopes, ok := object["missing_scopes"].([]any)
if !ok || len(missingScopes) != 1 || missingScopes[0] != "im:message" {
t.Fatalf("typed extension fields missing from JSON: %s", raw)
}
}
func TestDiagnosticMetadataMergesWithoutMutatingExistingWrapper(t *testing.T) {
typed := NewNetworkError(SubtypeUpstreamUnavailable, "unavailable")
withOrigin := WithDiagnosticMetadata(typed, DiagnosticMetadata{Origin: "proxy"})
withRequestID := WithDiagnosticMetadata(withOrigin, DiagnosticMetadata{ProxyRequestID: "proxy_req_2"})
original, _ := DiagnosticMetadataOf(withOrigin)
if original.ProxyRequestID != "" {
t.Fatalf("existing wrapper was mutated: %#v", original)
}
merged, ok := DiagnosticMetadataOf(withRequestID)
if !ok || merged.Origin != "proxy" || merged.ProxyRequestID != "proxy_req_2" {
t.Fatalf("merged metadata = (%#v, %v)", merged, ok)
}
}
func TestDiagnosticMetadataPreservesOuterErrorContext(t *testing.T) {
cause := errors.New("transport failed")
typed := NewNetworkError(SubtypeNetworkTransport, "request failed").WithCause(cause)
outer := fmt.Errorf("fetch document: %w", typed)
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "proxy"})
if got, want := wrapped.Error(), outer.Error(); got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
if !errors.Is(wrapped, cause) {
t.Fatal("metadata wrapper lost the original cause chain")
}
var gotTyped *NetworkError
if !errors.As(wrapped, &gotTyped) || gotTyped != typed {
t.Fatalf("errors.As() = %p, want original typed error %p", gotTyped, typed)
}
}
func TestDiagnosticMetadataDoesNotCrossTypedProducerBoundary(t *testing.T) {
inner := NewNetworkError(SubtypeUpstreamUnavailable, "proxy unavailable")
annotatedInner := WithDiagnosticMetadata(inner, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_inner",
})
outer := NewInternalError(SubtypeUnknown, "business reclassified failure").
WithCause(annotatedInner)
if metadata, ok := DiagnosticMetadataOf(outer); ok {
t.Fatalf("outer typed producer inherited inner metadata: %#v", metadata)
}
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "cli"})
problem, ok := ProblemOf(wrapped)
if !ok || problem != &outer.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want outer Problem %p", problem, ok, &outer.Problem)
}
if problem.Category != CategoryInternal ||
problem.Subtype != SubtypeUnknown ||
problem.Message != "business reclassified failure" {
t.Fatalf("outer typed identity changed: %#v", problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "cli" || metadata.ProxyRequestID != "" {
t.Fatalf("outer metadata = (%#v, %v), want cli without inner request id", metadata, ok)
}
innerMetadata, ok := DiagnosticMetadataOf(annotatedInner)
if !ok ||
innerMetadata.Origin != "proxy" ||
innerMetadata.ProxyRequestID != "proxy_req_inner" {
t.Fatalf("inner metadata was mutated: (%#v, %v)", innerMetadata, ok)
}
}

View File

@@ -27,11 +27,7 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
Identity: "user",
ConsoleURL: "https://example",
}
withMetadata := WithDiagnosticMetadata(pe, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_123",
})
b, err := json.Marshal(withMetadata)
b, err := json.Marshal(pe)
if err != nil {
t.Fatal(err)
}
@@ -43,8 +39,6 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
`"message":"x"`,
`"hint":"y"`,
`"log_id":"lg"`,
`"origin":"proxy"`,
`"proxy_request_id":"proxy_req_123"`,
`"missing_scopes":["docx:document"]`,
`"identity":"user"`,
`"console_url":"https://example"`,

View File

@@ -48,13 +48,11 @@ const (
// CategoryNetwork subtypes
const (
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
SubtypeCredentialSourceUnavailable Subtype = "credential_source_unavailable" // external credential program or identity service is temporarily unavailable
SubtypeUpstreamUnavailable Subtype = "upstream_unavailable" // external proxy cannot reach the requested upstream service
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
)
// CategoryAPI subtypes

View File

@@ -1,12 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package build
const Edition = "extended"
func Capabilities() []string {
return []string{"external-credential-platform"}
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package build
const Edition = "standard"
func Capabilities() []string { return []string{} }

View File

@@ -23,7 +23,6 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/requestcontext"
"github.com/larksuite/cli/internal/util"
)
@@ -120,7 +119,6 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ctx = requestcontext.WithIdentity(ctx, as)
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
@@ -157,7 +155,6 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
ctx = requestcontext.WithIdentity(ctx, as)
cfg := buildConfig(opts)
// Resolve auth
@@ -215,9 +212,6 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}

View File

@@ -1,169 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"io"
"net/http"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
// RemoteFile is a service-returned file reference that has passed the active
// runtime's data-plane policy. Its URL is intentionally private so callers
// cannot construct a trusted reference without Validate.
type RemoteFile struct {
rawURL string
}
// URL returns the original service-provided URL verbatim.
func (f RemoteFile) URL() string { return f.rawURL }
// NewRequest constructs a request that remains bound to this validated file
// reference. Business shortcuts do not construct raw URL requests themselves.
func (f RemoteFile) NewRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
if f.rawURL == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"cannot build a request for an empty remote file reference")
}
req, err := http.NewRequestWithContext(ctx, method, f.rawURL, body)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"build remote file request: %v", err).WithCause(err)
}
return req, nil
}
// DirectRemoteFileValidator applies a caller-specific policy to direct-mode
// URLs. Proxy handles are validated by the managed data-plane policy instead.
type DirectRemoteFileValidator func(context.Context, string) error
// HTTPClientProvider resolves the runtime's configured HTTP client without
// exposing raw client construction to business shortcuts.
type HTTPClientProvider func() (*http.Client, error)
// RemoteFilePolicy is the source-neutral runtime contract for validating and
// routing service-returned file references.
type RemoteFilePolicy interface {
ValidateRemoteFile(rawURL string) error
UsesManagedFilePlane() bool
}
// RemoteFiles is the runtime boundary for service-returned file references.
// It keeps credential mode and edition routing out of business shortcuts.
// Every service-returned file byte transfer must pass through Validate,
// RemoteFile.NewRequest, and Do so the active data-plane policy is enforced.
type RemoteFiles struct {
policy RemoteFilePolicy
managedClient HTTPClientProvider
identity core.Identity
}
// NewRemoteFiles creates a file boundary for one resolved runtime identity.
func NewRemoteFiles(
policy RemoteFilePolicy,
managedClient HTTPClientProvider,
identity core.Identity,
) *RemoteFiles {
return &RemoteFiles{
policy: policy,
managedClient: managedClient,
identity: identity,
}
}
// Validate checks a known service-returned file URL and returns an opaque,
// typed reference. A directValidator, when supplied, is applied only to the
// ordinary/direct data plane; proxy handles use the configured proxy policy.
func (r *RemoteFiles) Validate(
ctx context.Context,
rawURL string,
directValidator ...DirectRemoteFileValidator,
) (RemoteFile, error) {
if r == nil {
return RemoteFile{}, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if r.policy != nil {
if err := r.policy.ValidateRemoteFile(rawURL); err != nil {
return RemoteFile{}, err
}
}
if !usesManagedFilePlane(r.policy) && len(directValidator) > 0 && directValidator[0] != nil {
if err := directValidator[0](ctx, rawURL); err != nil {
return RemoteFile{}, err
}
}
return RemoteFile{rawURL: rawURL}, nil
}
// RequirePortableURL rejects commands that would return a managed file handle
// for use outside this CLI. The check is capability-based, so callers do not
// branch on a credential mode.
func (r *RemoteFiles) RequirePortableURL(param string) error {
if r == nil || !usesManagedFilePlane(r.policy) {
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%s is unavailable because this credential source requires CLI-managed file routing", param).
WithParam(param).
WithHint("omit %s and let lark-cli transfer the file", param)
}
// Do executes a request for a validated remote file. Ordinary/direct runtimes
// use directClient unchanged; a nil directClient gets the same isolated
// DefaultTransport client historically used by presigned Apps transfers.
// Managed runtimes select the proxy-aware client and copy the caller's
// timeout/redirect policy so existing transfer semantics remain intact.
func (r *RemoteFiles) Do(req *http.Request, file RemoteFile, directClient *http.Client) (*http.Response, error) {
if r == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if req == nil || req.URL == nil || file.rawURL == "" || req.URL.String() != file.rawURL {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"remote file request does not match its validated reference")
}
if directClient == nil {
directClient = &http.Client{Transport: http.DefaultTransport}
}
selected := directClient
managedPlane := usesManagedFilePlane(r.policy)
if managedPlane {
if r.managedClient == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime has no HTTP client")
}
managed, err := r.managedClient()
if err != nil {
return nil, err
}
if managed == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime returned no HTTP client")
}
if directClient != nil && managed != directClient {
cloned := *managed
cloned.Timeout = directClient.Timeout
if directClient.CheckRedirect != nil {
cloned.CheckRedirect = directClient.CheckRedirect
}
selected = &cloned
} else {
selected = managed
}
}
if !managedPlane {
return selected.Do(req)
}
requestCtx := requestcontext.WithIdentity(req.Context(), r.identity)
return selected.Do(req.Clone(requestCtx))
}
func usesManagedFilePlane(policy RemoteFilePolicy) bool {
return policy != nil && policy.UsesManagedFilePlane()
}

View File

@@ -1,317 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
const externalCredentialImplementation = "github.com/larksuite/cli/internal/externalcredential"
// These functions own protocols that are explicitly outside the
// service-returned file plane: user-supplied external downloads and MCP.
// Adding a boundary is an architectural decision; //nolint alone must never
// make a new raw HTTP path possible.
var shortcutRawHTTPBoundaries = map[string]string{
"common/mcp_client.go:DoMCPCall": "MCP protocol client",
"doc/doc_resource_cover.go:downloadDocCoverURL": "validated user-supplied cover URL",
"doc/doc_resource_cover.go:newDocCoverHTTPClient": "guarded external-download client",
"doc/doc_resource_cover.go:cloneDocCoverTransport": "guarded external-download transport",
"im/helpers.go:startURLDownload": "validated user-supplied media URL",
}
func TestCoreDoesNotOwnExternalCredentialProductModel(t *testing.T) {
checkCoreDirectoryIsProductNeutral(t, filepath.Join("..", "core"))
}
func TestRuntimeFrameworkDoesNotImportExternalCredentialImplementation(t *testing.T) {
for _, dir := range []string{
".",
filepath.Join("..", "cmdutil"),
filepath.Join("..", "credential"),
} {
checkDirectoryDoesNotImportExternalCredential(t, dir)
}
}
func checkCoreDirectoryIsProductNeutral(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
forbidden := []string{
"externalcredential",
"external-credential",
"credential_proxy",
"platform_proxy",
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkCoreDirectoryIsProductNeutral(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
normalizedSource := strings.ToLower(string(src))
for _, productSymbol := range forbidden {
if strings.Contains(normalizedSource, strings.ToLower(productSymbol)) {
t.Errorf("%s contains external credential product symbol %q; keep the core profile model product-neutral", path, productSymbol)
}
}
}
}
func TestBusinessShortcutsUseRuntimeCredentialBoundaries(t *testing.T) {
checkBusinessShortcutDirectory(t, filepath.Join("..", "..", "shortcuts"))
}
func TestShortcutRawHTTPConstructionIsConfinedToExplicitBoundaries(t *testing.T) {
root := filepath.Join("..", "..", "shortcuts")
checkShortcutRawHTTPDirectory(t, root, root)
}
func TestRawHTTPConstructionGuardRecognizesAliasedBypasses(t *testing.T) {
const source = `package fixture
import nethttp "net/http"
func bypass() {
_ = &nethttp.Client{}
_, _ = nethttp.NewRequest(nethttp.MethodGet, "https://files.example", nil)
_ = nethttp.DefaultClient
}`
file, err := parser.ParseFile(token.NewFileSet(), "fixture.go", source, 0)
if err != nil {
t.Fatal(err)
}
uses := rawHTTPConstructions(file, map[string]struct{}{"nethttp": {}})
joined := strings.Join(uses, ",")
for _, want := range []string{"Client literal", "NewRequest", "DefaultClient"} {
if !strings.Contains(joined, want) {
t.Fatalf("raw HTTP uses = %q, want %q", joined, want)
}
}
}
func checkDirectoryDoesNotImportExternalCredential(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkDirectoryDoesNotImportExternalCredential(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; inject a source-neutral runtime boundary", path)
}
}
}
}
func checkBusinessShortcutDirectory(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if entry.Name() != "common" {
checkBusinessShortcutDirectory(t, path)
}
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; use a source-neutral runtime capability boundary", path)
}
}
ast.Inspect(file, func(node ast.Node) bool {
selector, ok := node.(*ast.SelectorExpr)
if ok && selector.Sel.Name == "ExternalCredential" {
t.Errorf("%s selects ExternalCredential directly; use a runtime capability boundary", path)
}
return true
})
}
}
func checkShortcutRawHTTPDirectory(t *testing.T, root, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkShortcutRawHTTPDirectory(t, root, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
httpAliases := make(map[string]struct{})
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath != "net/http" {
continue
}
alias := "http"
if spec.Name != nil {
alias = spec.Name.Name
}
if alias == "." {
t.Errorf("%s dot-imports net/http; raw HTTP boundaries must remain statically auditable", path)
continue
}
if alias != "_" {
httpAliases[alias] = struct{}{}
}
}
if len(httpAliases) == 0 {
continue
}
relativePath, err := filepath.Rel(root, path)
if err != nil {
t.Fatalf("relative shortcut path for %s: %v", path, err)
}
relativePath = filepath.ToSlash(relativePath)
for _, decl := range file.Decls {
fn, isFunc := decl.(*ast.FuncDecl)
uses := rawHTTPConstructions(decl, httpAliases)
if len(uses) == 0 {
continue
}
if !isFunc {
t.Errorf("%s constructs raw net/http at package scope (%s); route service-returned file bytes through RuntimeContext.RemoteFiles",
path, strings.Join(uses, ", "))
continue
}
boundary := relativePath + ":" + fn.Name.Name
if _, allowed := shortcutRawHTTPBoundaries[boundary]; allowed {
continue
}
t.Errorf("%s function %s constructs raw net/http (%s); route service-returned file bytes through RuntimeContext.RemoteFiles or declare a reviewed non-file-plane boundary",
path, fn.Name.Name, strings.Join(uses, ", "))
}
}
}
func rawHTTPConstructions(node ast.Node, aliases map[string]struct{}) []string {
seen := make(map[string]struct{})
var uses []string
add := func(name string) {
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
uses = append(uses, name)
}
isHTTPSelector := func(selector *ast.SelectorExpr) bool {
ident, ok := selector.X.(*ast.Ident)
if !ok {
return false
}
_, ok = aliases[ident.Name]
return ok
}
ast.Inspect(node, func(current ast.Node) bool {
switch typed := current.(type) {
case *ast.CallExpr:
selector, ok := typed.Fun.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "NewRequest", "NewRequestWithContext", "Get", "Post", "PostForm", "Head", "Serve", "ListenAndServe":
add(selector.Sel.Name)
}
case *ast.CompositeLit:
selector, ok := typed.Type.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "Client", "Request", "Transport":
add(selector.Sel.Name + " literal")
}
case *ast.SelectorExpr:
if !isHTTPSelector(typed) {
break
}
switch typed.Sel.Name {
case "DefaultClient", "DefaultTransport":
add(typed.Sel.Name)
}
}
return true
})
return uses
}

View File

@@ -1,177 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
type testRemoteFilePolicy struct {
allowed string
managed bool
}
func (p testRemoteFilePolicy) ValidateRemoteFile(rawURL string) error {
if p.allowed != "" && rawURL != p.allowed {
return errors.New("remote file rejected")
}
return nil
}
func (p testRemoteFilePolicy) UsesManagedFilePlane() bool { return p.managed }
func TestRemoteFilesDirectPreservesCallerClientAndPolicy(t *testing.T) {
var managedCalls, directCalls, validationCalls int
direct := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
directCalls++
if got := requestcontext.Identity(req.Context()); got != "" {
t.Fatalf("direct request context was changed: identity = %q", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
files := NewRemoteFiles(nil, func() (*http.Client, error) {
managedCalls++
return nil, errors.New("must not be called")
}, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/object?signature=x",
func(_ context.Context, rawURL string) error {
validationCalls++
if rawURL != "https://files.example/object?signature=x" {
t.Fatalf("validator URL = %q", rawURL)
}
return nil
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if directCalls != 1 || managedCalls != 0 || validationCalls != 1 {
t.Fatalf("calls direct=%d managed=%d validation=%d", directCalls, managedCalls, validationCalls)
}
}
func TestRemoteFilesManagedValidatesAndRoutesOpaqueHandle(t *testing.T) {
const opaqueURL = "https://proxy.example/lark-cli/v1/files/opaque_1"
policy := testRemoteFilePolicy{allowed: opaqueURL, managed: true}
var managedCalls, directCalls, directValidationCalls int
managed := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
managedCalls++
if got := requestcontext.Identity(req.Context()); got != core.AsBot {
t.Fatalf("request identity = %q, want bot", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
direct := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
directCalls++
return nil, errors.New("must not be called")
})}
files := NewRemoteFiles(policy, func() (*http.Client, error) { return managed, nil }, core.AsBot)
if _, err := files.Validate(context.Background(), "https://objects.example/raw?signature=secret"); err == nil {
t.Fatal("raw object URL accepted in managed mode")
}
file, err := files.Validate(context.Background(), opaqueURL,
func(context.Context, string) error {
directValidationCalls++
return errors.New("must not be called")
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if managedCalls != 1 || directCalls != 0 || directValidationCalls != 0 {
t.Fatalf("calls managed=%d direct=%d validation=%d", managedCalls, directCalls, directValidationCalls)
}
}
func TestRemoteFilesDoRejectsReferenceMismatch(t *testing.T) {
files := NewRemoteFiles(nil, nil, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/one")
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, "https://files.example/two", nil)
if _, err := files.Do(req, file, http.DefaultClient); err == nil {
t.Fatal("mismatched request and validated file accepted")
}
}
func TestRemoteFilesRequirePortableURL(t *testing.T) {
files := NewRemoteFiles(testRemoteFilePolicy{managed: true}, nil, core.AsBot)
err := files.RequirePortableURL("--url-only")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Param != "--url-only" {
t.Fatalf("error = subtype %q param %q", validationErr.Subtype, validationErr.Param)
}
}
func TestCallAPIPreservesURLsInArbitraryJSON(t *testing.T) {
const rawURL = "https://objects.example/raw?signature=opaque"
apiClient, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
resp := jsonResponse(map[string]any{
"code": 0,
"data": map[string]any{
"download_url": rawURL,
"nested": []any{map[string]any{"url": rawURL}},
},
})
resp.Request = req
return resp, nil
}))
result, err := apiClient.CallAPI(context.Background(), RawApiRequest{
Method: http.MethodGet,
URL: "/open-apis/example/v1/raw",
As: core.AsBot,
})
if err != nil {
t.Fatal(err)
}
data := result.(map[string]any)["data"].(map[string]any)
if got := data["download_url"]; got != rawURL {
t.Fatalf("download_url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
nested := data["nested"].([]any)[0].(map[string]any)
if got := nested["url"]; got != rawURL {
t.Fatalf("nested url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
}

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

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/runtimeplan"
)
// Factory holds shared dependencies injected into every command.
@@ -44,8 +43,6 @@ type Factory struct {
Credential *credential.CredentialProvider
runtimePlan *runtimeplan.Plan
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
@@ -60,34 +57,6 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
return f.FileIOProvider.ResolveFileIO(ctx)
}
// NewRemoteFiles returns the invocation's file-transfer boundary without
// exposing credential source, mode, or edition to business shortcuts.
func (f *Factory) NewRemoteFiles(identity core.Identity) *client.RemoteFiles {
if f == nil {
return client.NewRemoteFiles(nil, nil, identity)
}
return client.NewRemoteFiles(runtimeplan.Ensure(f.runtimePlan), f.HttpClient, identity)
}
// RuntimeDescription returns sanitized diagnostics for the active plan.
func (f *Factory) RuntimeDescription() runtimeplan.Description {
if f == nil {
return runtimeplan.Description{}
}
return runtimeplan.Ensure(f.runtimePlan).Describe()
}
// RuntimeStartupError reports whether invocation bootstrap failed before an
// effective credential/runtime configuration could be selected. It exposes
// only the source-neutral plan contract so diagnostic commands do not need to
// know which edition or credential product produced the failure.
func (f *Factory) RuntimeStartupError() error {
if f == nil {
return nil
}
return runtimeplan.Ensure(f.runtimePlan).StartupError()
}
// ResolveAs returns the effective identity type.
// If the user explicitly passed --as, use that value; otherwise use the configured default.
// When the value is "auto" (or unset), auto-detect based on credential hints.
@@ -241,3 +210,26 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
Credential: f.Credential,
}, nil
}
// RequireBuiltinCredentialProvider returns a typed validation error when an
// extension provider is actively managing credentials. Intended for use as
// PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
// - No extension provider is active (built-in keychain/config path is used)
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
if f.Credential == nil {
return nil
}
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if provName == "" {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%q is not supported: credentials are provided externally and do not support interactive management", command).
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
}

View File

@@ -23,17 +23,11 @@ import (
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
"github.com/larksuite/cli/internal/runtimeplan"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
var (
initRegistryWithBrand = registry.InitWithBrand
initEmbeddedRegistryWithBrand = registry.InitEmbeddedWithBrand
)
// NewDefault creates a production Factory with cached closures.
// Initialization follows a credential-first order:
//
@@ -42,39 +36,19 @@ var (
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Preserve the established standalone Factory behavior. Product-specific
// runtime selection belongs to the CLI composition root, which calls
// NewDefaultWithRuntimePlan with one immutable startup snapshot.
core.SetCurrentWorkspace(core.DetectWorkspaceFromEnv(os.Getenv))
return newDefaultWithRuntimePlan(streams, inv, nil, runtimeplan.Default(), false)
}
// NewDefaultWithRuntimePlan creates a production Factory from the same
// immutable Profile snapshot and source-neutral plan used by startup routing.
func NewDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
) *Factory {
return newDefaultWithRuntimePlan(streams, inv, profileConfig, plan, true)
}
func newDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
useProfileSnapshot bool,
) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
runtimePlan: runtimeplan.Ensure(plan),
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
}
// Workspace detection: determines which config subtree to use.
// Must run before any config or credential load, since those paths are
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
keychain.RuntimeDirFunc = core.GetRuntimeDir
@@ -82,9 +56,6 @@ func newDefaultWithRuntimePlan(
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
if profileConfig != nil {
workspaceConfig = core.NewConfigSnapshotFrom(profileConfig)
}
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
@@ -92,13 +63,10 @@ func newDefaultWithRuntimePlan(
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
RuntimePlan: f.runtimePlan,
ProfileConfigSnapshot: profileConfig,
UseProfileSnapshot: useProfileSnapshot,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Runtime config contains resolved account data only.
@@ -108,13 +76,7 @@ func newDefaultWithRuntimePlan(
return nil, err
}
cfg := acct.ToCliConfig()
if f.runtimePlan.AllowsRemoteMetadata() {
initRegistryWithBrand(cfg.Brand)
} else {
// Defense in depth for callers that construct a Factory directly
// instead of going through cmd/build's composition root.
initEmbeddedRegistryWithBrand(cfg.Brand)
}
registry.InitWithBrand(cfg.Brand)
return cfg, nil
})
@@ -158,13 +120,6 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
var err error
rt, err = applyRuntimePlan(f, rt)
if err != nil {
return nil, err
}
// Risk control remains the final trusted header boundary before either
// the ordinary network transport or the managed proxy data plane.
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
@@ -195,14 +150,9 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
sdkBase, err = applyRuntimePlan(f, sdkBase)
if err != nil {
return nil, err
}
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection. A managed runtime applies its data-plane policy after this
// boundary so trusted signals remain associated with the original request.
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
@@ -220,51 +170,20 @@ func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
sdkTransport = wrapWithExtension(sdkTransport)
return sdkTransport
}
func applyRuntimePlan(f *Factory, base http.RoundTripper) (http.RoundTripper, error) {
if f == nil {
return base, nil
}
return runtimeplan.Ensure(f.runtimePlan).Wrap(base)
return wrapWithExtension(sdkTransport)
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
RuntimePlan *runtimeplan.Plan
ProfileConfigSnapshot *core.MultiAppConfig
UseProfileSnapshot bool
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
plan := runtimeplan.Ensure(deps.RuntimePlan)
providers := extcred.Providers()
localAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
if deps.UseProfileSnapshot {
localAcct = credential.NewDefaultAccountProviderFromSnapshot(deps.Keychain, deps.Profile, deps.ProfileConfigSnapshot)
}
localToken := credential.NewDefaultTokenProvider(localAcct, deps.HttpClient, deps.ErrOut)
var defaultAcct credential.DefaultAccountResolver = localAcct
var defaultToken credential.DefaultTokenResolver = localToken
if startupErr := plan.StartupError(); startupErr != nil {
providers = []extcred.Provider{&runtimePlanErrorProvider{err: startupErr}}
defaultAcct = nil
defaultToken = nil
} else if provider, replace := plan.CredentialProvider(); provider != nil {
if replace {
providers = []extcred.Provider{provider}
defaultAcct = nil
defaultToken = nil
} else {
providers = append([]extcred.Provider{provider}, providers...)
}
}
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
// happens before the command runs, so any plain-text warning written
// to stderr would break the JSON envelope contract that AI agents
@@ -273,15 +192,3 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}
type runtimePlanErrorProvider struct{ err error }
func (p *runtimePlanErrorProvider) Name() string { return "runtime-policy" }
func (p *runtimePlanErrorProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return nil, p.err
}
func (p *runtimePlanErrorProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, p.err
}

View File

@@ -6,7 +6,6 @@ package cmdutil
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
@@ -15,9 +14,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -217,63 +213,3 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
func TestRuntimePlanMetadataPolicyIsEnforcedByFactory(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
originalRemote := initRegistryWithBrand
originalEmbedded := initEmbeddedRegistryWithBrand
t.Cleanup(func() {
initRegistryWithBrand = originalRemote
initEmbeddedRegistryWithBrand = originalEmbedded
})
var remoteCalls, embeddedCalls int
initRegistryWithBrand = func(core.LarkBrand) { remoteCalls++ }
initEmbeddedRegistryWithBrand = func(core.LarkBrand) { embeddedCalls++ }
profile := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}}}
plan := runtimeplan.New(runtimeplan.Options{Metadata: runtimeplan.MetadataEmbeddedOnly})
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, profile, plan)
if _, err := f.Config(); err != nil {
t.Fatalf("Config() error = %v", err)
}
if remoteCalls != 0 || embeddedCalls != 1 {
t.Fatalf("registry init calls = remote:%d embedded:%d, want remote:0 embedded:1", remoteCalls, embeddedCalls)
}
}
func TestCLICompositionPreservesEnvironmentCredentialsWhenProfileIsUnreadable(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliExternalCredentialConfig,
filepath.Join(configDir, "missing-external-credential.json"))
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := vfs.MkdirAll(core.GetConfigPath(), 0o700); err != nil {
t.Fatal(err)
}
startup := runtimebootstrap.Resolve("")
if err := startup.Plan.StartupError(); err != nil {
t.Fatalf("runtime bootstrap changed legacy environment fallback: %v", err)
}
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, startup.ProfileConfig, startup.Plan)
account, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}

View File

@@ -1,261 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdutil
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"testing"
_ "github.com/larksuite/cli/extension/credential/env"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/externalcredential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/requestcontext"
"github.com/larksuite/cli/internal/riskcontrol"
"github.com/larksuite/cli/internal/runtimebootstrap"
internaltransport "github.com/larksuite/cli/internal/transport"
)
func newFactoryFromRuntimeBootstrap(streams *IOStreams, inv InvocationContext) *Factory {
startup := runtimebootstrap.Resolve(inv.Profile)
return NewDefaultWithRuntimePlan(streams, inv, startup.ProfileConfig, startup.Plan)
}
func writePlatformProxyConfiguration(t *testing.T, config *core.MultiAppConfig, appID string) {
t.Helper()
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
systemPath := configDir + "/external-credential.json"
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
system := externalcredential.Config{
Version: 1, Mode: externalcredential.ModePlatformProxy,
RemoteEndpoint: "https://credentials.example.com",
Applications: []externalcredential.Application{{Brand: core.BrandFeishu, AppID: appID}},
}
data, err := json.Marshal(system)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemPath, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestFactoryInstallsExternalProxyTransport(t *testing.T) {
t.Setenv(internaltransport.EnvNoProxy, "")
previousTransport := http.DefaultTransport
var observed *http.Request
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
observed = req.Clone(req.Context())
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "sandbox", AppId: "cli_test", Brand: core.BrandFeishu, Lang: i18n.LangJaJP, Users: []core.AppUser{},
}}}
writePlatformProxyConfiguration(t, config, "cli_test")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
resolved, err := factory.Config()
if err != nil {
t.Fatal(err)
}
if resolved.Lang != i18n.LangJaJP {
t.Fatalf("resolved Lang = %q, want Profile Lang %q", resolved.Lang, i18n.LangJaJP)
}
client, err := factory.HttpClient()
if err != nil {
t.Fatal(err)
}
ctx := requestcontext.WithIdentity(context.Background(), core.AsUser)
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://open.feishu.cn/open-apis/test/v1/ping", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if observed == nil ||
observed.URL.String() != "https://credentials.example.com/lark-cli/v1/openapi/open-apis/test/v1/ping" ||
observed.Header.Get(externalcredential.HeaderAppID) != "cli_test" ||
observed.Header.Get(externalcredential.HeaderIdentity) != "user" ||
observed.Header.Get(riskcontrol.HeaderOSType) == "" {
t.Fatalf("managed data-plane request = %#v", observed)
}
}
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsMalformed(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := os.WriteFile(core.GetConfigPath(), []byte(`{"apps":[`), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsUnreadable(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := os.Mkdir(core.GetConfigPath(), 0700); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}
func TestFactoryRejectsMisspelledExternalCredentialInsteadOfFallingBack(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredentials":{"mode":"proxy"}}]}`
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected misspelled external credential profile to fail closed")
}
}
func TestFactoryRejectsNullExternalCredentialInsteadOfFallingBack(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredential":null}]}`
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected null external credential profile to fail closed")
}
}
func TestFactoryRejectsMissingSelectedExternalCredentialProfile(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
config := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "sandbox", AppId: "cli_external", Brand: core.BrandFeishu, Users: []core.AppUser{},
}},
}
writePlatformProxyConfiguration(t, config, "cli_external")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected missing selected external credential profile to fail closed")
}
}
func TestFactoryUsesOneProfileSnapshotForCredentialAndTransport(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
systemPath := configDir + "/external-credential.json"
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
for _, name := range []string{
envvars.CliAppID, envvars.CliAppSecret, envvars.CliUserAccessToken,
envvars.CliTenantAccessToken, envvars.CliAuthProxy, envvars.CliProxyKey,
envvars.CliProxyEnable, envvars.CliProxyAddress, envvars.CliCAPath,
} {
t.Setenv(name, "")
}
core.SetCurrentWorkspace(core.WorkspaceLocal)
initial := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "initial", AppId: "cli_initial", AppSecret: core.PlainSecret("initial-secret"),
Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
if err := core.SaveMultiAppConfig(initial); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
replacement := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "replacement", AppId: "cli_proxy", Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
if err := core.SaveMultiAppConfig(replacement); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemPath, []byte(`{"version":1,"mode":"platform_proxy","remoteEndpoint":"https://credentials.example.com","applications":[{"brand":"feishu","appId":"cli_proxy"}]}`), 0o600); err != nil {
t.Fatal(err)
}
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if account.AppID != "cli_initial" {
t.Fatalf("account AppID = %q, want immutable snapshot cli_initial", account.AppID)
}
client, err := factory.HttpClient()
if err != nil {
t.Fatal(err)
}
if _, ok := client.Transport.(*externalcredential.Transport); ok {
t.Fatalf("transport = %T, should match initial non-proxy profile", client.Transport)
}
}
func TestFactoryRejectsProxyModeWithTransportExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
writePlatformProxyConfiguration(t, config, "cli_test")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.HttpClient(); err == nil {
t.Fatal("expected proxy mode and transport extension to be rejected")
}
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimeplan"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
@@ -414,13 +413,13 @@ func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (
return nil, nil
}
func TestRequireRuntimeCapabilities_BlocksProviderOwnedCredentials(t *testing.T) {
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -440,44 +439,25 @@ func TestRequireRuntimeCapabilities_BlocksProviderOwnedCredentials(t *testing.T)
}
}
func TestRequireRuntimeCapabilities_DefaultCommandLabel(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireRuntimeCapabilities(context.Background(), "", runtimeplan.CapabilityLocalCredentialManagement)
if err == nil {
t.Fatal("expected error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed error", err)
}
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestRequireRuntimeCapabilities_AllowsLocalCredentialProvider(t *testing.T) {
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
// No extension providers → built-in path → no error
f, _, _, _ := TestFactory(t, nil)
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRequireRuntimeCapabilities_AllowsNilCredential(t *testing.T) {
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error with nil Credential: %v", err)
}
}
func TestRequireRuntimeCapabilities_PropagatesProviderError(t *testing.T) {
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
@@ -485,7 +465,7 @@ func TestRequireRuntimeCapabilities_PropagatesProviderError(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if !errors.Is(err, sentinel) {
t.Fatalf("error = %v, want sentinel", err)
}

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

@@ -1,112 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/runtimeplan"
)
const (
runtimeCapabilitiesAnnotation = "lark:runtimeCapabilities"
noRuntimeCapabilities = "-"
)
// SetRuntimeCapabilities declares the runtime surfaces required by a command.
// A zero-capability declaration explicitly overrides a parent default.
func SetRuntimeCapabilities(cmd *cobra.Command, capabilities ...runtimeplan.Capability) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
if len(capabilities) == 0 {
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
return
}
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if capability != "" {
values = append(values, string(capability))
}
}
if len(values) == 0 {
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
return
}
cmd.Annotations[runtimeCapabilitiesAnnotation] = strings.Join(values, ",")
}
// GetRuntimeCapabilities resolves the nearest explicit declaration from the
// leaf command toward its parents.
func GetRuntimeCapabilities(cmd *cobra.Command) []runtimeplan.Capability {
for current := cmd; current != nil; current = current.Parent() {
raw, ok := current.Annotations[runtimeCapabilitiesAnnotation]
if !ok {
continue
}
if raw == "" || raw == noRuntimeCapabilities {
return nil
}
parts := strings.Split(raw, ",")
out := make([]runtimeplan.Capability, 0, len(parts))
for _, part := range parts {
if capability := strings.TrimSpace(part); capability != "" {
out = append(out, runtimeplan.Capability(capability))
}
}
return out
}
return nil
}
// RequireRuntimeCapabilities applies the invocation plan and the selected
// credential source to a command's source-neutral capability declaration.
func (f *Factory) RequireRuntimeCapabilities(
ctx context.Context,
command string,
capabilities ...runtimeplan.Capability,
) error {
if f == nil {
return nil
}
plan := runtimeplan.Ensure(f.runtimePlan)
for _, capability := range capabilities {
if err := plan.Require(capability); err != nil {
return err
}
if capability != runtimeplan.CapabilityLocalCredentialManagement || f.Credential == nil {
continue
}
providerName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if providerName == "" {
continue
}
if command == "" {
command = "credential management"
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%q cannot manage credentials owned by provider %q", command, providerName).
WithHint("manage authorization through the active credential provider, or use a local Profile credential source")
}
return nil
}
// RequireCommandRuntimeCapabilities checks the declaration resolved for cmd.
func (f *Factory) RequireCommandRuntimeCapabilities(ctx context.Context, cmd *cobra.Command) error {
command := "credential management"
if cmd != nil {
command = cmd.CommandPath()
}
return f.RequireRuntimeCapabilities(ctx, command, GetRuntimeCapabilities(cmd)...)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/spf13/cobra"
)
func TestRuntimeCapabilitiesUseNearestDeclaration(t *testing.T) {
parent := &cobra.Command{Use: "auth"}
SetRuntimeCapabilities(parent, runtimeplan.CapabilityLocalCredentialManagement)
inherited := &cobra.Command{Use: "login"}
parent.AddCommand(inherited)
got := GetRuntimeCapabilities(inherited)
if len(got) != 1 || got[0] != runtimeplan.CapabilityLocalCredentialManagement {
t.Fatalf("inherited capabilities = %v", got)
}
diagnostic := &cobra.Command{Use: "status"}
SetRuntimeCapabilities(diagnostic)
parent.AddCommand(diagnostic)
if got := GetRuntimeCapabilities(diagnostic); len(got) != 0 {
t.Fatalf("explicit empty capabilities = %v", got)
}
}
func TestRequireRuntimeCapabilitiesUsesPlan(t *testing.T) {
denied := errors.New("events denied")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityRealtimeEvents {
return denied
}
return nil
},
})
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil, plan)
err := f.RequireRuntimeCapabilities(context.Background(), "event consume", runtimeplan.CapabilityRealtimeEvents)
if !errors.Is(err, denied) {
t.Fatalf("error = %v, want plan denial", err)
}
}
func TestRequireRuntimeCapabilitiesKeepsPurelyLocalCommandsAvailable(t *testing.T) {
startupErr := errors.New("managed runtime bootstrap failed")
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil,
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly))
if err := f.RequireRuntimeCapabilities(context.Background(), "local recovery"); err != nil {
t.Fatalf("capability-free local command = %v, want available", err)
}
}
func TestRequireRuntimeCapabilitiesBlocksProviderOwnedCredentials(t *testing.T) {
provider := &runtimeCapabilityProvider{}
f, _, _, _ := TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{provider}, nil, nil, nil,
)
err := f.RequireRuntimeCapabilities(
context.Background(),
"auth login",
runtimeplan.CapabilityLocalCredentialManagement,
)
if err == nil {
t.Fatal("expected provider-owned credentials to reject local management")
}
if err := f.RequireRuntimeCapabilities(
context.Background(),
"profile use",
runtimeplan.CapabilityLocalProfileMutation,
); err != nil {
t.Fatalf("generic provider unexpectedly blocked Standard Profile mutation: %v", err)
}
}
type runtimeCapabilityProvider struct{}
func (*runtimeCapabilityProvider) Name() string { return "test-provider" }
func (*runtimeCapabilityProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "cli_test"}, nil
}
func (*runtimeCapabilityProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
@@ -77,29 +76,6 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
return f, stdoutBuf, stderrBuf, reg
}
// TestFactoryWithRuntimePlan creates a TestFactory with an explicit
// source-neutral runtime policy.
func TestFactoryWithRuntimePlan(
t *testing.T,
config *core.CliConfig,
plan *runtimeplan.Plan,
) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
f, out, errOut, registry := TestFactory(t, config)
f.runtimePlan = runtimeplan.Ensure(plan)
return f, out, errOut, registry
}
// TestSetRuntimePlan replaces a Factory's runtime policy in tests that need to
// preserve an existing HTTP mock registry or other custom wiring.
func TestSetRuntimePlan(t *testing.T, f *Factory, plan *runtimeplan.Plan) {
t.Helper()
if f == nil {
t.Fatal("cannot install a runtime plan on a nil Factory")
}
f.runtimePlan = runtimeplan.Ensure(plan)
}
type testDefaultAcct struct {
config *core.CliConfig
}

View File

@@ -21,14 +21,6 @@ func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
// NewConfigSnapshotFrom creates a snapshot from configuration that was
// already captured by another invocation-start boundary.
func NewConfigSnapshotFrom(config *MultiAppConfig) *ConfigSnapshot {
return newConfigSnapshot(func() (*MultiAppConfig, error) {
return config, nil
})
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}

View File

@@ -38,19 +38,6 @@ func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
}
}
func TestNewConfigSnapshotFromPreservesCapturedConfig(t *testing.T) {
want := &MultiAppConfig{CurrentApp: "captured"}
snapshot := NewConfigSnapshotFrom(want)
got, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if got != want || got.CurrentApp != "captured" {
t.Fatalf("MultiAppConfig() = %#v, want captured pointer %#v", got, want)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")

View File

@@ -14,7 +14,6 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
// DefaultAccountResolver is implemented by the default account provider.
@@ -27,34 +26,6 @@ type DefaultTokenResolver interface {
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
}
type userInfoEnrichmentSkipper interface {
SkipUserInfoEnrichment() bool
}
// ProviderCapabilities describes source-neutral credential behavior used by
// inspection and account resolution. It carries no provider configuration.
type ProviderCapabilities struct {
SkipUserInfoEnrichment bool
ProvidesOnDemandAuth bool
CanInspectScopes bool
}
type providerCapabilitiesSource interface {
CredentialCapabilities() ProviderCapabilities
}
// ProviderAccountMetadata carries source-neutral Profile preferences that are
// not part of the public credential extension account contract. Internal
// runtime providers can expose them without coupling core configuration to a
// concrete credential product.
type ProviderAccountMetadata struct {
Lang i18n.Lang
}
type providerAccountMetadataSource interface {
CredentialAccountMetadata() ProviderAccountMetadata
}
var (
getStoredToken = auth.GetStoredToken
getStoredTokenStatus = auth.TokenStatus
@@ -165,12 +136,10 @@ type CredentialProvider struct {
httpClient func() (*http.Client, error)
warnOut io.Writer
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
selectedProvider extcred.Provider
selectedCaps ProviderCapabilities
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
hintOnce sync.Once
hint *IdentityHint
@@ -207,61 +176,36 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
// A provider error stops the chain, so remember that source as the
// immutable selection even though account resolution failed.
p.selectedSource = extensionTokenSource{provider: prov}
p.selectedProvider = prov
p.selectedCaps = credentialProviderCapabilities(prov)
return nil, err
}
if acct != nil {
internal := convertAccount(acct)
if source, ok := prov.(providerAccountMetadataSource); ok {
metadata := source.CredentialAccountMetadata()
internal.Lang = metadata.Lang
}
source := extensionTokenSource{provider: prov}
capabilities := credentialProviderCapabilities(prov)
skipEnrichment := capabilities.SkipUserInfoEnrichment
if skipper, ok := prov.(userInfoEnrichmentSkipper); ok {
skipEnrichment = skipper.SkipUserInfoEnrichment()
}
if !skipEnrichment {
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
}
p.selectedSource = source
p.selectedProvider = prov
p.selectedCaps = capabilities
return internal, nil
}
}
if p.defaultAcct != nil {
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
return nil, core.NotConfiguredError()
}
func credentialProviderCapabilities(provider extcred.Provider) ProviderCapabilities {
if source, ok := provider.(providerCapabilitiesSource); ok {
return source.CredentialCapabilities()
}
return ProviderCapabilities{CanInspectScopes: true}
}
// enrichUserInfo resolves user identity when extension provides a UAT.
// If UAT is available, user_info API call is mandatory (security: verify token validity).
// If no UAT from extension, falls back to provider-supplied OpenID.

View File

@@ -120,7 +120,7 @@ func TestCredentialProvider_TokenFromExtension(t *testing.T) {
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Scopes: "im:message", Source: "env"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
@@ -128,28 +128,8 @@ func TestCredentialProvider_TokenFromExtension(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" || result.Scopes != "im:message" {
t.Errorf("extension token = %#v, want token and scopes preserved", result)
}
}
func TestCredentialProvider_ResolveTokenTreatsEmptyExtensionTokenAsMalformed(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{},
}},
nil, nil, nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
var malformedErr *MalformedTokenResultError
if !errors.As(err, &malformedErr) {
t.Fatalf("ResolveToken() error = %T %v, want *MalformedTokenResultError", err, err)
}
if malformedErr.Source != "env" || malformedErr.Type != TokenTypeUAT || malformedErr.Reason != "empty token" {
t.Fatalf("malformed token error = %+v, want env/uat/empty token", malformedErr)
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
}
}

View File

@@ -61,21 +61,8 @@ func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) e
// DefaultAccountProvider resolves account from config.json via keychain.
type DefaultAccountProvider struct {
keychain func() keychain.KeychainAccess
profile string
snapshot *core.MultiAppConfig
useSnapshot bool
}
// NewDefaultAccountProviderFromSnapshot creates the production provider for a
// Factory. A nil snapshot means no usable config existed when the Factory was
// created; the provider does not re-read a file that may have changed since
// transport wiring was selected.
func NewDefaultAccountProviderFromSnapshot(kc func() keychain.KeychainAccess, profile string, snapshot *core.MultiAppConfig) *DefaultAccountProvider {
provider := NewDefaultAccountProvider(kc, profile)
provider.snapshot = snapshot
provider.useSnapshot = true
return provider
keychain func() keychain.KeychainAccess
profile string
}
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
@@ -86,16 +73,9 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string
}
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
// Use one config for both credentials and strict mode.
multi := p.snapshot
if !p.useSnapshot {
var err error
multi, err = core.LoadMultiAppConfig()
if err != nil {
return nil, core.NotConfiguredError()
}
}
if multi == nil {
// Load config once — used for both credentials and strict mode.
multi, err := core.LoadMultiAppConfig()
if err != nil {
return nil, core.NotConfiguredError()
}

View File

@@ -1,271 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"strings"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
)
// ScopeState describes whether a credential source can authoritatively report
// the scopes granted to a token. It deliberately distinguishes an omitted
// answer from a source that does not support scope inspection.
type ScopeState string
const (
ScopeKnown ScopeState = "known"
ScopeUnknown ScopeState = "unknown"
ScopeUnsupported ScopeState = "unsupported"
)
// TokenInspectionStatus is the local, non-secret state of a token or identity.
type TokenInspectionStatus string
const (
TokenInspectionReady TokenInspectionStatus = "ready"
TokenInspectionNeedsRefresh TokenInspectionStatus = "needs_refresh"
TokenInspectionExpired TokenInspectionStatus = "expired"
TokenInspectionMissing TokenInspectionStatus = "missing"
TokenInspectionNotLoggedIn TokenInspectionStatus = "not_logged_in"
TokenInspectionNotSupported TokenInspectionStatus = "not_supported"
TokenInspectionAvailableLive TokenInspectionStatus = "available_on_demand"
)
// SourceInspection is a sanitized description of the selected credential
// source. It never contains an app secret or any resolved credential value.
type SourceInspection struct {
Name string
Managed bool
AppID string
Brand core.LarkBrand
DefaultAs core.Identity
ProfileName string
UserOpenID string
UserName string
SupportedIdentities uint8
ProvidesOnDemandAuth bool
CanInspectScopes bool
}
// TokenInspectionRequest controls a non-secret token inspection.
type TokenInspectionRequest struct {
TokenSpec
IncludeScopes bool
}
// TokenInspection contains only diagnostic metadata. In particular, it has no
// field capable of carrying the resolved credential value.
type TokenInspection struct {
Source SourceInspection
Status TokenInspectionStatus
Present bool
ScopeState ScopeState
Scopes string
ExpiresAtMillis int64
RefreshExpiresAtMillis int64
GrantedAtMillis int64
}
// InspectSource returns a sanitized view of the selected credential source.
// Extension detection remains encapsulated here so commands do not need to
// know whether credentials came from env, a helper, or the built-in keychain.
func (p *CredentialProvider) InspectSource(ctx context.Context) (*SourceInspection, error) {
if p == nil {
return &SourceInspection{Name: "default"}, nil
}
acct, err := p.ResolveAccount(ctx)
info := &SourceInspection{Name: "default"}
if p.selectedProvider != nil {
info.Managed = true
info.Name = p.selectedProvider.Name()
if info.Name == "" {
info.Name = "external"
}
}
if err != nil {
// Source inspection must not replace the established error path for the
// built-in config/keychain source. Commands still load their normal
// config after this call and report the same command-specific error as
// before the inspection boundary existed. A selected managed provider,
// however, owns credential resolution, so its failure remains fail
// closed and must be surfaced here.
if info.Managed {
return info, err
}
return info, nil
}
if acct == nil {
return info, nil
}
fillSourceInspection(info, acct, p.selectedCaps)
return info, nil
}
// InspectToken reports token availability and metadata without returning the
// credential value. Scope resolution is opt-in because managed sources may
// need to perform work to obtain authoritative scope metadata.
func (p *CredentialProvider) InspectToken(ctx context.Context, req TokenInspectionRequest) (*TokenInspection, error) {
acct, err := p.ResolveAccount(ctx)
if err != nil {
return nil, err
}
source, err := p.selectedCredentialSource(ctx)
if err != nil {
return nil, err
}
info := SourceInspection{Name: "default"}
if source != nil {
info.Name = source.Name()
}
if p.selectedProvider != nil {
info.Managed = true
if info.Name == "" {
info.Name = "external"
}
}
if acct != nil {
fillSourceInspection(&info, acct, p.selectedCaps)
}
result := &TokenInspection{
Source: info,
Status: TokenInspectionMissing,
ScopeState: ScopeUnknown,
}
if acct == nil || source == nil {
return result, nil
}
if info.Managed {
return inspectManagedToken(ctx, source, acct, req, result)
}
return inspectDefaultToken(acct, req.TokenSpec, result), nil
}
func fillSourceInspection(info *SourceInspection, acct *Account, capabilities ProviderCapabilities) {
info.AppID = acct.AppID
info.Brand = acct.Brand
info.DefaultAs = acct.DefaultAs
info.ProfileName = acct.ProfileName
info.UserOpenID = acct.UserOpenId
info.UserName = acct.UserName
info.SupportedIdentities = acct.SupportedIdentities
info.ProvidesOnDemandAuth = capabilities.ProvidesOnDemandAuth
info.CanInspectScopes = capabilities.CanInspectScopes
}
func inspectDefaultToken(acct *Account, spec TokenSpec, result *TokenInspection) *TokenInspection {
switch spec.Type {
case TokenTypeTAT:
ids := extcred.IdentitySupport(acct.SupportedIdentities)
if ids.UserOnly() {
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result
}
if acct.SupportedIdentities == 0 && !HasRealAppSecret(acct.AppSecret) {
result.Status = TokenInspectionMissing
result.ScopeState = ScopeUnsupported
return result
}
result.Status = TokenInspectionReady
result.Present = true
result.ScopeState = ScopeUnsupported
return result
case TokenTypeUAT:
if acct.UserOpenId == "" {
result.Status = TokenInspectionNotLoggedIn
return result
}
stored := getStoredToken(acct.AppID, acct.UserOpenId)
if stored == nil {
result.Status = TokenInspectionMissing
return result
}
result.Present = true
result.ScopeState = ScopeKnown
result.Scopes = stored.Scope
result.ExpiresAtMillis = stored.ExpiresAt
result.RefreshExpiresAtMillis = stored.RefreshExpiresAt
result.GrantedAtMillis = stored.GrantedAt
switch getStoredTokenStatus(stored) {
case "valid":
result.Status = TokenInspectionReady
case "needs_refresh":
result.Status = TokenInspectionNeedsRefresh
default:
result.Status = TokenInspectionExpired
}
return result
default:
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result
}
}
func inspectManagedToken(
ctx context.Context,
source credentialSource,
acct *Account,
req TokenInspectionRequest,
result *TokenInspection,
) (*TokenInspection, error) {
if req.Type != TokenTypeUAT && req.Type != TokenTypeTAT {
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result, nil
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
if (req.Type == TokenTypeUAT && ids.BotOnly()) || (req.Type == TokenTypeTAT && ids.UserOnly()) {
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result, nil
}
if result.Source.ProvidesOnDemandAuth {
result.Status = TokenInspectionAvailableLive
result.Present = true
}
if req.Type == TokenTypeTAT && !result.Source.ProvidesOnDemandAuth {
result.Status = TokenInspectionReady
result.Present = true
result.ScopeState = ScopeUnsupported
}
if req.Type == TokenTypeUAT && !result.Source.ProvidesOnDemandAuth && acct.UserOpenId != "" {
result.Status = TokenInspectionReady
result.Present = true
}
if !req.IncludeScopes {
return result, nil
}
if !result.Source.CanInspectScopes {
result.ScopeState = ScopeUnsupported
return result, nil
}
token, found, err := source.TryResolveToken(ctx, req.TokenSpec)
if err != nil {
return nil, err
}
if !found {
result.Status = TokenInspectionMissing
result.Present = false
return result, nil
}
result.Status = TokenInspectionReady
result.Present = true
if strings.TrimSpace(token.Scopes) == "" {
result.ScopeState = ScopeUnknown
return result, nil
}
result.ScopeState = ScopeKnown
result.Scopes = token.Scopes
return result, nil
}

View File

@@ -1,243 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"errors"
"fmt"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/vfs"
)
type inspectionExtProvider struct {
capabilities ProviderCapabilities
token *extcred.Token
accountErr error
accountCalls int
tokenCalls int
}
func (p *inspectionExtProvider) Name() string { return "inspection-test" }
func (p *inspectionExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
p.accountCalls++
if p.accountErr != nil {
return nil, p.accountErr
}
return &extcred.Account{
AppID: "cli_test",
Brand: extcred.BrandFeishu,
SupportedIdentities: extcred.SupportsAll,
}, nil
}
func (p *inspectionExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
p.tokenCalls++
return p.token, nil
}
func (p *inspectionExtProvider) CredentialCapabilities() ProviderCapabilities {
return p.capabilities
}
func TestInspectSourceUsesSingleCachedProviderSelection(t *testing.T) {
ext := &inspectionExtProvider{}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
first, err := provider.InspectSource(context.Background())
if err != nil {
t.Fatalf("first InspectSource() error = %v", err)
}
second, err := provider.InspectSource(context.Background())
if err != nil {
t.Fatalf("second InspectSource() error = %v", err)
}
if ext.accountCalls != 1 {
t.Fatalf("ResolveAccount() calls = %d, want one immutable selection", ext.accountCalls)
}
if !first.Managed || first.Name != "inspection-test" || first.AppID != "cli_test" {
t.Fatalf("first inspection = %#v", first)
}
if *first != *second {
t.Fatalf("source inspection changed: first=%#v second=%#v", first, second)
}
}
func TestInspectSourcePreservesDefaultCommandErrorPath(t *testing.T) {
resolveErr := errors.New("default account is not configured")
provider := NewCredentialProvider(nil, &mockDefaultAcct{err: resolveErr}, nil, nil)
got, err := provider.InspectSource(context.Background())
if err != nil {
t.Fatalf("InspectSource() error = %v, want default resolution deferred to the command", err)
}
if got == nil || got.Managed || got.Name != "default" {
t.Fatalf("InspectSource() = %#v, want unmanaged default source", got)
}
}
func TestInspectSourceManagedFailureRemainsFailClosed(t *testing.T) {
resolveErr := errors.New("managed provider unavailable")
ext := &inspectionExtProvider{accountErr: resolveErr}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
got, err := provider.InspectSource(context.Background())
if !errors.Is(err, resolveErr) {
t.Fatalf("InspectSource() error = %v, want %v", err, resolveErr)
}
if got == nil || !got.Managed || got.Name != "inspection-test" {
t.Fatalf("InspectSource() = %#v, want selected managed source", got)
}
}
func TestInspectToken_DefaultPreservesStoredScopeMetadataWithoutCredentialValue(t *testing.T) {
originalGet := getStoredToken
originalStatus := getStoredTokenStatus
t.Cleanup(func() {
getStoredToken = originalGet
getStoredTokenStatus = originalStatus
})
getStoredToken = func(appID, openID string) *auth.StoredUAToken {
return &auth.StoredUAToken{
AppId: appID,
UserOpenId: openID,
AccessToken: "must-not-leak",
RefreshToken: "must-not-leak-refresh",
Scope: "im:message docx:document",
ExpiresAt: 11,
RefreshExpiresAt: 22,
GrantedAt: 33,
}
}
getStoredTokenStatus = func(*auth.StoredUAToken) string { return "valid" }
provider := NewCredentialProvider(nil, &mockDefaultAcct{account: &Account{
AppID: "cli_test", UserOpenId: "ou_test",
}}, &mockDefaultToken{}, nil)
got, err := provider.InspectToken(context.Background(), TokenInspectionRequest{
TokenSpec: TokenSpec{Type: TokenTypeUAT, AppID: "cli_test"},
})
if err != nil {
t.Fatalf("InspectToken() error = %v", err)
}
if !got.Present || got.Status != TokenInspectionReady || got.ScopeState != ScopeKnown {
t.Fatalf("inspection = %#v", got)
}
if got.Scopes != "im:message docx:document" || got.ExpiresAtMillis != 11 ||
got.RefreshExpiresAtMillis != 22 || got.GrantedAtMillis != 33 {
t.Fatalf("metadata = %#v", got)
}
if rendered := fmt.Sprintf("%+v", got); strings.Contains(rendered, "must-not-leak") {
t.Fatalf("inspection leaked credential value: %s", rendered)
}
}
func TestTokenInspectionHasNoCredentialValueField(t *testing.T) {
typ := reflect.TypeOf(TokenInspection{})
for i := 0; i < typ.NumField(); i++ {
name := strings.ToLower(typ.Field(i).Name)
for _, forbidden := range []string{"token", "secret", "credential", "value"} {
if strings.Contains(name, forbidden) {
t.Fatalf("TokenInspection field %q could carry a credential value", typ.Field(i).Name)
}
}
}
}
func TestInspectToken_UninspectableManagedScopesDoNotResolveToken(t *testing.T) {
ext := &inspectionExtProvider{
capabilities: ProviderCapabilities{ProvidesOnDemandAuth: true, CanInspectScopes: false},
token: &extcred.Token{Value: "opaque-placeholder", Scopes: "must:not:be:used"},
}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
got, err := provider.InspectToken(context.Background(), TokenInspectionRequest{
TokenSpec: TokenSpec{Type: TokenTypeUAT, AppID: "cli_test"},
IncludeScopes: true,
})
if err != nil {
t.Fatalf("InspectToken() error = %v", err)
}
if got.ScopeState != ScopeUnsupported || !got.Present || got.Status != TokenInspectionAvailableLive {
t.Fatalf("inspection = %#v", got)
}
if ext.tokenCalls != 0 {
t.Fatalf("ResolveToken() calls = %d, want 0", ext.tokenCalls)
}
}
func TestInspectToken_InspectableManagedSourceDistinguishesScopeState(t *testing.T) {
for _, test := range []struct {
name string
scopes string
wantState ScopeState
}{
{name: "known", scopes: "im:message", wantState: ScopeKnown},
{name: "unknown", scopes: "", wantState: ScopeUnknown},
{name: "blank is unknown", scopes: " ", wantState: ScopeUnknown},
} {
t.Run(test.name, func(t *testing.T) {
ext := &inspectionExtProvider{
capabilities: ProviderCapabilities{ProvidesOnDemandAuth: true, CanInspectScopes: true},
token: &extcred.Token{Value: "external-secret-token", Scopes: test.scopes},
}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
got, err := provider.InspectToken(context.Background(), TokenInspectionRequest{
TokenSpec: TokenSpec{Type: TokenTypeUAT, AppID: "cli_test"},
IncludeScopes: true,
})
if err != nil {
t.Fatalf("InspectToken() error = %v", err)
}
if got.ScopeState != test.wantState || !got.Present {
t.Fatalf("inspection = %#v, want scope state %q", got, test.wantState)
}
if rendered := fmt.Sprintf("%+v", got); strings.Contains(rendered, "external-secret-token") {
t.Fatalf("inspection leaked credential value: %s", rendered)
}
})
}
}
func TestDiagnosticCommandsUseCredentialInspectionBoundary(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller() could not locate test source")
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
files := []string{
"cmd/auth/check.go",
"cmd/auth/status.go",
"cmd/config/show.go",
"cmd/doctor/doctor.go",
"internal/identitydiag/diagnostics.go",
}
forbidden := []string{
"ActiveExtensionProviderName(",
"GetStoredToken(",
"TokenStatus(",
"internal/keychain",
"internal/externalcredential",
"os.Getenv(",
".ExternalCredential.Mode",
"ExternalCredential != nil",
"ExternalCredential == nil",
}
for _, relative := range files {
data, err := vfs.ReadFile(filepath.Join(repoRoot, relative))
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", relative, err)
}
for _, token := range forbidden {
if strings.Contains(string(data), token) {
t.Errorf("%s bypasses credential inspection with %q", relative, token)
}
}
}
}

View File

@@ -12,10 +12,6 @@ const (
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Developer-only path override used by both editions when detecting the
// system-managed external credential configuration.
CliExternalCredentialConfig = "LARKSUITE_CLI_EXTERNAL_CREDENTIAL_CONFIG"
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar

View File

@@ -107,22 +107,21 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
base.Hint = detailHint
}
var classified error
switch meta.Category {
case errs.CategoryAuthorization:
classified = buildPermissionError(base, resp, cc)
return buildPermissionError(base, resp, cc)
case errs.CategoryAuthentication:
classified = &errs.AuthenticationError{Problem: base}
return &errs.AuthenticationError{Problem: base}
case errs.CategoryConfig:
classified = buildConfigError(base)
return buildConfigError(base)
case errs.CategoryPolicy:
classified = buildSecurityPolicyError(base, resp)
return buildSecurityPolicyError(base, resp)
case errs.CategoryValidation:
classified = &errs.ValidationError{Problem: base}
return &errs.ValidationError{Problem: base}
case errs.CategoryNetwork:
classified = &errs.NetworkError{Problem: base}
return &errs.NetworkError{Problem: base}
case errs.CategoryInternal:
classified = &errs.InternalError{Problem: base}
return &errs.InternalError{Problem: base}
case errs.CategoryConfirmation:
// Risk + Action are non-omitempty wire fields. Derive from
// CodeMeta when available; otherwise emit RiskUnknown +
@@ -138,7 +137,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
if action == "" {
action = "unknown"
}
classified = &errs.ConfirmationRequiredError{
return &errs.ConfirmationRequiredError{
Problem: base,
Risk: risk,
Action: action,
@@ -149,24 +148,20 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
classified = &errs.APIError{Problem: base}
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
// instead of emitting an empty Problem on the wire.
return errs.WithDiagnosticMetadata(
&errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeSDKError,
Code: base.Code,
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
LogID: base.LogID,
},
return &errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeSDKError,
Code: base.Code,
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
LogID: base.LogID,
},
errs.DiagnosticMetadata{Origin: larkErrorOrigin()},
)
}
}
return errs.WithDiagnosticMetadata(classified, errs.DiagnosticMetadata{Origin: larkErrorOrigin()})
}
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API

View File

@@ -108,10 +108,6 @@ func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) {
if ie.Code != stubCode {
t.Errorf("Code = %d, want %d (raw Lark code should propagate)", ie.Code, stubCode)
}
metadata, _ := errs.DiagnosticMetadataOf(err)
if metadata.Origin != larkErrorOrigin() {
t.Errorf("Origin = %q, want %q", metadata.Origin, larkErrorOrigin())
}
}
// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a

View File

@@ -11,7 +11,6 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
)
@@ -60,22 +59,6 @@ func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
}
}
func TestBuildAPIErrorMarksLarkOrigin(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": 99991663,
"msg": "token invalid",
}, errclass.ClassifyContext{})
problem, ok := errs.ProblemOf(err)
wantOrigin := ""
if build.Edition == "extended" {
wantOrigin = "lark"
}
metadata, _ := errs.DiagnosticMetadataOf(err)
if !ok || metadata.Origin != wantOrigin {
t.Fatalf("problem = %#v, metadata = %#v, ok = %v, want origin %q", problem, metadata, ok, wantOrigin)
}
}
// matchesTypedError reports whether err is the typed-error variant identified by
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
@@ -107,30 +90,14 @@ func matchesTypedError(err error, wantTyped string) bool {
var x *errs.SecurityPolicyError
return errors.As(err, &x)
case "APIError":
var x *errs.APIError
return errors.As(err, &x)
// APIError is the default fallback; use a direct type assertion to avoid
// matching against typed subclasses that also satisfy IsAPI.
_, ok := err.(*errs.APIError)
return ok
}
return false
}
func requirePermissionError(t *testing.T, err error) *errs.PermissionError {
t.Helper()
var permission *errs.PermissionError
if !errors.As(err, &permission) {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
return permission
}
func requireSecurityPolicyError(t *testing.T, err error) *errs.SecurityPolicyError {
t.Helper()
var policy *errs.SecurityPolicyError
if !errors.As(err, &policy) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T", err)
}
return policy
}
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
cases := []struct {
name string
@@ -452,7 +419,10 @@ func TestRetryableEnvelope_TrueOnly(t *testing.T) {
func TestConsoleURL_FeishuBrand(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe := requirePermissionError(t, err)
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
}
@@ -461,7 +431,10 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
func TestConsoleURL_LarkBrand(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"})
pe := requirePermissionError(t, err)
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
}
@@ -470,7 +443,7 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
func TestConsoleURL_EmptyAppID(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if pe.ConsoleURL != "" {
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
}
@@ -486,13 +459,13 @@ func TestConsoleURL_EmptyAppID(t *testing.T) {
func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) {
cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}
bot := requirePermissionError(t, errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc))
bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError)
if bot.ConsoleURL == "" {
t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty")
}
user := requirePermissionError(t, errclass.BuildAPIError(missingScopeResp("docx:document"),
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}))
user := errclass.BuildAPIError(missingScopeResp("docx:document"),
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError)
if user.ConsoleURL != "" {
t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.ConsoleURL)
}
@@ -565,7 +538,7 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
func TestPermissionError_DefaultIdentity(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if pe.Identity != "user" {
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
}
@@ -577,7 +550,7 @@ func TestPermissionError_NoViolations(t *testing.T) {
// SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides.
resp := map[string]any{"code": 99991672, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
@@ -600,7 +573,7 @@ func TestExtractMissingScopes_Dedup(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if got, want := len(pe.MissingScopes), 2; got != want {
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
}
@@ -635,7 +608,9 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
resp := missingScopeResp(missing[0])
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
requirePermissionError(t, dispatcherErr)
if _, ok := dispatcherErr.(*errs.PermissionError); !ok {
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
}
// Path B: direct construction — exercises the same helpers that
// cmd/service/service.go's newPreflightMissingScopeError uses. Keep this
@@ -657,9 +632,7 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
t.Fatal("direct path failed to emit typed envelope")
}
// Strip fields that only exist when the error came from an upstream Lark
// response. The remaining fields must converge with the local preflight
// error.
// Strip `code` from both envelopes — see test doc above.
stripA := stripUpstreamFields(t, bufA.Bytes())
stripB := stripUpstreamFields(t, bufB.Bytes())
if stripA != stripB {
@@ -667,9 +640,9 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
}
}
// stripUpstreamFields parses an envelope JSON and re-marshals it with fields
// that identify an upstream Lark response removed from the inner "error"
// block. Used by the convergence test to isolate fields shared between the
// stripUpstreamFields parses an envelope JSON and re-marshals it with the
// upstream-derived "code" key removed from the inner "error" block. Used by
// the convergence test to isolate contract fields shared between the
// dispatcher and pre-flight paths.
func stripUpstreamFields(t *testing.T, raw []byte) string {
t.Helper()
@@ -679,7 +652,6 @@ func stripUpstreamFields(t *testing.T, raw []byte) string {
}
if errBlock, ok := obj["error"].(map[string]any); ok {
delete(errBlock, "code")
delete(errBlock, "origin")
}
out, err := json.Marshal(obj)
if err != nil {
@@ -866,7 +838,10 @@ func TestBuildPermissionError_CanonicalMessage(t *testing.T) {
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"})
pe := requirePermissionError(t, err)
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *PermissionError, got %T", err)
}
if pe.Subtype != tc.wantSubtype {
t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype)
}
@@ -963,7 +938,9 @@ func TestBuildAPIError_JSONNumberCode(t *testing.T) {
if err == nil {
t.Fatal("expected error for json.Number-encoded code")
}
requirePermissionError(t, err)
if _, ok := err.(*errs.PermissionError); !ok {
t.Errorf("expected *errs.PermissionError, got %T", err)
}
}
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
@@ -981,7 +958,10 @@ func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe := requireSecurityPolicyError(t, err)
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
}
@@ -1001,7 +981,10 @@ func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe := requireSecurityPolicyError(t, err)
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.Hint != "ask your admin for elevated approval" {
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
}
@@ -1025,7 +1008,10 @@ func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
"data": map[string]any{"challenge_url": bad, "hint": "h"},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe := requireSecurityPolicyError(t, err)
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
}
@@ -1039,7 +1025,10 @@ func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
resp := map[string]any{"code": 21000, "msg": "challenge required"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe := requireSecurityPolicyError(t, err)
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
}
@@ -1073,7 +1062,10 @@ func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
}
}()
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
spe := requireSecurityPolicyError(t, err)
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
}
@@ -1096,7 +1088,10 @@ func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe := requireSecurityPolicyError(t, err)
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
}

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package errclass
func larkErrorOrigin() string { return "lark" }

View File

@@ -1,9 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package errclass
// Standard keeps the pre-Extended API error envelope unchanged.
func larkErrorOrigin() string { return "" }

View File

@@ -1,98 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package extendedupdate
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
extendedStateFile = "update-state-extended.json"
extendedCacheTTL = 24 * time.Hour
)
type cachedRelease struct {
LatestVersion string `json:"latest_version"`
CheckedAt int64 `json:"checked_at"`
}
// CheckCached reads only the Extended release cache. Standard and Extended
// deliberately use different files so one edition can never advertise the
// other edition's release channel.
func CheckCached(currentVersion string) *update.UpdateInfo {
if skipUpdateNotice(currentVersion) {
return nil
}
state, err := loadCachedRelease()
if err != nil || state.LatestVersion == "" ||
!update.IsNewer(state.LatestVersion, currentVersion) {
return nil
}
return &update.UpdateInfo{Current: currentVersion, Latest: state.LatestVersion}
}
// RefreshCache refreshes the Extended GitHub-release cache when stale. It is
// intentionally best-effort because callers run it from the notice goroutine.
func RefreshCache(currentVersion string) {
if skipUpdateNotice(currentVersion) {
return
}
state, _ := loadCachedRelease()
if state != nil && time.Since(time.Unix(state.CheckedAt, 0)) < extendedCacheTTL {
return
}
latest, err := FetchLatest()
if err != nil {
return
}
_ = saveCachedRelease(&cachedRelease{
LatestVersion: latest,
CheckedAt: time.Now().Unix(),
})
}
func skipUpdateNotice(version string) bool {
if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" || update.IsCIEnv() {
return true
}
return !update.IsRelease(version)
}
func extendedStatePath() string {
return filepath.Join(core.GetConfigDir(), extendedStateFile)
}
func loadCachedRelease() (*cachedRelease, error) {
data, err := vfs.ReadFile(extendedStatePath())
if err != nil {
return nil, err
}
var state cachedRelease
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
func saveCachedRelease(state *cachedRelease) error {
dir := core.GetConfigDir()
if err := vfs.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return validate.AtomicWrite(extendedStatePath(), data, 0o600)
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package extendedupdate
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs"
)
func TestCheckCachedUsesExtendedEditionState(t *testing.T) {
for _, key := range []string{
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER",
"CI",
"BUILD_NUMBER",
"RUN_ID",
} {
t.Setenv(key, "")
}
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
standardState, err := json.Marshal(cachedRelease{
LatestVersion: "9.0.0",
CheckedAt: time.Now().Unix(),
})
if err != nil {
t.Fatal(err)
}
if err := vfs.WriteFile(filepath.Join(dir, "update-state.json"), standardState, 0o600); err != nil {
t.Fatal(err)
}
if got := CheckCached("1.0.0"); got != nil {
t.Fatalf("Standard cache leaked into Extended notice: %+v", got)
}
extendedState, err := json.Marshal(cachedRelease{
LatestVersion: "2.0.0",
CheckedAt: time.Now().Unix(),
})
if err != nil {
t.Fatal(err)
}
if err := vfs.WriteFile(filepath.Join(dir, extendedStateFile), extendedState, 0o600); err != nil {
t.Fatal(err)
}
got := CheckCached("1.0.0")
if got == nil || got.Current != "1.0.0" || got.Latest != "2.0.0" {
t.Fatalf("CheckCached() = %+v, want Extended 1.0.0 -> 2.0.0", got)
}
}

View File

@@ -1,323 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
// Package extendedupdate updates an Extended binary from the matching
// lark-cli-extended asset in GitHub Releases.
package extendedupdate
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
const (
latestReleaseURL = "https://api.github.com/repos/larksuite/cli/releases/latest"
releaseBaseURL = "https://github.com/larksuite/cli/releases/download"
maxChecksumBytes = 1 << 20
maxArchiveBytes = 256 << 20
requestTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
)
var httpClient = newHTTPClient()
type release struct {
TagName string `json:"tag_name"`
}
type versionInfo struct {
Version string `json:"version"`
Edition string `json:"edition"`
}
func newHTTPClient() *http.Client {
client := transport.NewHTTPClient(requestTimeout)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" || !trustedDownloadHost(req.URL.Hostname()) {
return fmt.Errorf("release download redirected to an untrusted URL: %s", req.URL.Redacted())
}
if len(via) >= 5 {
return fmt.Errorf("release download exceeded redirect limit")
}
return nil
}
return client
}
func trustedDownloadHost(host string) bool {
host = strings.ToLower(host)
return host == "github.com" ||
host == "api.github.com" ||
strings.HasSuffix(host, ".githubusercontent.com")
}
// FetchLatest returns the latest release version. Install verifies that the
// matching Extended asset and checksum entry exist before replacing anything.
func FetchLatest() (string, error) {
body, err := download(latestReleaseURL, maxChecksumBytes)
if err != nil {
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to query the latest Extended release: %v", err).WithCause(err)
}
var latest release
if err := json.Unmarshal(body, &latest); err != nil {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"GitHub returned an invalid latest release response").WithCause(err)
}
version := strings.TrimPrefix(strings.TrimSpace(latest.TagName), "v")
if version == "" || strings.ContainsAny(version, `/\`) {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"GitHub returned an invalid latest release tag")
}
return version, nil
}
// Install downloads, verifies, and atomically replaces the running Extended
// binary with the requested Extended release asset.
func Install(version string) error {
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
archiveName, err := assetName(version, runtime.GOOS, runtime.GOARCH)
if err != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%v", err).WithCause(err)
}
base := releaseBaseURL + "/v" + version
checksums, err := download(base+"/checksums.txt", maxChecksumBytes)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to download Extended release checksums: %v", err).WithCause(err)
}
expected, err := checksumFor(checksums, archiveName)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Extended release checksum is invalid: %v", err).WithCause(err)
}
archive, err := download(base+"/"+archiveName, maxArchiveBytes)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to download Extended release asset: %v", err).WithCause(err)
}
actual := sha256.Sum256(archive)
if !bytes.Equal(actual[:], expected) {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Extended release checksum verification failed")
}
binary, err := extractBinary(archive, runtime.GOOS)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Extended release archive is invalid: %v", err).WithCause(err)
}
if err := replaceCurrent(binary, version); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to install lark-cli Extended: %v", err).
WithCause(err).
WithHint("ensure the current lark-cli installation directory is writable")
}
return nil
}
func download(rawURL string, limit int64) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "lark-cli-extended")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(body)) > limit {
return nil, fmt.Errorf("response exceeds %d bytes", limit)
}
return body, nil
}
func assetName(version, goos, goarch string) (string, error) {
switch goos {
case "darwin", "linux", "windows":
default:
return "", fmt.Errorf("Extended update does not support %s", goos)
}
switch goarch {
case "amd64", "arm64", "riscv64":
default:
return "", fmt.Errorf("Extended update does not support %s/%s", goos, goarch)
}
ext := ".tar.gz"
if goos == "windows" {
ext = ".zip"
}
return fmt.Sprintf("lark-cli-extended-%s-%s-%s%s", version, goos, goarch, ext), nil
}
func checksumFor(data []byte, asset string) ([]byte, error) {
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != asset {
continue
}
sum, err := hex.DecodeString(fields[0])
if err != nil || len(sum) != sha256.Size {
return nil, fmt.Errorf("invalid SHA-256 for %s", asset)
}
return sum, nil
}
return nil, fmt.Errorf("checksums.txt does not contain %s", asset)
}
func extractBinary(archive []byte, goos string) ([]byte, error) {
name := "lark-cli"
if goos == "windows" {
name += ".exe"
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
return nil, err
}
for _, file := range reader.File {
if path.Clean(file.Name) != name || file.FileInfo().IsDir() {
continue
}
rc, err := file.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return readBinary(rc)
}
return nil, fmt.Errorf("%s is missing", name)
}
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if path.Clean(header.Name) == name && header.Typeflag == tar.TypeReg {
return readBinary(tr)
}
}
return nil, fmt.Errorf("%s is missing", name)
}
func readBinary(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxArchiveBytes+1))
if err != nil {
return nil, err
}
if len(data) == 0 || int64(len(data)) > maxArchiveBytes {
return nil, fmt.Errorf("binary has invalid size")
}
return data, nil
}
func replaceCurrent(binary []byte, version string) error {
exe, err := vfs.Executable()
if err != nil {
return err
}
exe, err = vfs.EvalSymlinks(exe)
if err != nil {
return err
}
dir := filepath.Dir(exe)
tmp, err := vfs.CreateTemp(dir, ".lark-cli-extended-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer vfs.Remove(tmpName)
if _, err := tmp.Write(binary); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o755); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := verifyBinary(tmpName, version); err != nil {
return err
}
backup := exe + ".old"
_ = vfs.Remove(backup)
if err := vfs.Rename(exe, backup); err != nil {
return err
}
restore := func() {
_ = vfs.Remove(exe)
_ = vfs.Rename(backup, exe)
}
if err := vfs.Rename(tmpName, exe); err != nil {
restore()
return err
}
if err := verifyBinary(exe, version); err != nil {
restore()
return err
}
_ = vfs.Remove(backup)
return nil
}
func verifyBinary(exe, version string) error {
ctx, cancel := context.WithTimeout(context.Background(), verifyTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, exe, "version", "--json").Output()
if err != nil {
return fmt.Errorf("candidate binary is not executable: %w", err)
}
var info versionInfo
if err := json.Unmarshal(out, &info); err != nil {
return fmt.Errorf("candidate returned invalid version metadata: %w", err)
}
if strings.TrimPrefix(info.Version, "v") != strings.TrimPrefix(version, "v") {
return fmt.Errorf("candidate version is %q, want %q", info.Version, version)
}
if info.Edition != "extended" {
return fmt.Errorf("candidate edition is %q, want extended", info.Edition)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More