Compare commits

..

93 Commits

Author SHA1 Message Date
shanglei
1cbaa7ff21 Merge remote-tracking branch 'origin/main' into refactor/package-debt-phase2
main brought behaviour, this branch brought renames, so every resolution keeps
both. Nothing had to be given up.

Two textual conflicts:

- lint/domaincontract/scan.go: the package comment. Took main's wording, which
  describes the domain policy it just added and names no package, so this
  branch's edit — replacing a stale internal/core reference — needs nothing
  carried over. The behaviour-bearing line in the same file, resolverPath
  pointing at brand/brand.go, sits outside the conflict and is untouched.
- shortcuts/drive/drive_io_test.go: the import block, resolved as the union of
  main's credential and validate with this branch's config and identity.

Four more the merge resolved silently and wrongly, because #2070 added files
naming a package this branch deletes. They compile only after rewriting:
core.LarkBrand to brandpkg.Brand, core.BrandLark to brandpkg.Lark and
core.ResolveOpenBaseURL to brand.ResolveOpenBaseURL, across
drive_permission_get_setting.go, its test, and drive_io_test.go. The production
file needs the brandpkg alias because a local variable there is already named
brand. Diffing those files against main afterwards shows the import line and
the symbol names as the only differences, so #2070's host selection is intact.

Worth recording for the next merge: main's new drive test imports
internal/credential, which shortcuts-runtime-gate denies and which this branch
now also checks in test files. It passes only because internal/credential is
one of the rule's TestExempt entries. A denied import outside that list would
have forced a real choice.
2026-07-31 14:28:09 +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
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
shanglei
a72297b026 test(qualitygate): pin the test-import wiring to a fixture module
TestGoListGraphCarriesBothTestImportKinds derived its expectation from this
repository: it scanned the tree for in-package and external test files, then required
the graph to report the kinds it found. That reads as thorough and fails open — the
day the last external test package is deleted or moved, the XTestImports half stops
asserting anything and the suite still passes.

It now builds a module for the purpose: a subject package that imports one package
from an in-package test file and another from its external test package, so neither
field can be satisfied by the file that satisfies the other. Same `go list` and same
merge path, deterministic input, and 0.2s instead of 35s because the sweep runs over
four packages rather than the whole repository. Mutation-checked again: dropping
either merge line fails with the field named.

Three other gaps from the same review, all of them tests for behaviour that is
already correct:

- shortcuts/common asserted every EmitOptions field the Out* methods forward except
  the notice provider. A dropped assignment there stays valid JSON, so nothing would
  have failed — it would only show up as users no longer being warned their token is
  about to expire.
- internal/outputdir and its shortcuts/common forwarder covered success, rejection
  and the absolute path, but never a filesystem failure. Both now create a regular
  file where a parent directory has to be and require the error to come back, rather
  than a success for a directory that does not exist.
- internal/cmdutil explained the authlog install order in terms of internal/core,
  which this branch split. The cycle it describes is still real — keychain imports
  authlog — so the comment now names that instead of a package the reader cannot find.

Left alone deliberately: splitting layering_test.go. It is 2770 lines holding the
rules, the go list plumbing, the graph, the registry and their tests, and it should be
several files in the same package. That is a pure file move, and doing it inside a
review round about this PR's size would bury the changes above in relocation noise.
Better as the first commit after this merges.
2026-07-30 18:29:24 +08:00
shanglei
7512f017d6 Merge remote-tracking branch 'origin/main' into refactor/package-debt-phase2
No textual conflict this time, and that is the problem it hides: a575a8ba added
shortcuts/contact/contact_search_bot_test.go, which builds its fixture config from
core.CliConfig and core.BrandFeishu. This branch split internal/core, so the merged
tree stops compiling — `go vet ./...` reports no module provides internal/core, which
fails fast-gate before anything else runs and skips ten checks behind it. Git had no
way to see it: neither side touched a line the other did.

The new test now says configpkg.CliConfig and brand.Feishu, matching its neighbour
contact_search_user_test.go in the same package, which was ported when the split
landed. Nothing else in the merge referred to the removed package; the two remaining
mentions of internal/core in the tree are a comment in internal/authlog and a
fixture import path inside the layering rule engine's own tests.

Verified on the merge result: go build ./..., go vet ./..., go test -count=1 over
./internal/... ./shortcuts/... ./events/... ./cmd/... ./extension/..., and
`golangci-lint run --new-from-rev` at 0 issues.
2026-07-30 18:19:57 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
shanglei
d0b26821df test(qualitygate): fail when the test-import wiring is removed
ae56d30c added the test-import graph and covered it with fixtures handed straight
to testDependencyView and evaluateLayeringTestRule. That left the wiring itself
uncovered: deleting either merge line in goListPackageGraph, or the test-view
evaluation in the gate's aggregation, kept the whole deptest suite at exit 0. Three
independent mutations, three green runs — the reverse check for this gate lived in a
shell session instead of in CI.

Two tests close it, one per half of the wiring.

The aggregation moves into layeringViolationsByRule, so consulting both graphs is
now code a test can reach. TestLayeringViolationsByRuleReportsTestOnlyEdges runs the
real rule set over a package whose production imports are clean and whose denied
dependencies exist only in TestImports and XTestImports; both have to come back,
grouped under their rule and marked TestOnly. It doubles as the statement of what
stays denied to tests — keychain and client, the two the shortcuts TestExempt list
leaves out.

TestGoListGraphCarriesBothTestImportKinds covers what no fixture can: that the two
fields survive `go list -json` and the per-configuration merge. Its expectation is
derived from the tree rather than pinned to a package that happens to import
something today — whichever kinds of test file the module contains, in-package or
external, must appear in the graph.

Verified by mutation: each of the three deletions now fails, and named for the
reader — "goListPackageGraph is dropping the field" for the merges, "the gate is not
consulting the test view" for the aggregation.
2026-07-30 17:37:36 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
shanglei
7faf9da3a4 fix(shortcuts): keep the output-dir hop that lint requires
4c220154 inlined internal/outputdir into shortcuts/common on the grounds that the
package below held nothing the runtime gate does not. It held one thing: the
internal/vfs import. The depguard rule shortcuts-no-vfs denies vfs to every file
under shortcuts/ and grants no exemption, while the layering rule
shortcuts-runtime-gate exempts shortcuts/common as the runtime gate. Two gates,
different answers about the same package — so the inline built, passed the layering
gate, and failed the lint job on the one line it added:

  shortcuts/common/output_dir.go:10:2: import '.../internal/vfs' is not allowed
  from list 'shortcuts-no-vfs' (depguard)

The forwarder is restored with the reason written down in both files, so the next
reader does not measure the hop against the layering rule alone and reach the same
wrong conclusion.

The tests stay, split where each belongs: internal/outputdir owns the behaviour it
implements — relative paths resolved inside the working directory, an escaping path
rejected before anything is created, an absolute path accepted — and reaches 100%
of its statements, which is more than the 0% that made the hop look dead in the
first place. shortcuts/common keeps one test for the only way a forwarder this thin
can fail, by not being called.

The gap that let this reach CI: the verification for 4c220154 ran the layering gate
and the repository's own lintcheck, neither of which is golangci-lint.
`golangci-lint run --new-from-rev` — the command the lint job actually runs — now
reports 0 issues for this branch.
2026-07-30 16:41:11 +08:00
shanglei
ae56d30ce3 fix(qualitygate): check the imports test files bring in
The layering graph was built from Imports and Deps only. `go list` keeps a
package's test dependencies in two other fields — TestImports for the in-package
test files, XTestImports for the external test package — and listedPackage did not
declare either, so every denied dependency reached through a _test.go file went
unreported. Ten packages were already through the gap: shortcuts/mail's tests
import internal/auth, internal/vfs and internal/vfs/localfileio, and the rule that
denies exactly those to shortcuts stayed green.

TestLayeringBuildConfigsSelectEveryFile made it worse than a plain omission. It
counts TestGoFiles and XTestGoFiles as selected, on the stated ground that "an
import edge only reaches the rules through a selected file" — so the check that
exists to prove nothing is unscanned was vouching for files the rules never read.

TestPackageLayering now walks a second graph, testDependencyView, built from those
two lists: direct imports for a Direct rule, and for a Transitive one the closure
through each test import's production deps. A package's own import path is dropped,
because `package foo_test` always imports foo and errs-leaf denies this module
wholesale — counting that would fail the leaf on the test that tests it.

The two graphs need different answers, so Rule gains TestExempt. A shortcut's test
builds the runtime the shortcut is handed at run time, which means naming the
credential, auth and filesystem packages that runtime is assembled from; denying
those in tests moves no production import and would only park ten packages in the
exception registry for writing ordinary tests. keychain and client stay denied in
tests too: a test needs neither to construct a RuntimeContext, and reaching for
them means it is talking to the real keyring or issuing real requests. Direction
stays denied everywhere — a test may reach down for scaffolding, never up.

That last part left one real violation, and it was an inversion rather than
scaffolding: internal/output's frozen-oracle test imported shortcuts/common to run
the same fixtures through RuntimeContext.Out*. The Emitter half stays where the
fixtures are; the wiring half moves to the layer that owns those methods, as
shortcuts/common/runner_emitter_wiring_test.go — each Out* has to hand the Emitter
the option its name promises, which the bytes show (Raw decides whether
`<p>a&b</p>` survives). It also covers OutFormatRaw, which the oracle was the only
test to reach. Statement coverage is unchanged in both packages, 83.5% and 72.6%.

Verified by probe, both buckets: an XTest-only and an in-package-test-only import
of a denied package under shortcuts/mail each fail the gate now, reported with
in=test files, and passed it before this change.
2026-07-30 16:21:48 +08:00
shanglei
7e34eccf3a Merge remote-tracking branch 'origin/main' into refactor/package-debt-phase2
One conflict, in internal/envvars/read_test.go, and it stands for a real
disagreement rather than two edits to the same line.

This branch unexported CliAgentName into a package-private agentNameEnv while
trimming the envvars surface (8ba24311): at that point read.go and its test were
the constant's only readers. main then landed #2097, whose
internal/cmdutil/secheader_test.go sets the variable through envvars.CliAgentName
— a cross-package reader again, so the constant earns its export back. Keeping it
private would have meant spelling "LARKSUITE_CLI_AGENT_NAME" a second time in
cmdutil, which is what the constant exists to prevent.

So: CliAgentName is restored in envvars.go, read.go reads it instead of the private
duplicate, and read_test.go is taken from main — that keeps #2097's de-branding of
the fixtures ("sample-agent" in place of the two agent names the test used to
hardcode), which this branch had no stake in.

Verified on the merge result: go build ./..., go vet ./... and go test -count=1
over ./internal/... ./shortcuts/... ./events/... ./cmd/... ./extension/... are
clean, as are the layering ratchet and ci-workflow script suites.
2026-07-30 14:52:47 +08:00
shanglei
4c22015464 refactor(shortcuts): drop the forwarders nothing was left holding
internal/outputdir had one importer: a shortcuts/common function that forwarded to
it and did nothing else. shortcuts/common is the runtime gate that
shortcuts-runtime-gate exempts, so it already holds vfs and validate, and the
package below it held nothing the gate does not. EnsureOutputDir is the whole
implementation again, and gains the first tests it has had — four callers, no
coverage until now: a relative path resolved inside the working directory, one
that climbs out and must be rejected before anything is created, and the absolute
path its doc comment promises to accept.

convert_lib kept four forwarders into internal/imcontent. ResolveMentionKeys,
formatTimestamp and extractPostBlocksText had no caller but a test, and forwarding
ParseJSONObject only gave one function two entry points; its two real callers in
resource_extract.go now say imcontent.ParseJSONObject. BuildMentionKeyMap stays,
because shortcuts/event builds a ConvertContext through this package and should
not have to reach past it.

The five helper tests move to internal/imcontent, where the code they cover lives,
so removing a forwarder no longer removes coverage. Two files that arrived without
tests of their own get them: the imcontent dispatch — including the invariant a
converter table cannot state, that a registered type must never be answered by the
"[type]" placeholder — and sparkstore's AppStorage adapter, where ListAppIDs
decodes escaped directory names and must report an absent root as zero apps rather
than an error. Own-package coverage: imcontent 82.0% -> 89.1%, sparkstore
72.6% -> 94.5%.
2026-07-30 14:26:30 +08:00
shanglei
1698ac1ff3 fix(qualitygate): keep both layering walks out of dot directories
skipLayeringScopeDir named .git explicitly and skipped every "_" prefix, but the
go command ignores "." and "_" alike, so `go list ./...` never offers a dot
directory's files to any configuration. The walk descended into them anyway, and
TestLayeringBuildConfigsSelectEveryFile then demanded a configuration that had
compiled them: a gitignored .cache/ holding one Go probe file fails the suite with
"is compiled by none of the 28 executed configurations". CI checks out clean, so
this only ever bit a developer with build scratch in the tree.

The predicate now answers the scope its own comment claims — what `go list ./...`
builds — and the module root stays in scope whatever it is called, so a checkout
under a directory the rules would otherwise reject does not empty both walks.
2026-07-30 14:26:15 +08:00
shanglei
8f3e9630a1 fix(qualitygate): report a stale ratchet base as a stale base
check-layering-ratchet.sh picks bootstrap or incremental mode by whether the base
revision carries layering-edges.txt. Once the registry is on the target branch,
every PR whose merge base predates that point still lands in bootstrap mode, so a
branch that legitimately registers one exception was told its bootstrap differs
from the approved 0-edge snapshot. The obvious response to that message — edit
the approved baseline — is the opposite of what the gate wants.

A gate-version marker cannot separate the two cases: the script and the registry
land in the same commit, so "the base has neither" describes both the commit that
introduces the gate and any branch that forked before it. The failure now names
both situations, says what each one does, and lists the keys it found, so a
developer on a stale base sees the row to fix and the rebase that will report it
as a new key instead.

added_at also becomes immutable on a key the base already carries. That date is
the ratchet's clock: it records when the debt was accepted, and moving it makes an
old exception look fresh, or a fresh one grandfathered, without touching a single
import. owner and reason stay editable — their change is legible in the diff, and
locking them would leave no way to hand an exception over, since the gate has no
override short of deleting a row the dependency still needs.
2026-07-30 14:26:15 +08:00
shanglei
aa93b3f3a6 fix(convertlib): keep an empty merge_forward body empty
ConvertBodyContent opened with `if ctx.RawContent == "" { return "" }` before the
converters moved down to internal/imcontent. The guard went with them, and
merge_forward is dispatched above that call — the shortcut-side converter expands
the tree from the API rather than from body.content, so it never reaches
imcontent's copy.

A merge_forward item whose body.content is an empty string therefore stopped
converting to "" the way every other message type does. With a prefetched page it
renders a full <forwarded_messages> subtree; without one, and with a runtime in
hand, it issues an inline GET /open-apis/im/v1/messages/{id} and prints
"[Merged forward: fetch failed: ...]" when that fails. Both reach the formatted
message output. FormatEventMessage carries no Runtime and no prefetch, so the
event path kept falling through to imcontent and was unaffected.

The guard belongs above the dispatch, where it was, and now also covers a nil
context, which the original would have dereferenced. Two tests pin it: the
prefetch path must still convert to "", and the runtime path must issue zero
requests.
2026-07-30 14:26:00 +08:00
calendar-assistant
1f565a290b docs(calendar): warn against container-default timezone in time conversion (#2104)
Agents dropping to the raw `calendar events create/patch` API must convert
wall-clock time to Unix timestamps themselves. In UTC containers this silently
yields an 8-hour offset. Require explicit ISO 8601 offsets on +create/+update
--start/--end, and warn that raw-API timestamp conversion must specify the
target timezone instead of relying on the container default.
2026-07-30 14:06:14 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
shanglei
0885ec2eae fix(qualitygate): give both layering walks one scope
The constraint walk descended into nested modules while the file walk skipped
them, and the two feed each other: constraints become the configurations the
file walk is measured against. A compound tag under lint/ therefore added
`-tags bar,foo` to this module's sweep — seven more `go list` runs selecting
nothing here, and a failing configuration list — over a file no walk ever
required to be selected. lint/ carries no custom tag today, so the divergence
was latent rather than broken.

One predicate now answers both walks, and a test pins it: a nested module is out
of scope, a plain package directory is not, and the module root itself stays in.
2026-07-29 16:20:50 +08:00
shanglei
b39258c169 fix(shortcuts): stop printing the import alias as the brand word
The internal/core split renamed the brand package and aliased the import as
brandpkg, and the sweep that rewrote `brand.` also rewrote the word ending three
sentences. One of them is user-visible: `apps --help` on Lark read "The "apps"
feature is not yet supported on the lark brandpkg." The error path a few lines
above was spelled without the trailing period and escaped the sweep, so the two
surfaces disagreed.

The brand-guard tests only exercised RunE, which --help bypasses, so nothing
covered the sentence. Pin it whole: a substring check would still pass on a
mangled tail.

The other two are comments in internal/auth.
2026-07-29 15:27:32 +08:00
shanglei
2a252d2a80 fix(qualitygate): select build-tag files the union used to miss
The union executed one `go list` per registered tag, so a file constrained by
`foo && bar` was selected by neither `-tags foo` nor `-tags bar`. The coverage
test only asked whether each tag name appeared in the registry, which both did,
and the remedy it printed — "union the tag" — is what produced the gap: register
the two tags separately and the file lands in no graph while every check reports
covered. A probe file under events/ importing shortcuts/common, the exact shape
events-no-shortcuts forbids, passed the whole suite that way.

Derive the configurations instead. Every //go:build line is parsed with
go/build/constraint, and each distinct expression contributes a tag set that
satisfies it, so `foo && bar` yields `-tags bar,foo` without anyone registering
anything. Platform terms stay free variables: layeringBuildTargets already
varies GOOS and GOARCH, and -tags cannot set them. Cheapest set wins, which
keeps a platform-only constraint from adding a configuration it does not need.

Deriving the sets removes both hand-kept lists, including the exclusion map that
carved out the sidecar demo tags — those now get a configuration like everything
else.

The replacement invariant is a file-level one the tag registry could not state:
every Go file in this module must be compiled by at least one executed
configuration, asked of the toolchain rather than re-derived from the model that
produced the configurations. It immediately found a second blind spot with no
custom tag in it at all: internal/riskcontrol/osmodel_other.go is constrained
`!darwin && !windows && !linux`, and all seven release targets are one of those,
so no configuration has ever compiled it. That file is recorded as out of scope
with its reason checked — an entry naming a custom tag, or one a release target
does compile, now fails.

Nested modules are skipped, since `go list ./...` does not reach into lint/ and
the rules are written against this module's import paths.

Cost: the file-level check sweeps `go list` again because the rule graph merges
packages across configurations and keeps only imports, so it can no longer say
which configuration contributed which file. The package goes from roughly 16s to
roughly 60s.
2026-07-29 15:27:20 +08:00
shanglei
1ba4d1fd77 Merge remote-tracking branch 'origin/main' into refactor/package-debt-phase2
Two conflicts, both in the calendar rich-image preview URL and both the same
shape: main fixed the default host while this branch renamed the brand type.
Took main's fix and this branch's package.

- shortcuts/calendar/description_rich_images.go: host stays the feishu.cn value
  #2079 corrected it to, with the brandpkg.Brand signature.
- shortcuts/calendar/description_rich_images_test.go: the expected host fragment
  follows the same fix; the table already carried brandpkg.Brand from the
  automatic merge, so core.Brand* would not have compiled.
2026-07-29 14:18:27 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
shanglei
bb7342c3cc docs: record what internal/core became in the source layout
The split left the layout table naming only the renamed config package, so
brand, workspace and identity — the three a caller reaches for most — had no
entry, and nothing said where the remaining two went. brand earns a row of its
own for a second reason: it sits at the repository root precisely so extension/
may import it.

The note under the table carries what a path table cannot. The five siblings do
not import each other, and that is the whole reason to ask for the narrow one:
a caller that only wants a config directory no longer compiles keychain, i18n
and validate along with it.
2026-07-28 16:30:30 +08:00
shanglei
f25ef0ae75 docs: point the moved core references at their new packages
The internal/core split renamed or relocated every symbol these comments
name, and the sweep missed six call-outs. Two had gone self-contradictory:
internal/meta named the package internal/core while already qualifying the
type as identity.Identity, and authlog attributed its runtime-directory
indirection to a cycle through internal/core that the split removed.

Restate authlog's reason for keeping the indirection instead of promising a
follow-up: the factory-installed logger follows the detected workspace while
the Shared() fallback stays on the pre-workspace directory, so resolving the
directory inside the package would collapse that distinction.

The domaincontract rule's README still pointed host literals at
internal/core/types.go; the exemption moved to brand/brand.go with the
resolver.
2026-07-28 15:36:35 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
shanglei
5bae5bbbc2 fix(lint): follow the extracted brand resolver 2026-07-28 14:50:25 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
shanglei
d7cf797bdd refactor(config): remove unreachable legacy loaders 2026-07-28 14:40:30 +08:00
shanglei
0266a7e9a1 refactor(core): rename remaining config package 2026-07-28 14:16:38 +08:00
shanglei
939fc1eeb8 refactor(core): extract identity policy 2026-07-28 14:12:20 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
shanglei
c69a61c672 refactor(core): extract secret storage 2026-07-28 14:04:51 +08:00
shanglei
ee27d0cdc2 refactor(core): extract workspace paths 2026-07-28 12:15:26 +08:00
shanglei
3732c6bcce refactor(core): extract brand package 2026-07-28 12:10:53 +08:00
shanglei
9d8e93c682 refactor(core): extract risk constants 2026-07-28 12:00:36 +08:00
shanglei
38312d3a9c refactor(apps): move local state behind internal storage 2026-07-28 11:58:22 +08:00
shanglei
342d1a247d refactor(shortcuts): centralize output directory creation 2026-07-28 11:53:54 +08:00
shanglei
770c23035c refactor(shortcuts): route client helpers through common 2026-07-28 11:48:37 +08:00
shanglei
2634092ff2 refactor(shortcuts): route scope checks through common 2026-07-28 11:45:57 +08:00
shanglei
9c50045f14 docs(authlog): drop the pointer to a note that was never written
The comment sent readers to a follow-up in the pull request description that
does not exist there. Keep the reason in the source, where it is already
complete, and add the evidence that made the decision: applying the validator
moved four packages' expectations from /var to /private/var, because it
resolves symlinks.
2026-07-27 17:45:07 +08:00
shanglei
7b6962a726 fix(sidecar): keep the classification the resolver already made
97e397cf classified every startup failure, including the one the config
resolver had already classified. An unconfigured CLI comes back as
not_configured carrying "run: lark-cli config init"; wrapping it in
invalid_config put the wrong subtype in front — ProblemOf reads the outermost —
and dropped the hint entirely. A caller would be told the config is broken when
it was never written.

Pass typed errors through untouched and reserve a fresh error for the case
where the resolver gave none, where internal/unknown is the honest answer
rather than a guess at invalid_config.

Flag rejections now name the flag through WithParam, so a caller learns which
one to fix without reading the sentence, and the tests assert subtype and
param through ProblemOf instead of matching prose.
2026-07-27 17:45:07 +08:00
shanglei
1af34e6649 docs(authlog): correct what the word cap is measuring
The comment claimed the binary plus two words is the deepest command path in
this CLI. It is not: generated service commands go one level further, as
`drive file.comments create_v2` in the manifest tests shows, and the cap cuts
their last word. Calling the bound a measurement invites the next reader to
raise it for a command that does not fit — which would also admit the first
positional argument, where resource identifiers live.

State it as the privacy bound it is, and add the generated-command case to the
table so the trade-off is visible next to the cases it protects.
2026-07-27 17:29:18 +08:00
shanglei
c12ab91349 fix(authlog): restore the word cap the flag boundary replaced
475f04a8 stopped the command line at the first flag and dropped the
"keep three words" rule with it, on the reasoning that the command path is what
the log needs. That reasoning missed positional arguments: `api <method> <path>`
takes the path as an argument, so a document token moved from truncated to
recorded in a file that is kept for a week.

Apply both limits. Stop at the first flag, so a sensitive flag ahead of the
subcommand cannot slip through, and keep at most three words, so a positional
identifier after the command path cannot either. Removing either one fails a
test: the flag boundary alone lets the document token through, the word cap
alone lets --token=... through.

Verified case by case that nothing reaches the log that the pre-475f04a8
behaviour withheld.
2026-07-27 17:08:38 +08:00
shanglei
57bf8ccd1e docs(authlog): record why two neighbours read the environment differently
LARKSUITE_CLI_LOG_DIR is validated, LARKSUITE_CLI_CONFIG_DIR is not, and the
asymmetry looks like an oversight. It is not free to remove:
validate.SafeEnvDirPath resolves symlinks, so routing CONFIG_DIR through it
changes the directory the CLI reports on any host where the path crosses one.
Applying it moved four packages' expectations from /var to /private/var on
macOS. Whether config paths should be symlink-resolved is a decision about the
on-disk contract, not a local tidy-up, so say so where the next reader looks.

Also record why the stderr capture helper uses os while the file assertions use
vfs: os.Pipe and os.Stderr are process contracts with nothing for a substituted
filesystem to intercept.
2026-07-27 16:42:20 +08:00
shanglei
97e397cf0c refactor(sidecar): return typed errors from the demo servers
Both demo entry points reported every startup failure with fmt.Errorf, so the
self-proxy rejection was indistinguishable from a missing key file except by
reading the sentence. Classify them instead: a poisoned environment and an
unreadable config are ConfigError, flag validation is ValidationError, local
key and log file work is InternalError, and listen or serve failures are
NetworkError. Causes are attached rather than folded into the message.

The self-proxy test asserts the type with errors.As and keeps the check that
the message names the variable, so renaming the variable still fails the test
while rewording the sentence no longer does.
2026-07-27 16:42:20 +08:00
shanglei
c138f29972 Merge remote-tracking branch 'origin/main' into refactor/package-debt-phase2 2026-07-27 15:45:58 +08:00
shanglei
2662729cd6 test(core): enforce the brand parity the comments only request
bf56e903 asked the next author to change both ParseBrand implementations by
cross-referencing them. That is the weakest kind of guarantee: it holds until
someone adds a brand to the package they happened to open.

Assert it instead. The constants are read out of both sources rather than
listed again, so a brand added to one package is under test immediately, and
the two parsers are compared on every declared value plus the inputs that
exercise the normalisation — case, padding, an unknown brand, a near miss.

The file sits in neither parser's package so neither owns the contract. It does
not merge the two implementations: extension/credential ships as a standalone
SDK and may not import internal, which is the constraint this branch exists to
establish.
2026-07-27 15:43:46 +08:00
shanglei
475f04a8dd fix(authlog): stop trusting argument position, and speak up on a rejected log dir
Two behaviours carried over from internal/keychain, both left as they were when
the package moved.

FormatAuthCmdline kept the first three arguments. That protected secrets only
while no sensitive flag appeared early: a global flag in front of the
subcommand put its value straight into a file that is world-readable to the
user, kept for seven days. Today's CLI cannot reach that state — the only
persistent flag is --profile and secrets arrive through --app-secret-stdin — so
this is about the shape, not a live leak. Drop everything from the first flag
onward instead. A denylist of sensitive names would need extending whenever one
is added; the command path is what the log is for, and it lives entirely in the
leading non-flag arguments. args[0] is reduced to its base name so an absolute
install path stays out too.

logDir swallowed the error when LARKSUITE_CLI_LOG_DIR failed validation and
wrote elsewhere while the caller kept watching the path they configured. Warn
instead. This fires only on a rejected override, not on every run, and logDir
resolves once per process.

Tests cover a flag ahead of the subcommand, the absolute-path case, and that a
usable override still prints nothing.
2026-07-27 15:43:46 +08:00
shanglei
52a1187c20 test(qualitygate): detect build tags the union never scans
The build-tag list was checked against a literal, so the test only caught edits
to the list itself. A tag introduced anywhere else left its files out of every
graph and the rules went quiet on them, with nothing to notice. The platform
list does not have this hole: it is cross-checked against .goreleaser.yml.

Walk the tree for //go:build constraints and require every custom tag to be
either unioned or listed as excluded. GOOS, GOARCH and toolchain terms come
from `go tool dist list` so a new port cannot look like a custom tag.

Exclusions now carry a reason that is verified rather than asserted: the two
demo tags are skipped because their files sit outside every rule's FromPrefix,
and the test fails if a file carrying one ever lands inside one. A listed
exclusion nobody uses fails too, so the list cannot rot.
2026-07-27 15:23:02 +08:00
shanglei
bf56e903ba docs: point the two brand parsers at each other
Removing extension's dependency on internal left the brand rule implemented
twice, once per Brand type. The two cannot share code: extension is published
as a standalone SDK and may not import internal, which is the constraint this
branch exists to establish. Cross-reference them so a third brand is added to
both rather than to whichever one the next author happens to open.

Also correct the timestamp wrapper's comment, which has claimed "HH:mm" since
before this branch while every implementation formatted seconds too.
2026-07-27 14:37:02 +08:00
shanglei
b028c33e8f fix(authlog): keep the installed logger for the life of the process
The package documents one logger and one file handle per process, but SetShared
overwrote the current instance on every call and the factory can be built more
than once. A second construction opened a second file, left the first one open
with no way to close it, and moved later lines to whichever workspace directory
that construction resolved.

Only the first explicit install now takes effect. A lazily created fallback is
not an explicit install, so the first real one still replaces it — and closes
the file it had opened, which needs the handle to be retained rather than
handed to log.New and forgotten. The once-guarded init becomes a mutex so the
handle can be released safely; a closed logger drops writes instead of pointing
at a file nobody reads.

Tests cover two non-nil installs and the fallback handover, and both fail if
the guard is removed.
2026-07-27 14:14:10 +08:00
shanglei
4073e75def fix(qualitygate): exempt single edges instead of whole packages
Two packages were listed in ExceptFrom, which makes the evaluator skip the
source package before it looks at any dependency. gitcred needs keychain and
vfs; manifest-export needs the cmd root. Exempting them wholesale also cleared
every other denied import, so a later gitcred -> internal/client or
manifest-export -> events would pass the gate and never reach the registry. A
probe confirmed both slip through unreported.

Add ExceptEdges, matched on the exact (from, denied) pair, and move these two
across. ExceptFrom stays for packages whose whole job is to sit on the boundary
the rule draws: the shortcuts/common runtime gate, the cmd assembly roots, the
wrapper-main demos. Contract tests feed each package its allowed imports plus
one denied import and assert exactly one violation, so the allowed edges carry
weight instead of being asserted trivially.
2026-07-27 14:14:10 +08:00
shanglei
72ea02875c Merge remote-tracking branch 'origin/main' into refactor/package-debt-phase2 2026-07-27 11:25:02 +08:00
shanglei
aec9c4677d fix(authlog): allow the cleanup panic notice and cover the logger
Two things surfaced once CI compared this branch against main.

The panic notice in cleanupOldLogs writes to os.Stderr, which forbidigo
rejects. The line is unchanged from internal/keychain, but moving the file
makes every line new to a diff-scoped linter. This package is a leaf with no
IOStreams in scope — the same constraint defaultRuntimeDir already documents —
and a panic in background cleanup still has to be visible, so mark it the way
internal/output marks its equivalent stderr write.

Coverage of the package sat at 28.6%: the extraction brought no test for what
the logger actually writes. Add three. One pins that lines land under the
supplied RuntimeDir, which is the property that regressed when a caller
constructed the logger with empty options. One covers the nil-receiver guard
both entry points carry. One pins the seven-day retention window and the
filename patterns the prune may touch. Coverage is now 80.5%.
2026-07-27 11:21:42 +08:00
shanglei
e5b2e96df4 Merge branch 'main' into refactor/package-debt-phase2
main gained risk-control host signals, which changed the cachedHttpClientFunc
and cachedLarkClientFunc signatures and rewrote the proxy-warning test around
TestFactory. The only conflict was that test's import block: this branch moved
the shared environment variable names out of internal/envvars into envnames,
while main still imported the old package.

Keep envnames for the five constants that moved and add internal/core for the
config types the rewritten test now builds. internal/envvars is no longer
needed here; its remaining constants are the internal-only ones.
2026-07-25 18:16:06 +08:00
shanglei
602f6719dc chore(qualitygate): align the bootstrap baseline with the merged registry
The approved bootstrap snapshot records the registry size at the moment the
file first reaches the target branch, and it is the only check that runs in
that situation. This change now carries both the registry and the first round
of cleanup, so the file lands on main holding 18 edges rather than the 39 it
was pinned to.

Update the count and hash to match, and state in a comment why the baseline
stays hardcoded: a value CI could supply would let anyone raise the approved
size without the change appearing in a diff.
2026-07-25 18:06:42 +08:00
shanglei
85490cb3da fix(authlog): share one authentication logger per process
Extracting the logger from keychain replaced an injected package variable with
per-call construction, which regressed two things.

Keychain errors went to the wrong directory. cmdutil used to inject
core.GetRuntimeDir into keychain, so every auth diagnostic landed in the
workspace-aware log. keychain now built its logger with empty Options, falling
back to the pre-workspace ~/.lark-cli path while internal/auth kept passing
core.GetRuntimeDir. Inside a workspace the two halves of one investigation
split across two directories, and LARKSUITE_CLI_LOG_DIR masks it whenever that
override is set.

Each call also built a fresh logger. The sync.Once guarding file creation is
per instance, so every logged line reopened the file — never closed — and
re-ran the week-old-log prune. wrapError fires on every keychain operation, and
a locked keychain is exactly the failure this log exists to diagnose.

Install one logger while the command factory is built, which is the only place
that knows the workspace-aware directory: authlog cannot resolve it itself
because internal/core imports internal/keychain, which imports authlog. Both
callers now read that shared instance, so there is one file handle and one
prune per process. Tests pin the singleton and the install-wins behaviour.

The process-wide variable is a stopgap: the internal/core split can hand the
runtime directory to authlog directly and remove the indirection.
2026-07-25 17:56:55 +08:00
shanglei
2d1341aff6 refactor(convertlib): drop the unused interactive-content wrapper
Once events switched to internal/imcontent directly, the convert_lib wrapper
for ConvertInteractiveEventContent had no callers left. It is newly unreachable
code, which the CI dead-code gate rejects because it only tolerates entries
that already exist on the base branch.
2026-07-25 17:56:42 +08:00
shanglei
aa50bec07e refactor(credential): share the brand parser across providers
Removing the internal/core dependency left each credential provider with its
own copy of the brand rule. Two identical five-line functions mean the brand
set can grow in one provider and silently not in the other, with nothing to
catch it at build time.

Move the rule next to the Brand constants as credential.ParseBrand and have
both providers call it. Same behaviour, one definition.
2026-07-25 17:56:41 +08:00
shanglei
5157c3a00e refactor(binding): split audit and config responsibilities 2026-07-25 17:17:07 +08:00
shanglei
5e23bbeddb refactor(auth): extract authentication logging from keychain 2026-07-25 17:13:32 +08:00
shanglei
d62f8dcbe8 refactor(events): move message conversion below shortcuts 2026-07-25 17:05:01 +08:00
shanglei
8ba2431192 refactor(extension): remove internal package dependencies 2026-07-25 16:52:10 +08:00
shanglei
217f4e5567 fix(qualitygate): pin the examples surface with an allowlist
examples-surface-only promised that demos may consume only the assembled CLI
and the public plugin SDK, but it enforced two denied prefixes instead, so
every tree nobody thought to deny was permitted. A demo importing `events`,
`errs` or a `cmd` subpackage passed the gate, and because
extension-zero-internal exempts these packages from the transitive check,
nothing examined what those imports dragged in either. The exemption was
therefore unbounded in what it covered, the same defect as the directory-name
skip it replaced.

- Add Rule.AllowedRepoDeps, which inverts the check: any dependency inside
  this module that is not listed is a violation. Standard library and
  third-party packages, including same-organisation modules that are not this
  one, stay outside the rule.
- Pin examples-surface-only to exactly `cmd` and `extension/platform`, so the
  rule name matches what it enforces and the inherited chain stays bounded by
  a direct surface of two packages.
- Cover the reproducers as contract cases: other repository trees, `cmd`
  subpackages, other `extension` subtrees, and the module root are rejected,
  while the two allowed packages plus non-module imports are not.

layering-edges.txt stays at 39 rows; the demos already import only the two
allowed packages.
2026-07-25 14:30:29 +08:00
shanglei
820305536c fix(qualitygate): scope the examples exemption to wrapper mains
The extension rule skipped any package whose import path contained
"/examples/", which let the gate miss two things: a directory named
examples anywhere under extension escaped the rule outright, and the
sanctioned demos were exempt from every denial rather than only from the
internal packages they inherit through cmd.

- Drop SkipFrom (and containsAny) so no rule can exempt by directory name.
- Exempt the two wrapper-main demos from extension-zero-internal by exact
  import path. Their cmd import is the pattern they exist to demonstrate,
  and seeding those edges instead would wedge the ratchet: the edges track
  cmd's transitive set, so a new internal package under cmd would demand a
  new row that check-layering-ratchet.sh refuses by design.
- Add examples-surface-only: demos may consume cmd and extension/platform
  but must not directly import internal or shortcuts. Zero violations today.

layering-edges.txt stays at 39 rows, so the ratchet bootstrap snapshot
still matches.
2026-07-25 12:18:26 +08:00
shanglei
d48c218d0d fix: close layering quality gate gaps 2026-07-24 17:49:33 +08:00
shanglei
abe0d09d4b fix(qualitygate): harden layering edge parsing and release-target checks
Layering edge parsing and graph coverage:
- Reject whitespace-padded exception fields instead of silently trimming
  them, so a padded row is a malformed row rather than a coerced identity;
  add a padded-field parse test.
- Fail loud when any release target/tag combination lists zero packages,
  which would otherwise let the layering graph silently under-cover.
- Document the build-tag scope (demo tags excluded), the SkipFrom substring
  semantics, and the toolchain-derived support set behind the drift check.

GoReleaser drift checks:
- Reject custom build commands and per-target overrides as unsupported.
- Detect --tags in addition to -tags when rejecting release build tags.
- Reject any GO* build environment variable (except CGO_ENABLED=0) through a
  single default branch instead of an explicit allowlist.
- Validate the GoReleaser global env block, and make the go-list stderr test
  table-driven across the default and authsidecar graphs.
2026-07-24 17:21:21 +08:00
shanglei
7c2ca4e465 test(qualitygate): cover default release graph 2026-07-24 16:34:22 +08:00
shanglei
cbe0fb12df test(qualitygate): reject unsupported release variants 2026-07-24 16:29:47 +08:00
shanglei
59b6393250 test(qualitygate): fail closed on release target drift 2026-07-24 16:24:30 +08:00
shanglei
f1ce88b48e test(qualitygate): pin release target coverage 2026-07-24 16:16:55 +08:00
shanglei
c09b0d5dd3 test(qualitygate): tolerate coverage helper diagnostics 2026-07-24 16:09:25 +08:00
shanglei
1772afe22d fix(qualitygate): cover release build graphs
Check all seven published GOOS and GOARCH combinations, and keep go list diagnostics separate from its JSON output for cold caches.\n\nMake the bootstrap snapshot immutable in CI, propagate shell failures explicitly, isolate sourced execution, and add deterministic regression tests for each contract.
2026-07-24 16:01:45 +08:00
shanglei
acd50f25fa fix(qualitygate): harden layering ratchet enforcement 2026-07-24 15:41:51 +08:00
shanglei
e488cf4cd3 feat(qualitygate): enforce six-layer package dependency boundaries
Add a data-driven architecture layering test that builds the full import
graph (go list -json -tags authsidecar) and evaluates six rules:

- extension must not depend on internal (transitive; keeps it extractable
  as a standalone SDK module)
- events must not depend on shortcuts (transitive)
- shortcuts must not directly import auth/keychain/credential/client/vfs
  (direct; must go through the shortcuts/common RuntimeContext gate)
- cmd subpackages must not import shortcuts (assembly point + cmd/auth only)
- errs must stay a leaf
- internal must not depend on cmd/shortcuts/events

Pre-existing violations are seeded into layering-edges.txt (37 rows). The
test rejects any unseeded violation (new debt) and any stale row (removed
debt), and CI locks the effective row count to only ever decrease. Removes
the tautological circular-dependency check from arch-audit.yml, since Go
already forbids import cycles at compile time.
2026-07-24 15:18:45 +08:00
536 changed files with 27444 additions and 4998 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,7 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -62,19 +62,6 @@ jobs:
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md

View File

@@ -119,6 +119,8 @@ jobs:
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Enforce layering ratchet
run: bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run source-contract lint guards (lintcheck)

View File

@@ -8,376 +8,130 @@ on:
permissions:
contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
source_sha: ${{ steps.validate.outputs.source_sha }}
version: ${{ steps.validate.outputs.version }}
channel: ${{ steps.validate.outputs.channel }}
prerelease: ${{ steps.validate.outputs.prerelease }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate protected release tag
id: validate
- name: Validate tag and commit
env:
REF_PROTECTED: ${{ github.ref_protected }}
REPOSITORY: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
[[ "$REPOSITORY" == "larksuite/cli" ]] || { echo "Release tags are accepted only from larksuite/cli." >&2; exit 1; }
[[ "$REF_PROTECTED" == "true" ]] || { echo "Release tag ${TAG} must be protected by a repository ruleset." >&2; exit 1; }
preflight_file="${RUNNER_TEMP}/release-preflight.json"
node scripts/release-preflight.js --tag "$TAG" > "$preflight_file"
git fetch --no-tags origin main
head_sha="$(git rev-parse --verify 'HEAD^{commit}')"
tag_sha="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
[[ "$tag_sha" == "$head_sha" ]] || { echo "Tag ${TAG} does not resolve to checked-out HEAD." >&2; exit 1; }
if [[ "$TAG" != "v1.0.78-beta.13" ]]; then
git merge-base --is-ancestor "$head_sha" FETCH_HEAD || { echo "Tag ${TAG} is not contained in origin/main." >&2; exit 1; }
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
node - "$preflight_file" "$head_sha" "$GITHUB_OUTPUT" <<'NODE'
const fs = require("node:fs");
const [file, sourceSha, output] = process.argv.slice(2);
const result = JSON.parse(fs.readFileSync(file, "utf8"));
if (result?.ok !== true || !["stable", "beta"].includes(result.data?.releaseChannel)) {
throw new Error("release preflight returned an invalid success payload");
}
const channel = result.data.releaseChannel;
fs.appendFileSync(output, `source_sha=${sourceSha}\nversion=${result.data.tagVersion}\nchannel=${channel}\nprerelease=${channel === "beta"}\n`);
NODE
build-sign-notarize:
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: read
contents: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Prepare Apple notarization key
env:
MACOS_NOTARY_ISSUER_ID: ${{ vars.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ vars.MACOS_NOTARY_KEY_ID }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_TEAM_ID: ${{ vars.MACOS_TEAM_ID }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
set +x
for name in MACOS_SIGN_P12 MACOS_SIGN_PASSWORD MACOS_NOTARY_KEY MACOS_TEAM_ID MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do
[[ -n "${!name:-}" ]] || { echo "Required Apple release input ${name} is not configured." >&2; exit 1; }
done
umask 077
notary_key="$(mktemp "${RUNNER_TEMP}/macos-notary-key.XXXXXX")"
printf '%s' "$MACOS_NOTARY_KEY" > "$notary_key"
chmod 0600 "$notary_key"
printf 'MACOS_NOTARY_KEY_PATH=%s\n' "$notary_key" >> "$GITHUB_ENV"
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: v2.17.1
args: release --clean --skip=publish
version: '~> v2'
args: release --clean
env:
MACOS_NOTARY_ISSUER_ID: ${{ vars.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ vars.MACOS_NOTARY_KEY_ID }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
GITHUB_TOKEN: ${{ github.token }}
- name: Clean up Apple notarization key
if: ${{ always() }}
run: |
set -euo pipefail
set +x
[[ -z "${MACOS_NOTARY_KEY_PATH:-}" ]] || rm -f -- "$MACOS_NOTARY_KEY_PATH"
- name: Build release candidate
env:
VERSION: ${{ needs.preflight.outputs.version }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
mkdir release-candidate
cp dist/*.tar.gz dist/*.zip dist/checksums.txt release-candidate/
cp dist/checksums.txt checksums.txt
npm install --global npm@11.16.0
pack_json="$(npm pack --ignore-scripts --json --pack-destination release-candidate)"
node - "$pack_json" "$VERSION" <<'NODE'
const [payload, version] = process.argv.slice(2);
const packs = JSON.parse(payload);
if (!Array.isArray(packs) || packs.length !== 1 || packs[0]?.name !== "@larksuite/cli" || packs[0]?.version !== version || !/^[^/\\]+\.tgz$/.test(packs[0]?.filename || "")) {
throw new Error("npm pack did not produce the expected release package");
}
NODE
- name: Upload release candidate
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
- name: Collect release asset
run: |
set -euo pipefail
mkdir 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
with:
name: release-candidate-${{ github.run_id }}
path: release-candidate/
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
create-draft-release:
needs: [preflight, build-sign-notarize]
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.preflight.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Download release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-candidate-${{ github.run_id }}
path: release-candidate
- name: Verify tag still points to source commit
env:
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
[[ "$(git rev-parse "refs/tags/${TAG}^{commit}")" == "$SOURCE_SHA" ]] || { echo "Release tag changed after preflight." >&2; exit 1; }
- name: Create or reuse Draft Release
env:
GH_TOKEN: ${{ github.token }}
PRERELEASE: ${{ needs.preflight.outputs.prerelease }}
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if gh release view "$TAG" --json isDraft >/dev/null 2>&1; then
if [[ "$(gh release view "$TAG" --json isDraft -q .isDraft)" != "true" ]]; then
existing="$(mktemp -d "${RUNNER_TEMP}/published-release.XXXXXX")"
trap 'rm -rf -- "$existing"' EXIT
gh release download "$TAG" --dir "$existing"
cmp --silent release-candidate/checksums.txt "$existing/checksums.txt" || { echo "Published Release checksums do not match the current candidate." >&2; exit 1; }
(cd "$existing" && sha256sum --check checksums.txt)
diff --brief \
<(find release-candidate -maxdepth 1 -type f ! -name '*.tgz' -printf '%f\n' | sort) \
<(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
exit 0
fi
else
args=("$TAG" --target "$SOURCE_SHA" --title "$TAG" --draft)
[[ "$PRERELEASE" != "true" ]] || args+=(--prerelease)
gh release create "${args[@]}"
fi
gh release upload "$TAG" release-candidate/*.tar.gz release-candidate/*.zip release-candidate/checksums.txt --clobber
diff --brief \
<(find release-candidate -maxdepth 1 -type f ! -name '*.tgz' -printf '%f\n' | sort) \
<(gh release view "$TAG" --json assets -q '.assets[].name' | sort)
verify-macos:
needs: [preflight, create-draft-release]
permissions:
# Draft Release assets require repository write access to download.
contents: write
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
arch: amd64
- runner: macos-15
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- name: Verify notarized macOS binary
env:
ARCH: ${{ matrix.arch }}
GH_TOKEN: ${{ github.token }}
MACOS_TEAM_ID: ${{ vars.MACOS_TEAM_ID }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
[[ -n "$MACOS_TEAM_ID" ]] || { echo "MACOS_TEAM_ID is not configured." >&2; exit 1; }
archive="lark-cli-${VERSION}-darwin-${ARCH}.tar.gz"
work="$(mktemp -d "${RUNNER_TEMP}/macos-release.XXXXXX")"
trap 'rm -rf -- "$work"' EXIT
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$archive" --pattern checksums.txt --dir "$work"
awk -v archive="$archive" '$2 == archive { print }' "$work/checksums.txt" > "$work/checksum.txt"
[[ "$(wc -l < "$work/checksum.txt" | tr -d '[:space:]')" == "1" ]] || { echo "checksums.txt must contain exactly one entry for ${archive}." >&2; exit 1; }
(cd "$work" && shasum -a 256 -c checksum.txt)
tar -xzf "$work/$archive" -C "$work"
binary="$work/lark-cli"
[[ -f "$binary" && ! -L "$binary" ]] || { echo "Archive did not contain a regular lark-cli binary." >&2; exit 1; }
codesign --verify --strict --verbose=4 "$binary"
details="$(codesign -dv --verbose=4 "$binary" 2>&1)"
grep -Eq '^Authority=Developer ID Application: .+' <<<"$details"
grep -Fxq "TeamIdentifier=${MACOS_TEAM_ID}" <<<"$details"
grep -Fq 'flags=0x10000(runtime)' <<<"$details"
grep -Eq '^Timestamp=.+' <<<"$details"
codesign --verify --strict --verbose=4 --check-notarization -R='notarized' "$binary"
"$binary" --version | grep -Fq "$VERSION"
publish-github:
needs: [preflight, create-draft-release, verify-macos]
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.preflight.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Verify tag still points to source commit
env:
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
[[ "$(git rev-parse "refs/tags/${TAG}^{commit}")" == "$SOURCE_SHA" ]] || { echo "Release tag changed after preflight." >&2; exit 1; }
- name: Publish verified Draft Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: gh release edit "$TAG" --draft=false
publish-npm:
needs: [preflight, build-sign-notarize, publish-github]
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: ${{ needs.preflight.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-candidate-${{ github.run_id }}
path: release-candidate
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Verify tag still points to source commit
env:
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}"
[[ "$(git rev-parse "refs/tags/${TAG}^{commit}")" == "$SOURCE_SHA" ]] || { echo "Release tag changed after preflight." >&2; exit 1; }
- name: Publish or verify npm package
env:
CHANNEL: ${{ needs.preflight.outputs.channel }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
shopt -s nullglob
packages=(release-candidate/*.tgz)
(( ${#packages[@]} == 1 )) || { echo "Expected exactly one npm package." >&2; exit 1; }
tgz="${packages[0]}"
tar -xOzf "$tgz" package/checksums.txt > "${RUNNER_TEMP}/checksums.txt"
cmp --silent release-candidate/checksums.txt "${RUNNER_TEMP}/checksums.txt" || { echo "npm package checksums do not match the release candidate." >&2; exit 1; }
integrity="$(node - "$tgz" <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");
const hash = crypto.createHash("sha512");
hash.update(fs.readFileSync(process.argv[2]));
process.stdout.write(`sha512-${hash.digest("base64")}`);
NODE
)"
dist_tag=latest
[[ "$CHANNEL" != "beta" ]] || dist_tag=beta
if npm view "@larksuite/cli@${VERSION}" version --json >/dev/null 2>&1; then
published="$(npm view "@larksuite/cli@${VERSION}" dist.integrity --json | tr -d '"[:space:]')"
[[ "$published" == "$integrity" ]] || { echo "Existing npm version has different package integrity." >&2; exit 1; }
current="$(npm view @larksuite/cli "dist-tags.${dist_tag}" --json | tr -d '"[:space:]')"
[[ "$current" == "$VERSION" ]] || { echo "Existing npm version is not assigned to ${dist_tag}; repair registry state manually." >&2; exit 1; }
else
npm publish "./$tgz" --access public --provenance --tag "$dist_tag"
fi
retry-guidance:
needs: [preflight, build-sign-notarize, create-draft-release, verify-macos, publish-github, publish-npm]
if: ${{ always() && (needs.preflight.result == 'failure' || needs.build-sign-notarize.result == 'failure' || needs.create-draft-release.result == 'failure' || needs.verify-macos.result == 'failure' || needs.publish-github.result == 'failure' || needs.publish-npm.result == 'failure') }}
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- name: Write retry guidance
env:
PREFLIGHT_RESULT: ${{ needs.preflight.result }}
BUILD_RESULT: ${{ needs.build-sign-notarize.result }}
DRAFT_RESULT: ${{ needs.create-draft-release.result }}
VERIFY_RESULT: ${{ needs.verify-macos.result }}
GITHUB_RESULT: ${{ needs.publish-github.result }}
NPM_RESULT: ${{ needs.publish-npm.result }}
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
{
echo "## Release retry guidance"
echo
echo "This job only records recovery guidance; it does not retry or publish anything."
echo
echo "| Job | Result |"
echo "| --- | --- |"
echo "| preflight | ${PREFLIGHT_RESULT} |"
echo "| build-sign-notarize | ${BUILD_RESULT} |"
echo "| create-draft-release | ${DRAFT_RESULT} |"
echo "| verify-macos | ${VERIFY_RESULT} |"
echo "| publish-github | ${GITHUB_RESULT} |"
echo "| publish-npm | ${NPM_RESULT} |"
printf '%s\n' \
'' \
'Select the recovery action from the failed-step diagnosis:' \
'' \
'- **preflight:** network or fetch failure → retry preflight. Version/tag validation failure → correct it, then create a new tag.' \
'- **build-sign-notarize:** transient build/service failure → retry build. Code or release configuration issue → correct it, then create a new tag.' \
'- **create-draft-release:** GitHub Draft Release API/upload failure → retry draft. Release-candidate inconsistency → retry build.' \
'- **verify-macos:** runner or network failure → retry only the failed matrix child. Checksum, signing, notarization, or runtime failure → retry build.' \
'- **publish-github:** GitHub publish network failure → retry GitHub publish. Install issue → retry build. Tag/assets inconsistency → stop and publish a new version.' \
'- **publish-npm:** network failure or uncertain publish outcome → retry npm only after verifying whether that version already exists. Integrity mismatch → publish a new version.'
} >> "$GITHUB_STEP_SUMMARY"
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
run: npm publish --access public

View File

@@ -5,8 +5,7 @@ before:
- python3 scripts/fetch_meta.py
builds:
- id: lark-cli
binary: lark-cli
- binary: lark-cli
env:
- CGO_ENABLED=0
ldflags:
@@ -20,27 +19,11 @@ builds:
- arm64
- riscv64
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
ids:
- lark-cli
sign:
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
notarize:
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
key: "{{ .Env.MACOS_NOTARY_KEY_PATH }}"
wait: true
timeout: 20m
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
format: zip
files:
- README.md
- LICENSE

View File

@@ -62,10 +62,21 @@ Both notices recommend the same fix command: `lark-cli update`. The skills notic
| `internal/credential/` | Credential provider chain (extension → default) |
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
| `internal/core/config.go` | Multi-profile config loading/saving |
| `brand/` | Brand (feishu/lark) and its endpoint hosts — repo root, so `extension/` may import it |
| `internal/workspace/` | Workspace detection plus the config and runtime directory paths |
| `internal/identity/` | The `--as` identity (user/bot) and the strict-mode policy |
| `internal/config/config.go` | Multi-profile config loading/saving |
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
| `internal/validate/path.go` | Path safety validation |
`internal/core` is gone. Besides the four packages above it also became
`internal/secret` (app secret storage and resolution) and `internal/risk` (the
read / write / high-risk-write vocabulary). Import the narrowest one you need:
`brand`, `internal/workspace`, `internal/identity`, `internal/secret` and
`internal/risk` do not import each other — only `internal/config` sits on top of
them — so asking for a config directory no longer drags in keychain, i18n and
validate.
## Who Uses This CLI
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.

View File

@@ -2,6 +2,43 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
@@ -1685,6 +1722,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75

View File

@@ -49,8 +49,8 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/check-layering-ratchet.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

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -1,27 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
package brand
import "strings"
// LarkBrand represents the Lark platform brand.
// Brand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// ParseBrand and ResolveEndpoints map unrecognized values to BrandFeishu.
type LarkBrand string
// ParseBrand and ResolveEndpoints map unrecognized values to Feishu.
type Brand string
const (
BrandFeishu LarkBrand = "feishu"
BrandLark LarkBrand = "lark"
Feishu Brand = "feishu"
Lark Brand = "lark"
)
// ParseBrand normalizes a brand string (case-insensitive, whitespace-tolerant);
// anything other than "lark" normalizes to BrandFeishu.
func ParseBrand(value string) LarkBrand {
// anything other than "lark" normalizes to Feishu.
func ParseBrand(value string) Brand {
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
return BrandLark
return Lark
}
return BrandFeishu
return Feishu
}
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
@@ -40,9 +40,9 @@ type Endpoints struct {
// ResolveEndpoints resolves endpoint URLs for the brand, normalizing its
// input so stored values with unusual casing still resolve correctly.
func ResolveEndpoints(brand LarkBrand) Endpoints {
func ResolveEndpoints(brand Brand) Endpoints {
switch ParseBrand(string(brand)) {
case BrandLark:
case Lark:
return Endpoints{
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
@@ -60,6 +60,6 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
}
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
func ResolveOpenBaseURL(brand LarkBrand) string {
func ResolveOpenBaseURL(brand Brand) string {
return ResolveEndpoints(brand).Open
}

View File

@@ -1,12 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
package brand
import "testing"
func TestResolveEndpoints_Feishu(t *testing.T) {
ep := ResolveEndpoints(BrandFeishu)
ep := ResolveEndpoints(Feishu)
if ep.Open != "https://open.feishu.cn" {
t.Errorf("Open = %q, want feishu.cn", ep.Open)
}
@@ -22,7 +22,7 @@ func TestResolveEndpoints_Feishu(t *testing.T) {
}
func TestResolveEndpoints_Lark(t *testing.T) {
ep := ResolveEndpoints(BrandLark)
ep := ResolveEndpoints(Lark)
if ep.Open != "https://open.larksuite.com" {
t.Errorf("Open = %q, want larksuite.com", ep.Open)
}
@@ -50,10 +50,10 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
}
func TestResolveOpenBaseURL(t *testing.T) {
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" {
if got := ResolveOpenBaseURL(Feishu); got != "https://open.feishu.cn" {
t.Errorf("ResolveOpenBaseURL(feishu) = %q", got)
}
if got := ResolveOpenBaseURL(BrandLark); got != "https://open.larksuite.com" {
if got := ResolveOpenBaseURL(Lark); got != "https://open.larksuite.com" {
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
}
}
@@ -61,15 +61,15 @@ func TestResolveOpenBaseURL(t *testing.T) {
func TestParseBrand(t *testing.T) {
cases := []struct {
in string
want LarkBrand
want Brand
}{
{"", BrandFeishu},
{"feishu", BrandFeishu},
{"lark", BrandLark},
{"LARK", BrandLark},
{" lark ", BrandLark},
{"Lark", BrandLark},
{"xyz", BrandFeishu},
{"", Feishu},
{"feishu", Feishu},
{"lark", Lark},
{"LARK", Lark},
{" lark ", Lark},
{"Lark", Lark},
{"xyz", Feishu},
}
for _, c := range cases {
if got := ParseBrand(c.in); got != c.want {
@@ -83,11 +83,11 @@ func TestParseBrand(t *testing.T) {
// unusual casing or whitespace still resolve to their intended endpoints.
func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
for _, raw := range []string{"LARK", " lark ", "Lark"} {
if got := ResolveEndpoints(LarkBrand(raw)).Open; got != "https://open.larksuite.com" {
if got := ResolveEndpoints(Brand(raw)).Open; got != "https://open.larksuite.com" {
t.Errorf("ResolveEndpoints(%q).Open = %q, want the lark endpoint", raw, got)
}
}
if got := ResolveEndpoints(LarkBrand("unexpected")).Open; got != "https://open.feishu.cn" {
if got := ResolveEndpoints(Brand("unexpected")).Open; got != "https://open.feishu.cn" {
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
}
}

View File

@@ -13,7 +13,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -33,7 +34,7 @@ type APIOptions struct {
// Flags
Params string
Data string
As core.Identity
As identity.Identity
Output string
PageAll bool
PageSize int
@@ -87,7 +88,7 @@ Examples:
opts.Path = args[1]
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
opts.As = identity.Identity(asStr)
if runF != nil {
return runF(opts)
}
@@ -304,7 +305,7 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}

View File

@@ -13,11 +13,13 @@ import (
"net/http"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
)
@@ -44,10 +46,10 @@ func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer,
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
config := &configpkg.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
Brand: brand.Feishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
@@ -62,7 +64,7 @@ func apiPaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
As: identity.AsBot,
}
}

View File

@@ -16,11 +16,13 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/spf13/cobra"
)
@@ -40,8 +42,8 @@ func newTestRootCmd() *cobra.Command {
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
@@ -60,7 +62,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
if gotOpts.Path != "/open-apis/test" {
t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path)
}
if gotOpts.As != core.AsBot {
if gotOpts.As != identity.AsBot {
t.Errorf("expected as=bot, got %s", gotOpts.As)
}
if !gotOpts.DryRun {
@@ -69,8 +71,8 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -104,8 +106,8 @@ func TestApiCmd_DryRun(t *testing.T) {
}
func TestApiCmd_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -122,8 +124,8 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -137,8 +139,8 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
// Register API endpoint stub
@@ -170,8 +172,8 @@ func TestApiCmd_BotMode(t *testing.T) {
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -183,8 +185,8 @@ func TestApiCmd_MissingArgs(t *testing.T) {
}
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -199,8 +201,8 @@ func TestApiCmd_EmptyMethodRejected(t *testing.T) {
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -212,8 +214,8 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
}
func TestApiValidArgsFunction(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -278,8 +280,8 @@ func TestApiValidArgsFunction(t *testing.T) {
}
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
@@ -296,8 +298,8 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
@@ -316,8 +318,8 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
@@ -332,8 +334,8 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
@@ -355,8 +357,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -405,8 +407,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
}
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: brand.Feishu,
})
// Register a non-batch API that returns scalar data (no array field)
@@ -449,8 +451,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
}
func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: brand.Feishu,
})
// Non-batch API that returns a business error (code != 0)
@@ -486,8 +488,8 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: brand.Feishu,
})
// Register a batch API that returns an array field
@@ -519,8 +521,8 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -561,8 +563,8 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -627,8 +629,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -678,8 +680,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -723,8 +725,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -809,8 +811,8 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
}
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
@@ -829,8 +831,8 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
}
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
@@ -849,8 +851,8 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
}
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -867,8 +869,8 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
}
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -901,8 +903,8 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
}
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -919,8 +921,8 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
}
func TestApiCmd_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -937,8 +939,8 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
}
func TestApiCmd_PageAll_WithJq(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -968,8 +970,8 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
}
func TestApiCmd_MethodUppercase(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
@@ -988,8 +990,8 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
}
func TestApiCmd_FileFlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -1007,8 +1009,8 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
}
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1024,8 +1026,8 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
}
func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1041,8 +1043,8 @@ func TestApiCmd_FileWithGET(t *testing.T) {
}
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1064,8 +1066,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
@@ -1102,8 +1104,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
// — there is no raw-payload passthrough; new Lark diagnostic fields require
// a CLI release.
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -1141,8 +1143,8 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *APIOptions
@@ -1194,8 +1196,8 @@ func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]stri
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
dir := t.TempDir()
@@ -1223,8 +1225,8 @@ func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
dir := t.TempDir()
@@ -1258,8 +1260,8 @@ func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
dir := t.TempDir()
@@ -1291,8 +1293,8 @@ func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))

View File

@@ -16,8 +16,8 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/identity"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -130,7 +130,7 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
}, core.AsBot)
}, identity.AsBot)
if err != nil {
return nil, err
}
@@ -170,7 +170,7 @@ func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory
}
raw["code"] = code
raw["msg"] = msg
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
cc := errclass.ClassifyContext{Identity: string(identity.AsBot)}
if cfg, _ := f.Config(); cfg != nil {
cc.Brand = string(cfg.Brand)
cc.AppID = appId

View File

@@ -12,10 +12,11 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -23,8 +24,8 @@ import (
)
func TestAuthLoginCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *LoginOptions
@@ -46,8 +47,8 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
}
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
@@ -72,8 +73,8 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
}
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *CheckOptions
@@ -92,8 +93,8 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *CheckOptions
@@ -192,8 +193,8 @@ func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *StatusOptions
@@ -211,8 +212,8 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *StatusOptions
@@ -234,8 +235,8 @@ func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *StatusOptions
@@ -336,8 +337,8 @@ func TestDomainFlagCompletion(t *testing.T) {
}
func TestAuthScopesCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *ScopesOptions
@@ -356,8 +357,8 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *ScopesOptions
@@ -382,8 +383,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "", Brand: brand.Feishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
@@ -438,8 +439,8 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
// getAppInfo classifies it as *errs.PermissionError carrying the server-
// supplied MissingScopes — not a bare error wrapped as InternalError.
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)

View File

@@ -9,9 +9,10 @@ import (
"testing"
"time"
"github.com/larksuite/cli/brand"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -23,8 +24,8 @@ import (
// branch. These tests pin that contract end-to-end through the dispatcher.
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
// UserOpenId left empty: triggers the not_logged_in branch.
})
@@ -55,8 +56,8 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
}
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
UserOpenId: "ou_user", UserName: "tester",
})
@@ -92,10 +93,10 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
cfg := &configpkg.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
Brand: brand.Feishu,
UserOpenId: "ou_user",
UserName: "tester",
}
@@ -150,8 +151,8 @@ 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
// stderr envelope, distinct from the silent ErrBare predicate path.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
)
@@ -45,7 +45,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
func authListRun(opts *ListOptions) error {
f := opts.Factory
multi, _ := core.LoadMultiAppConfig()
multi, _ := configpkg.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
@@ -61,7 +61,7 @@ func authListRun(opts *ListOptions) error {
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)

View File

@@ -9,7 +9,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/workspace"
)
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
@@ -69,9 +69,9 @@ func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prev := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
prev := workspace.CurrentWorkspace()
t.Cleanup(func() { workspace.SetCurrentWorkspace(prev) })
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {

View File

@@ -13,12 +13,14 @@ import (
"github.com/spf13/cobra"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -55,7 +57,7 @@ send the verification URL (or QR code) to the user as your final message, end th
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
if mode := f.ResolveStrictMode(cmd.Context()); mode == identity.StrictModeBot {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, user login is disabled in this profile", mode).
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
@@ -72,7 +74,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
var helpBrand core.LarkBrand
var helpBrand brandpkg.Brand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
@@ -125,7 +127,7 @@ func authLoginRun(opts *LoginOptions) error {
// Determine UI language from saved config
var lang i18n.Lang
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if multi, _ := configpkg.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
}
@@ -391,7 +393,7 @@ func authLoginRun(opts *LoginOptions) error {
// authLoginPollDeviceCode resumes the device flow by polling with a device code
// obtained from a previous --no-wait call.
func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
func authLoginPollDeviceCode(opts *LoginOptions, config *configpkg.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
f := opts.Factory
httpClient, err := f.HttpClient()
@@ -474,7 +476,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := configpkg.LoadMultiAppConfig()
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
}
@@ -484,9 +486,9 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
}
oldUsers := append([]core.AppUser(nil), app.Users...)
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
oldUsers := append([]configpkg.AppUser(nil), app.Users...)
app.Users = []configpkg.AppUser{{UserOpenId: openID, UserName: userName}}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
}
@@ -499,7 +501,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
func findProfileByName(multi *configpkg.MultiAppConfig, profileName string) *configpkg.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
return &multi.Apps[i]
@@ -512,7 +514,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
func collectScopesForDomains(domains []string, identity string, brand brandpkg.Brand) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -553,7 +555,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains(brand core.LarkBrand) map[string]bool {
func allKnownDomains(brand brandpkg.Brand) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -572,7 +574,7 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains(brand core.LarkBrand) []string {
func sortedKnownDomains(brand brandpkg.Brand) []string {
m := allKnownDomains(brand)
domains := make([]string, 0, len(m))
for d := range m {

View File

@@ -6,26 +6,26 @@ package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/brand"
)
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(core.BrandFeishu)
feishuDomains := allKnownDomains(brand.Feishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}
larkDomains := allKnownDomains(core.BrandLark)
larkDomains := allKnownDomains(brand.Lark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Feishu)
if len(feishuScopes) == 0 {
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
}
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
larkScopes := collectScopesForDomains([]string{"apps"}, "user", brand.Lark)
if len(larkScopes) != 0 {
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
}

View File

@@ -7,7 +7,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
)
func setupLoginConfigDir(t *testing.T) {
@@ -17,22 +17,22 @@ func setupLoginConfigDir(t *testing.T) {
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
setupLoginConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "target",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
Name: "target",
AppId: "app-target",
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
Users: []configpkg.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
},
{
Name: "other",
AppId: "app-other",
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
Users: []configpkg.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -40,7 +40,7 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
t.Fatalf("syncLoginUserToProfile() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -54,13 +54,13 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
setupLoginConfigDir(t)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
Name: "default",
AppId: "app-default",
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}

View File

@@ -10,9 +10,9 @@ import (
"github.com/charmbracelet/huh"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -102,7 +102,7 @@ func buildDomainMeta(name, lang string) domainMeta {
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand brandpkg.Brand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options

View File

@@ -11,9 +11,9 @@ import (
"regexp"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/workspace"
)
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
@@ -25,7 +25,7 @@ type loginScopeCacheRecord struct {
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
// requested scopes keyed by device_code.
func loginScopeCacheDir() string {
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
return filepath.Join(workspace.GetConfigDir(), "cache", "auth_login_scopes")
}
// loginScopeCachePath returns the cache file path for a given device_code.

View File

@@ -9,11 +9,11 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
)
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
cfg := &core.CliConfig{
cfg := &configpkg.CliConfig{
AppID: "a", AppSecret: "s",
SupportedIdentities: uint8(extcred.SupportsBot),
}
@@ -39,7 +39,7 @@ func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
}
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
cfg := &core.CliConfig{
cfg := &configpkg.CliConfig{
AppID: "a", AppSecret: "s",
SupportedIdentities: uint8(extcred.SupportsUser),
}
@@ -62,7 +62,7 @@ func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
}
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "a", AppSecret: "s"})
var called bool
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {

View File

@@ -14,9 +14,10 @@ import (
"strings"
"testing"
brandpkg "github.com/larksuite/cli/brand"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
@@ -308,8 +309,8 @@ func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
}
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: brandpkg.Feishu,
})
// TestFactory has IsTerminal=false by default
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
@@ -600,21 +601,21 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -696,7 +697,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
if stored.Scope != "offline_access" {
t.Fatalf("stored scope = %q", stored.Scope)
}
cfg, err := core.LoadMultiAppConfig()
cfg, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -716,21 +717,21 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -847,15 +848,15 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
err := authLoginRun(&LoginOptions{
@@ -886,15 +887,15 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -956,11 +957,11 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
}
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
f.IOStreams.Out = failWriter{}
@@ -993,11 +994,11 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
}
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -1067,11 +1068,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
f.IOStreams.Out = failWriter{}
@@ -1105,11 +1106,11 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
}
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brandpkg.Feishu,
})
reg.Register(&httpmock.Stub{

View File

@@ -11,8 +11,9 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
)
// LogoutOptions holds all inputs for auth logout.
@@ -44,7 +45,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
func authLogoutRun(opts *LogoutOptions) error {
f := opts.Factory
multi, _ := core.LoadMultiAppConfig()
multi, _ := configpkg.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
@@ -73,7 +74,7 @@ func authLogoutRun(opts *LogoutOptions) error {
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
appSecret, secretErr := secret.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
@@ -94,8 +95,8 @@ func authLogoutRun(opts *LogoutOptions) error {
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
app.Users = []configpkg.AppUser{}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {

View File

@@ -9,22 +9,24 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/secret"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
func writeLogoutConfig(t *testing.T, users []configpkg.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("test-secret"),
Brand: brand.Feishu,
Users: users,
},
},
@@ -91,7 +93,7 @@ func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
@@ -127,7 +129,7 @@ func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
writeLogoutConfig(t, []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
@@ -153,19 +155,19 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
@@ -177,11 +179,11 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -210,7 +212,7 @@ func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -224,19 +226,19 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
@@ -247,11 +249,11 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -280,7 +282,7 @@ func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -294,19 +296,19 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
@@ -318,11 +320,11 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -346,7 +348,7 @@ func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}

View File

@@ -11,14 +11,15 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *QRCodeOptions
@@ -45,8 +46,8 @@ func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *QRCodeOptions

View File

@@ -9,9 +9,10 @@ import (
"fmt"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
)
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
@@ -31,10 +32,10 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
// and reach the getAppInfoFn call.
func scopesTestFactory(t *testing.T) *ScopesOptions {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
Brand: brand.Feishu,
})
return &ScopesOptions{
Factory: f,

View File

@@ -8,14 +8,15 @@ import (
"net/http"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
})
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
@@ -38,8 +39,8 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
Method: http.MethodGet,

View File

@@ -8,6 +8,7 @@ import (
"io"
"io/fs"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
@@ -25,7 +26,6 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
@@ -44,7 +44,7 @@ type buildConfig struct {
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
startupBrand brandpkg.Brand
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -52,7 +52,7 @@ type buildConfig struct {
// registry's sync.Once locks onto the Feishu default at first catalog access,
// long before the lazily-resolved config brand is known — see
// ResolveStartupBrand for the caller-side resolution.
func WithStartupBrand(brand core.LarkBrand) BuildOption {
func WithStartupBrand(brand brandpkg.Brand) BuildOption {
return func(c *buildConfig) {
c.startupBrand = brand
}

View File

@@ -14,12 +14,15 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/workspace"
)
// BindOptions holds all inputs for config bind.
@@ -128,8 +131,8 @@ func configBindRun(opts *BindOptions) error {
if err != nil {
return err
}
core.SetCurrentWorkspace(core.Workspace(source))
targetConfigPath := core.GetConfigPath()
workspace.SetCurrentWorkspace(workspace.Workspace(source))
targetConfigPath := workspace.GetConfigPath()
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
if err != nil {
@@ -186,12 +189,12 @@ func finalizeSource(opts *BindOptions) (string, error) {
}
var detected string
switch core.DetectWorkspaceFromEnv(os.Getenv) {
case core.WorkspaceOpenClaw:
switch workspace.DetectWorkspaceFromEnv(os.Getenv) {
case workspace.WorkspaceOpenClaw:
detected = "openclaw"
case core.WorkspaceHermes:
case workspace.WorkspaceHermes:
detected = "hermes"
case core.WorkspaceLarkChannel:
case workspace.WorkspaceLarkChannel:
detected = "lark-channel"
}
@@ -264,7 +267,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
// enumerate candidates, pick one via the shared decision layer, and build a
// ready-to-persist AppConfig. Adding a new bind source only requires
// implementing SourceBinder — none of the logic below needs to change.
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
func resolveAccount(opts *BindOptions, source string) (*configpkg.AppConfig, error) {
binder, err := newBinder(source, opts)
if err != nil {
return nil, err
@@ -307,12 +310,12 @@ func resolveIdentity(opts *BindOptions) error {
// the bind flow treats a corrupt previous config (commitBinding will
// overwrite it cleanly).
func hasStrictBotLock(data []byte) bool {
var multi core.MultiAppConfig
var multi configpkg.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return false
}
for _, app := range multi.Apps {
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
if app.StrictMode != nil && *app.StrictMode == identity.StrictModeBot {
return true
}
}
@@ -369,16 +372,16 @@ func preferredLang(requested, prior i18n.Lang) i18n.Lang {
return prior
}
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
func applyPreferences(appConfig *configpkg.AppConfig, opts *BindOptions, prior i18n.Lang) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
sm := identity.StrictModeBot
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsBot
appConfig.DefaultAs = identity.AsBot
case "user-default":
sm := core.StrictModeOff
sm := identity.StrictModeOff
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
appConfig.DefaultAs = identity.AsUser
}
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
}
@@ -389,7 +392,7 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi core.MultiAppConfig
var multi configpkg.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
@@ -404,10 +407,10 @@ func priorLang(previousConfigBytes []byte) i18n.Lang {
// any), and a JSON success envelope. Cleanup runs only after the new config
// is durably written — if anything fails earlier, the old workspace stays
// usable.
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
func commitBinding(opts *BindOptions, appConfig *configpkg.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{*appConfig}}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
if err := vfs.MkdirAll(workspace.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
}
data, err := json.MarshalIndent(multi, "", " ")
@@ -476,8 +479,8 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
// the secret that ForStorage just wrote (old and new secret share the same
// keychain key, derived from appId). Best-effort: errors are silently
// ignored (same contract as config init's cleanup).
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
var multi core.MultiAppConfig
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *configpkg.AppConfig) {
var multi configpkg.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return
}
@@ -489,7 +492,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
continue
}
core.RemoveSecretStore(app.AppSecret, kc)
secret.RemoveSecretStore(app.AppSecret, kc)
}
}
@@ -503,13 +506,13 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
var source string
// Pre-select based on detected env signals
detected := core.DetectWorkspaceFromEnv(os.Getenv)
detected := workspace.DetectWorkspaceFromEnv(os.Getenv)
switch detected {
case core.WorkspaceOpenClaw:
case workspace.WorkspaceOpenClaw:
source = "openclaw"
case core.WorkspaceHermes:
case workspace.WorkspaceHermes:
source = "hermes"
case core.WorkspaceLarkChannel:
case workspace.WorkspaceLarkChannel:
source = "lark-channel"
default:
source = "openclaw" // default first option
@@ -582,7 +585,7 @@ func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, er
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
if data, err := vfs.ReadFile(configPath); err == nil {
var multi core.MultiAppConfig
var multi configpkg.MultiAppConfig
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
app := multi.Apps[0]
existingSummary = fmt.Sprintf(msg.ConflictDesc,

View File

@@ -13,11 +13,15 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/workspace"
)
// wantErrDetail is the normalized comparison shape for a typed error's wire
@@ -80,8 +84,8 @@ func assertEnvelope(t *testing.T, stdout []byte, want map[string]any) {
// Must be called at the start of any test that may trigger configBindRun (which sets workspace).
func saveWorkspace(t *testing.T) {
t.Helper()
orig := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(orig) })
orig := workspace.CurrentWorkspace()
t.Cleanup(func() { workspace.SetCurrentWorkspace(orig) })
}
// ── Command flag parsing tests (aligned with config_test.go pattern) ──
@@ -229,7 +233,7 @@ func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
}
multi, err := core.LoadMultiAppConfig()
multi, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
@@ -265,7 +269,7 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
t.Fatalf("re-bind (no --lang): %v", err)
}
multi, err := core.LoadMultiAppConfig()
multi, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
@@ -279,9 +283,9 @@ func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
// workspace (set up via `profile add` before a re-bind), the active profile's
// Lang must win over a sibling profile that happens to sit earlier in the slice.
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
multi := core.MultiAppConfig{
multi := configpkg.MultiAppConfig{
CurrentApp: "active",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
},
@@ -300,8 +304,8 @@ func TestPriorLang_RespectsCurrentApp(t *testing.T) {
// so a bind-written config (which always has exactly one app and no
// CurrentApp field) still inherits its Lang.
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
multi := core.MultiAppConfig{
Apps: []core.AppConfig{
multi := configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{
{AppId: "cli_only", Lang: i18n.LangJaJP},
},
}
@@ -639,8 +643,8 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
// Brand is not in the stdout envelope — read it back from the persisted
// workspace config to verify accounts.app.tenant flowed through to the
// stored AppConfig.Brand field.
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
multi, err := core.LoadMultiAppConfig()
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
multi, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("load workspace config: %v", err)
}
@@ -686,8 +690,8 @@ func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
t.Fatalf("expected success, got error: %v", err)
}
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
multi, err := core.LoadMultiAppConfig()
workspace.SetCurrentWorkspace(workspace.WorkspaceLarkChannel)
multi, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("load workspace config: %v", err)
}
@@ -801,16 +805,16 @@ func TestConfigShowRun_WorkspaceField(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
core.SetCurrentWorkspace(core.WorkspaceLocal)
workspace.SetCurrentWorkspace(workspace.WorkspaceLocal)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
AppId: "cli_local_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("save: %v", err)
}
@@ -827,7 +831,7 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
workspace.SetCurrentWorkspace(workspace.WorkspaceOpenClaw)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configShowRun(&ConfigShowOptions{Factory: f})
@@ -998,7 +1002,7 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
if err != nil {
t.Fatalf("read config.json: %v", err)
}
var multi core.MultiAppConfig
var multi configpkg.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
t.Fatalf("unmarshal config.json: %v", err)
}
@@ -1008,8 +1012,8 @@ func TestConfigBindRun_HermesSuccess(t *testing.T) {
if multi.Apps[0].AppId != "cli_hermes_abc" {
t.Errorf("appId = %q, want %q", multi.Apps[0].AppId, "cli_hermes_abc")
}
if multi.Apps[0].Brand != core.BrandLark {
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, core.BrandLark)
if multi.Apps[0].Brand != brand.Lark {
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, brand.Lark)
}
}
@@ -1275,7 +1279,7 @@ func TestConfigBindRun_Identity_BotOnly_Applied(t *testing.T) {
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "en")),
})
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
core.StrictModeBot, core.AsBot)
identity.StrictModeBot, identity.AsBot)
}
// TestConfigBindRun_FlagModeDefaultsToBotOnly verifies the flag-mode default
@@ -1310,7 +1314,7 @@ func TestConfigBindRun_FlagModeDefaultsToBotOnly(t *testing.T) {
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "")),
})
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
core.StrictModeBot, core.AsBot)
identity.StrictModeBot, identity.AsBot)
}
// TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce verifies the
@@ -1406,7 +1410,7 @@ func TestConfigBindRun_IdentityEscalationWithForceAllowed(t *testing.T) {
t.Fatalf("expected --force to allow the escalation, got: %v", err)
}
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
core.StrictModeOff, core.AsUser)
identity.StrictModeOff, identity.AsUser)
}
// TestConfigBindRun_AllowsRebindSameBotOnly verifies re-binding the same
@@ -1442,7 +1446,7 @@ func TestConfigBindRun_AllowsRebindSameBotOnly(t *testing.T) {
t.Fatalf("expected rebind to same bot-only identity to succeed, got: %v", err)
}
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
core.StrictModeBot, core.AsBot)
identity.StrictModeBot, identity.AsBot)
}
// TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig verifies that if the
@@ -1479,18 +1483,18 @@ func TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig(t *testing.T) {
t.Fatalf("expected user-default→user-default rebind to succeed, got: %v", err)
}
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
core.StrictModeOff, core.AsUser)
identity.StrictModeOff, identity.AsUser)
}
// assertPresetApplied verifies the on-disk config.json applied the identity
// preset's StrictMode + DefaultAs expansion.
func assertPresetApplied(t *testing.T, configPath string, wantStrict core.StrictMode, wantDefault core.Identity) {
func assertPresetApplied(t *testing.T, configPath string, wantStrict identity.StrictMode, wantDefault identity.Identity) {
t.Helper()
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read %s: %v", configPath, err)
}
var multi core.MultiAppConfig
var multi configpkg.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
t.Fatalf("unmarshal %s: %v", configPath, err)
}
@@ -1787,10 +1791,10 @@ func TestCleanupKeychainFromData_KeepsSecretSharedWithNewApp(t *testing.T) {
}
oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"` + sharedID + `"}}]}`)
newApp := &core.AppConfig{
newApp := &configpkg.AppConfig{
AppId: "cli_shared",
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: sharedID},
AppSecret: secret.SecretInput{
Ref: &secret.SecretRef{Source: "keychain", ID: sharedID},
},
}
@@ -1817,10 +1821,10 @@ func TestCleanupKeychainFromData_RemovesStaleSecretWhenAppIDChanges(t *testing.T
}
oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"` + oldID + `"}}]}`)
newApp := &core.AppConfig{
newApp := &configpkg.AppConfig{
AppId: "cli_new",
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: newID},
AppSecret: secret.SecretInput{
Ref: &secret.SecretRef{Source: "keychain", ID: newID},
},
}

View File

@@ -9,9 +9,11 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/openclawbind"
secretpkg "github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/vfs"
)
@@ -36,7 +38,7 @@ type SourceBinder interface {
ListCandidates() ([]Candidate, error)
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
Build(appID string) (*core.AppConfig, error)
Build(appID string) (*configpkg.AppConfig, error)
}
// newBinder constructs the SourceBinder for the given source name.
@@ -138,15 +140,15 @@ type openclawBinder struct {
path string
// Cached between ListCandidates and Build so we don't re-read / re-parse.
cfg *binding.OpenClawRoot
rawApps []binding.CandidateApp
cfg *openclawbind.OpenClawRoot
rawApps []openclawbind.CandidateApp
}
func (b *openclawBinder) Name() string { return "openclaw" }
func (b *openclawBinder) ConfigPath() string { return b.path }
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadOpenClawConfig(b.path)
cfg, err := openclawbind.ReadOpenClawConfig(b.path)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify OpenClaw is installed and configured").
@@ -157,7 +159,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
WithHint("configure Feishu in OpenClaw first")
}
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
raw := openclawbind.ListCandidateApps(cfg.Channels.Feishu)
b.cfg = cfg
b.rawApps = raw
@@ -168,12 +170,12 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
return result, nil
}
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
func (b *openclawBinder) Build(appID string) (*configpkg.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
var selected *binding.CandidateApp
var selected *openclawbind.CandidateApp
for i := range b.rawApps {
if b.rawApps[i].AppID == appID {
selected = &b.rawApps[i]
@@ -188,24 +190,24 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
WithHint("configure channels.feishu.appSecret in openclaw.json")
}
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
secret, err := openclawbind.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
}
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
stored, err := secretpkg.ForStorage(selected.AppID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &core.AppConfig{
return &configpkg.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.ParseBrand(selected.Brand),
Brand: brand.ParseBrand(selected.Brand),
}, nil
}
@@ -238,7 +240,7 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: appID, Label: "default"}}, nil
}
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
func (b *hermesBinder) Build(appID string) (*configpkg.AppConfig, error) {
if b.envMap == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -251,17 +253,17 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
WithHint("run 'hermes setup' to configure Feishu credentials")
}
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &core.AppConfig{
return &configpkg.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
Brand: brand.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
}, nil
}
@@ -274,14 +276,14 @@ type larkChannelBinder struct {
path string
// Cached between ListCandidates and Build so we don't re-read the file.
cfg *binding.LarkChannelRoot
cfg *openclawbind.LarkChannelRoot
}
func (b *larkChannelBinder) Name() string { return "lark-channel" }
func (b *larkChannelBinder) ConfigPath() string { return b.path }
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadLarkChannelConfig(b.path)
cfg, err := openclawbind.ReadLarkChannelConfig(b.path)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify lark-channel-bridge is installed and configured").
@@ -295,7 +297,7 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
}
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
func (b *larkChannelBinder) Build(appID string) (*configpkg.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -309,24 +311,24 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
// Resolve through the same SecretInput pipeline openclaw uses, so
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
secret, err := openclawbind.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
}
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
stored, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &core.AppConfig{
return &configpkg.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
Brand: brand.ParseBrand(b.cfg.Accounts.App.Tenant),
}, nil
}

View File

@@ -8,7 +8,7 @@ import (
"reflect"
"testing"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
)
@@ -20,10 +20,10 @@ type fakeBinder struct {
path string
}
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(appID string) (*configpkg.AppConfig, error) { return nil, nil }
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
// guardrail that proves the non-TUI decision paths really do stay out of the

View File

@@ -4,8 +4,8 @@
package config
import (
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
@@ -38,6 +38,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
return cmd
}
func parseBrand(value string) core.LarkBrand {
return core.ParseBrand(value)
func parseBrand(value string) brand.Brand {
return brand.ParseBrand(value)
}

View File

@@ -12,14 +12,16 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
)
type noopConfigKeychain struct{}
@@ -66,8 +68,8 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) {
}
func TestConfigShowCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
var gotOpts *ConfigShowOptions
@@ -108,16 +110,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Apps: []configpkg.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -186,18 +188,18 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
existing := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{
{AppId: "cli_x", AppSecret: secret.PlainSecret("s"), Brand: brand.Feishu, Lang: i18n.LangJaJP},
}}
if err := core.SaveMultiAppConfig(existing); err != nil {
if err := configpkg.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
if err := saveInitConfig("", existing, f, "cli_x", secret.PlainSecret("s2"), brand.Feishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := core.LoadMultiAppConfig()
got, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
@@ -318,17 +320,17 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
AppId: "app-test",
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
AppSecret: secret.SecretInput{
Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
},
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
Brand: brand.Feishu,
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -357,7 +359,7 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
if err := os.Chmod(configDir, 0700); err != nil {
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -377,18 +379,18 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{
existing := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{
{
Name: "prod",
AppId: "cli_prod",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
},
},
}
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", secret.PlainSecret("new-secret"), brand.Lark, "en")
if err == nil {
t.Fatal("expected conflict error")
}
@@ -428,21 +430,21 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
}
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "prod",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
Name: "prod",
AppId: "app-old",
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
Brand: core.BrandFeishu,
AppSecret: secret.SecretInput{Ref: &secret.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
Brand: brand.Feishu,
Lang: "zh",
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
Users: []configpkg.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
},
},
}
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
err := updateExistingProfileWithoutSecret(multi, "", "app-new", brand.Lark, "en")
if err == nil {
t.Fatal("expected error when changing app ID without a new secret")
}

View File

@@ -8,7 +8,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/spf13/cobra"
)
@@ -20,14 +21,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadOrNotConfigured()
multi, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return core.NoActiveProfileError()
return configpkg.NoActiveProfileError()
}
if len(args) == 0 {
@@ -44,8 +45,8 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
}
app.DefaultAs = core.Identity(value)
if err := core.SaveMultiAppConfig(multi); err != nil {
app.DefaultAs = identity.Identity(value)
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)

View File

@@ -13,13 +13,16 @@ import (
"github.com/spf13/cobra"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
secretpkg "github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/workspace"
)
// ConfigInitOptions holds all inputs for config init.
@@ -121,7 +124,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if opts.ForceInit {
return nil
}
ws := core.DetectWorkspaceFromEnv(os.Getenv)
ws := workspace.DetectWorkspaceFromEnv(os.Getenv)
if ws.IsLocal() {
return nil
}
@@ -136,7 +139,7 @@ func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
func cleanupOldConfig(existing *configpkg.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
if existing == nil {
return
}
@@ -144,7 +147,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
if app.AppId == skipAppID {
continue
}
core.RemoveSecretStore(app.AppSecret, f.Keychain)
secretpkg.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
@@ -152,19 +155,19 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
}
// saveAsOnlyApp overwrites config.json with a single-app config.
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
func saveAsOnlyApp(appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
config := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []configpkg.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
return configpkg.SaveMultiAppConfig(config)
}
// saveInitConfig saves a new/updated app config, respecting --profile mode.
// With profileName: appends or updates the named profile (preserves other profiles).
// Without profileName: cleans up old config and saves as the only app.
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
func saveInitConfig(profileName string, existing *configpkg.MultiAppConfig, f *cmdutil.Factory, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
if profileName != "" {
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
@@ -195,20 +198,20 @@ func wrapSaveConfigError(err error) error {
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
func saveAsProfile(existing *configpkg.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret secretpkg.SecretInput, brand brandpkg.Brand, lang string) error {
multi := existing
if multi == nil {
multi = &core.MultiAppConfig{}
multi = &configpkg.MultiAppConfig{}
}
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
// Clean up old keychain secret and user tokens if AppId changed
if multi.Apps[idx].AppId != appId {
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
secretpkg.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
for _, user := range multi.Apps[idx].Users {
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
}
multi.Apps[idx].Users = []core.AppUser{}
multi.Apps[idx].Users = []configpkg.AppUser{}
}
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
@@ -221,19 +224,19 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
WithParam("--name")
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
multi.Apps = append(multi.Apps, configpkg.AppConfig{
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
Users: []configpkg.AppUser{},
})
}
return core.SaveMultiAppConfig(multi)
return configpkg.SaveMultiAppConfig(multi)
}
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
func findProfileIndexByName(multi *configpkg.MultiAppConfig, profileName string) int {
if multi == nil {
return -1
}
@@ -245,7 +248,7 @@ func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int
return -1
}
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
func findAppIndexByAppID(multi *configpkg.MultiAppConfig, appID string) int {
if multi == nil {
return -1
}
@@ -272,13 +275,13 @@ func wrapUpdateExistingProfileErr(err error) error {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
func updateExistingProfileWithoutSecret(existing *configpkg.MultiAppConfig, profileName, appID string, brand brandpkg.Brand, lang string) error {
if existing == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
WithParam("--app-secret")
}
var app *core.AppConfig
var app *configpkg.AppConfig
if profileName != "" {
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
app = &existing.Apps[idx]
@@ -302,7 +305,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
app.AppId = appID
app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
return core.SaveMultiAppConfig(existing)
return configpkg.SaveMultiAppConfig(existing)
}
func configInitRun(opts *ConfigInitOptions) error {
@@ -323,14 +326,14 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
existing, err := core.LoadMultiAppConfig()
existing, err := configpkg.LoadMultiAppConfig()
if err != nil {
existing = nil // treat as empty
}
// Validate --profile name if set
if opts.ProfileName != "" {
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
if err := configpkg.ValidateProfileName(opts.ProfileName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
}
}
@@ -338,14 +341,14 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
secret, err := secretpkg.ForStorage(opts.AppID, secretpkg.PlainSecret(opts.appSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
@@ -377,8 +380,8 @@ func configInitRun(opts *ConfigInitOptions) error {
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
}
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
existing, _ := configpkg.LoadMultiAppConfig()
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
@@ -404,11 +407,11 @@ func configInitRun(opts *ConfigInitOptions) error {
WithParam("--app-id")
}
existing, _ := core.LoadMultiAppConfig()
existing, _ := configpkg.LoadMultiAppConfig()
if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
secret, err := secretpkg.ForStorage(result.AppID, secretpkg.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
@@ -443,7 +446,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
// Mode 5: Legacy interactive (readline fallback)
firstApp := (*core.AppConfig)(nil)
firstApp := (*configpkg.AppConfig)(nil)
if existing != nil {
firstApp = existing.CurrentAppConfig("")
}
@@ -494,9 +497,9 @@ func configInitRun(opts *ConfigInitOptions) error {
if resolvedAppId == "" && firstApp != nil {
resolvedAppId = firstApp.AppId
}
var resolvedSecret core.SecretInput
var resolvedSecret secretpkg.SecretInput
if appSecretInput != "" {
resolvedSecret = core.PlainSecret(appSecretInput)
resolvedSecret = secretpkg.PlainSecret(appSecretInput)
} else if firstApp != nil {
resolvedSecret = firstApp.AppSecret
}
@@ -513,14 +516,14 @@ func configInitRun(opts *ConfigInitOptions) error {
WithParam("--app-id")
}
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
storedSecret, err := secretpkg.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", workspace.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {

View File

@@ -10,13 +10,14 @@ import (
"net"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/build"
qrcode "github.com/skip2/go-qrcode"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
@@ -24,7 +25,7 @@ import (
// configInitResult holds the result of the interactive config init flow.
type configInitResult struct {
Mode string // "create" or "existing"
Brand core.LarkBrand
Brand brand.Brand
AppID string
AppSecret string
}
@@ -62,8 +63,8 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Load existing config for defaults
existing, _ := core.LoadMultiAppConfig()
var firstApp *core.AppConfig
existing, _ := configpkg.LoadMultiAppConfig()
var firstApp *configpkg.AppConfig
if existing != nil {
firstApp = existing.CurrentAppConfig("")
}
@@ -150,8 +151,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
var larkBrand core.LarkBrand
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride brand.Brand, msg *initMsg) (*configInitResult, error) {
var larkBrand brand.Brand
if brandOverride != "" {
larkBrand = brandOverride
} else {

View File

@@ -11,10 +11,10 @@ import (
"net/http"
"time"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
@@ -47,7 +47,7 @@ const probeTimeout = 3 * time.Second
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand brandpkg.Brand) error {
if factory == nil {
return nil
}
@@ -73,7 +73,7 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
url := brandpkg.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {

View File

@@ -13,10 +13,10 @@ import (
"testing"
"time"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
@@ -132,7 +132,7 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
@@ -148,7 +148,7 @@ func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
}
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
@@ -161,7 +161,7 @@ func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
@@ -180,7 +180,7 @@ func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
}
}
@@ -191,7 +191,7 @@ func TestRunProbe_TATTransportError_Silent(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
@@ -201,7 +201,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
@@ -211,7 +211,7 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
@@ -221,7 +221,7 @@ func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -245,7 +245,7 @@ func TestRunProbe_ProbeRequestShape(t *testing.T) {
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
if err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Lark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
@@ -262,7 +262,7 @@ func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
@@ -275,7 +275,7 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
err := runProbe(context.Background(), f, "cli_x", "secret_y", brand.Feishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {

View File

@@ -8,9 +8,11 @@ import (
"fmt"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
)
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
@@ -19,47 +21,47 @@ import (
// not for missing user input.
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", brand.Feishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
existing := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", brand.Feishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
existing := &core.MultiAppConfig{
existing := &configpkg.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Apps: []configpkg.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", brand.Feishu, "en")
assertValidationParam(t, err, "--app-secret")
}
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
existing := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
}},
}
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", brand.Feishu, "en")
assertValidationParam(t, err, "--app-secret")
}

View File

@@ -9,8 +9,9 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/spf13/cobra"
)
@@ -41,21 +42,21 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
func configRemoveRun(opts *ConfigRemoveOptions) error {
f := opts.Factory
config, err := core.LoadMultiAppConfig()
config, err := configpkg.LoadMultiAppConfig()
if err != nil || config == nil || len(config.Apps) == 0 {
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
}
// Save empty config first. If this fails, keep secrets and tokens intact so the
// existing config can still be retried instead of ending up half-removed.
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
if err := core.SaveMultiAppConfig(empty); err != nil {
empty := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{}}
if err := configpkg.SaveMultiAppConfig(empty); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// Clean up keychain entries for all apps after config is cleared.
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
secret.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
@@ -29,7 +29,7 @@ opt it back in explicitly, or default to remove the explicit preference.`,
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
config, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
@@ -52,7 +52,7 @@ opt it back in explicitly, or default to remove the explicit preference.`,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
if err := configpkg.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
@@ -64,7 +64,7 @@ opt it back in explicitly, or default to remove the explicit preference.`,
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
func printRiskControl(f *cmdutil.Factory, config *configpkg.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"

View File

@@ -8,17 +8,19 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/secret"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
if err := configpkg.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
@@ -28,7 +30,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
loaded, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
@@ -53,7 +55,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
loaded, err = configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
@@ -66,7 +68,7 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
loaded, err = configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
@@ -86,8 +88,8 @@ func TestRiskControlWorkspacePolicy(t *testing.T) {
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}); err != nil {
t.Fatal(err)
}
@@ -107,10 +109,10 @@ func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
if err := configpkg.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
@@ -120,7 +122,7 @@ func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T)
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
loaded, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}

View File

@@ -11,8 +11,9 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/workspace"
"github.com/spf13/cobra"
)
@@ -43,15 +44,15 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
config, err := core.LoadMultiAppConfig()
config, err := configpkg.LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return core.NotConfiguredError()
return configpkg.NotConfiguredError()
}
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
}
if config == nil || len(config.Apps) == 0 {
return core.NotConfiguredError()
return configpkg.NotConfiguredError()
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
@@ -66,7 +67,7 @@ func configShowRun(opts *ConfigShowOptions) error {
users = strings.Join(userStrs, ", ")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"workspace": core.CurrentWorkspace().Display(),
"workspace": workspace.CurrentWorkspace().Display(),
"profile": app.ProfileName(),
"appId": app.AppId,
"appSecret": "****",
@@ -74,6 +75,6 @@ func configShowRun(opts *ConfigShowOptions) error {
"lang": app.Lang,
"users": users,
})
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", workspace.GetConfigPath())
return nil
}

View File

@@ -9,7 +9,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/spf13/cobra"
)
@@ -37,7 +38,7 @@ explicit user confirmation — never run on your own initiative.`,
lark-cli config strict-mode --reset # clear profile override`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadOrNotConfigured()
multi, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
@@ -45,20 +46,20 @@ explicit user confirmation — never run on your own initiative.`,
if reset {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return core.NoActiveProfileError()
return configpkg.NoActiveProfileError()
}
return resetStrictMode(f, multi, app, global, args)
}
if len(args) == 0 {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return core.NoActiveProfileError()
return configpkg.NoActiveProfileError()
}
return showStrictMode(cmd.Context(), f, multi, app)
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if !global && app == nil {
return core.NoActiveProfileError()
return configpkg.NoActiveProfileError()
}
return setStrictMode(f, multi, app, args[0], global)
},
@@ -71,7 +72,7 @@ explicit user confirmation — never run on your own initiative.`,
return cmd
}
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
func resetStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, global bool, args []string) error {
if global {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
}
@@ -79,14 +80,14 @@ func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.A
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
}
app.StrictMode = nil
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
return nil
}
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) error {
// Runtime effective mode from credential provider chain is the source of truth.
runtime := f.ResolveStrictMode(ctx)
configMode, configSource := resolveStrictModeStatus(multi, app)
@@ -99,10 +100,10 @@ func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAp
return nil
}
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
mode := core.StrictMode(value)
func setStrictMode(f *cmdutil.Factory, multi *configpkg.MultiAppConfig, app *configpkg.AppConfig, value string, global bool) error {
mode := identity.StrictMode(value)
switch mode {
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
case identity.StrictModeBot, identity.StrictModeUser, identity.StrictModeOff:
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
}
@@ -118,7 +119,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
// false-positived (--global change while current profile has an explicit
// override) and false-negatived (--global broadening that doesn't affect
// the current profile but does affect other inheriting profiles).
var oldMode core.StrictMode
var oldMode identity.StrictMode
if global {
oldMode = multi.StrictMode
} else {
@@ -138,16 +139,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
}
} else {
if app == nil {
return core.NoActiveProfileError()
return configpkg.NoActiveProfileError()
}
app.StrictMode = &mode
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
if oldMode == identity.StrictModeBot && (mode == identity.StrictModeUser || mode == identity.StrictModeOff) {
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
}
@@ -162,19 +163,19 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
// strictModeRelaxLang picks the bind-message bundle whose language matches the
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
// available (global mutation with no current app).
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
func strictModeRelaxLang(app *configpkg.AppConfig) *bindMsg {
if app != nil {
return getBindMsg(app.Lang)
}
return getBindMsg("")
}
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
func resolveStrictModeStatus(multi *configpkg.MultiAppConfig, app *configpkg.AppConfig) (identity.StrictMode, string) {
if app != nil && app.StrictMode != nil {
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
}
if multi.StrictMode.IsActive() {
return multi.StrictMode, "global"
}
return core.StrictModeOff, "global (default)"
return identity.StrictModeOff, "global (default)"
}

View File

@@ -7,29 +7,32 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/secret"
)
func setupStrictModeTestConfig(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
multi := &configpkg.MultiAppConfig{
Apps: []configpkg.AppConfig{{
AppId: "test-app",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
}
func TestStrictMode_Show_Default(t *testing.T) {
setupStrictModeTestConfig(t)
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
@@ -42,37 +45,37 @@ func TestStrictMode_Show_Default(t *testing.T) {
func TestStrictMode_SetBot_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
multi, _ := configpkg.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot {
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeBot {
t.Error("expected StrictMode=bot on profile")
}
}
func TestStrictMode_SetUser_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"user"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
multi, _ := configpkg.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser {
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeUser {
t.Error("expected StrictMode=user on profile")
}
}
func TestStrictMode_SetOff_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
cmd.Execute()
@@ -81,23 +84,23 @@ func TestStrictMode_SetOff_Profile(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
multi, _ := configpkg.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff {
if app.StrictMode == nil || *app.StrictMode != identity.StrictModeOff {
t.Error("expected StrictMode=off on profile")
}
}
func TestStrictMode_SetBot_Global(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot", "--global"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
if multi.StrictMode != core.StrictModeBot {
multi, _ := configpkg.LoadMultiAppConfig()
if multi.StrictMode != identity.StrictModeBot {
t.Error("expected global StrictMode=bot")
}
}
@@ -105,38 +108,38 @@ func TestStrictMode_SetBot_Global(t *testing.T) {
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "missing-profile",
Apps: []core.AppConfig{{
Apps: []configpkg.AppConfig{{
Name: "default",
AppId: "test-app",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot", "--global"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.StrictMode != core.StrictModeBot {
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
if saved.StrictMode != identity.StrictModeBot {
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, identity.StrictModeBot)
}
}
func TestStrictMode_Reset(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
cmd.Execute()
@@ -145,7 +148,7 @@ func TestStrictMode_Reset(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
multi, _ := configpkg.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode != nil {
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
@@ -154,7 +157,7 @@ func TestStrictMode_Reset(t *testing.T) {
func TestStrictMode_InvalidValue(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"on"})
err := cmd.Execute()

View File

@@ -8,7 +8,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
)
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
@@ -16,7 +16,7 @@ import (
// new user-identity warning land.
func runStrictMode(t *testing.T, args ...string) string {
t.Helper()
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
f, _, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {

View File

@@ -14,14 +14,16 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/workspace"
)
// DoctorOptions holds inputs for the doctor command.
@@ -85,7 +87,7 @@ func doctorRun(opts *DoctorOptions) error {
}
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
_, err := configpkg.LoadMultiAppConfig()
if err != nil {
// For "config not present" cases, prefer the workspace-aware
// NotConfiguredError message + hint (e.g. "openclaw context
@@ -96,7 +98,7 @@ func doctorRun(opts *DoctorOptions) error {
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
}
@@ -118,7 +120,7 @@ func doctorRun(opts *DoctorOptions) error {
}
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
ep := core.ResolveEndpoints(cfg.Brand)
ep := brand.ResolveEndpoints(cfg.Brand)
// ── 3. Identity readiness ──
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
@@ -149,7 +151,7 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
func networkChecks(ctx context.Context, opts *DoctorOptions, ep brand.Endpoints) []checkResult {
if opts.Offline {
return []checkResult{
skip("endpoint_open", "skipped (--offline)"),
@@ -239,7 +241,7 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
result := map[string]interface{}{
"ok": allOK,
"workspace": core.CurrentWorkspace().Display(),
"workspace": workspace.CurrentWorkspace().Display(),
"checks": checks,
}
output.PrintJson(f.IOStreams.Out, result)

View File

@@ -13,15 +13,17 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/secret"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := NewCmdDoctor(f)
@@ -88,7 +90,7 @@ func TestFinishDoctor(t *testing.T) {
}
func TestNetworkChecks_Offline(t *testing.T) {
ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
ep := brand.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
opts := &DoctorOptions{Ctx: context.Background(), Offline: true}
checks := networkChecks(opts.Ctx, opts, ep)
if len(checks) != 2 {
@@ -103,22 +105,22 @@ func TestNetworkChecks_Offline(t *testing.T) {
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
Name: "default",
AppId: "test-app",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret"),
Brand: brand.Feishu,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: brand.Feishu,
})
err := doctorRun(&DoctorOptions{
Factory: f,
@@ -180,16 +182,16 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
Apps: []configpkg.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cfg := &configpkg.CliConfig{AppID: "cli_x", Brand: brand.Feishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
@@ -197,7 +199,7 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Config: func() (*configpkg.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/internal/apicatalog"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
identitypkg "github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -58,9 +58,9 @@ func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = string(core.AsUser)
identity = string(identitypkg.AsUser)
}
if identity != string(core.AsUser) && identity != string(core.AsBot) {
if identity != string(identitypkg.AsUser) && identity != string(identitypkg.AsBot) {
return nil
}
@@ -130,7 +130,7 @@ func commandCatalogPath(cmd *cobra.Command) []string {
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
if len(authTypes) == 0 {
authTypes = []string{string(core.AsUser)}
authTypes = []string{string(identitypkg.AsUser)}
}
for _, authType := range authTypes {
if authType == identity {

View File

@@ -14,10 +14,10 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/bus"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/workspace"
)
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
@@ -35,7 +35,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
eventsDir := filepath.Join(workspace.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {

View File

@@ -8,9 +8,10 @@ import (
"path/filepath"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
@@ -24,8 +25,8 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: brand.Feishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})

View File

@@ -10,8 +10,9 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/core"
brandpkg "github.com/larksuite/cli/brand"
eventlib "github.com/larksuite/cli/internal/event"
identitypkg "github.com/larksuite/cli/internal/identity"
)
// Landing-page contract for the scan-to-enable deep link, verified against the
@@ -67,23 +68,23 @@ func encodeAddons(a ManifestAddons) (string, error) {
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
func consoleAddonsURL(brand brandpkg.Brand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := core.ResolveEndpoints(brand).Open
host := brandpkg.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
func consoleLandingURL(brand brandpkg.Brand, appID string) string {
host := brandpkg.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
func addonsHintURL(brand brandpkg.Brand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
@@ -94,7 +95,7 @@ func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
func missingScopeAddons(identity identitypkg.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
@@ -106,7 +107,7 @@ func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity identitypkg.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}

View File

@@ -12,8 +12,9 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/brand"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/identity"
)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
@@ -55,11 +56,11 @@ func TestEncodeAddons_RoundTrip(t *testing.T) {
}
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
url, err := consoleAddonsURL(brand.Feishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
host := brand.ResolveEndpoints(brand.Feishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
@@ -71,22 +72,22 @@ func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
}
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
bot := missingScopeAddons(identity.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
user := missingScopeAddons(identity.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, identity.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
@@ -96,9 +97,9 @@ func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
missingScopeAddons(identity.AsBot, []string{"im:message"}),
missingScopeAddons(identity.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, identity.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)

View File

@@ -16,15 +16,16 @@ import (
"github.com/spf13/cobra"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/consume"
"github.com/larksuite/cli/internal/event/transport"
identitypkg "github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -118,7 +119,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
outputDir = safePath
}
domain := core.ResolveEndpoints(cfg.Brand).Open
domain := brandpkg.ResolveEndpoints(cfg.Brand).Open
// Surface auth errors before forking the bus daemon.
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
@@ -131,7 +132,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
}
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: identitypkg.AsBot}
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
preflightErrOut := f.IOStreams.ErrOut
@@ -224,8 +225,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
}
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
flagAs := core.Identity(cmd.Flag("as").Value.String())
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (identitypkg.Identity, error) {
flagAs := identitypkg.Identity(cmd.Flag("as").Value.String())
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
if len(keyDef.AuthTypes) > 0 {
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
@@ -238,9 +239,9 @@ func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.Ke
type preflightCtx struct {
factory *cmdutil.Factory
appID string
brand core.LarkBrand
brand brandpkg.Brand
eventKey string
identity core.Identity
identity identitypkg.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
@@ -264,7 +265,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
return nil
}
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
case pf.identity == core.AsUser:
case pf.identity == identitypkg.AsUser:
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
if err != nil || result == nil || result.Scopes == "" {
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
@@ -291,7 +292,7 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
func scopeRemediationHint(brand brandpkg.Brand, appID string, identity identitypkg.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
@@ -368,7 +369,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
if ctx == nil {
ctx = context.Background()
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(identitypkg.AsBot, appID))
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return "", err

View File

@@ -11,7 +11,7 @@ import (
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/output"
)
@@ -287,7 +287,7 @@ func errorAs(err error, target interface{}) bool {
}
func TestNewCmdFactories_WireFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
t.Run("consume", func(t *testing.T) {
cmd := NewCmdConsume(f)

View File

@@ -9,7 +9,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
@@ -29,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
}
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runList(f, false); err != nil {
t.Fatalf("runList: %v", err)
@@ -53,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) {
}
func TestRunList_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runList(f, true); err != nil {
t.Fatalf("runList json: %v", err)

View File

@@ -8,13 +8,14 @@ import (
"strings"
"testing"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
identitypkg "github.com/larksuite/cli/internal/identity"
)
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
func newPreflightCtx(appID string, brand brandpkg.Brand, identity identitypkg.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
key := ""
if keyDef != nil {
key = keyDef.Key
@@ -108,7 +109,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil))
if err != nil {
t.Fatalf("bot + nil appVer should skip, got: %v", err)
}
@@ -124,7 +125,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
"im:message.group_at_msg",
"contact:user:readonly",
}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
if err != nil {
t.Fatalf("all scopes granted, unexpected error: %v", err)
}
@@ -136,7 +137,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, appVer))
if err == nil {
t.Fatal("expected error for missing scope")
}
@@ -169,7 +170,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{Key: "x"}
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", identitypkg.AsBot, def, nil)); err != nil {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
@@ -177,9 +178,9 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
brand: brandpkg.Feishu,
eventKey: "test.cb",
identity: core.AsBot,
identity: identitypkg.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -206,9 +207,9 @@ func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
brand: brandpkg.Feishu,
eventKey: "test.cb",
identity: core.AsBot,
identity: identitypkg.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -227,9 +228,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
brand: brandpkg.Feishu,
eventKey: "test.cb",
identity: core.AsBot,
identity: identitypkg.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -249,9 +250,9 @@ func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
brand: brandpkg.Feishu,
eventKey: "test.cb",
identity: core.AsBot,
identity: identitypkg.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
@@ -266,12 +267,12 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
bot := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
user := scopeRemediationHint(brandpkg.Feishu, "cli_x", identitypkg.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}

View File

@@ -9,13 +9,13 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identity"
)
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
type consumeRuntime struct {
client *client.APIClient
accessIdentity core.Identity
accessIdentity identity.Identity
}
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {

View File

@@ -14,10 +14,12 @@ import (
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identity"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
@@ -45,9 +47,9 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
Config: &configpkg.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu},
},
accessIdentity: core.AsBot,
accessIdentity: identity.AsBot,
}
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
@@ -43,7 +43,7 @@ type approvalSchemaJSONProperty struct {
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
@@ -63,7 +63,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) {
}
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
@@ -83,7 +83,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
}
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
err := runSchema(f, "im.message.recieve_v1", false)
if err == nil {
@@ -99,7 +99,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
}
func TestRunSchema_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
@@ -120,7 +120,7 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
@@ -154,7 +154,7 @@ func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
@@ -193,7 +193,7 @@ func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
@@ -241,7 +241,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
"vc.meeting.participant_meeting_joined_v1",
} {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
@@ -288,7 +288,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
@@ -334,7 +334,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}

View File

@@ -4,7 +4,7 @@
package cmd
import (
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/spf13/pflag"
)
@@ -32,7 +32,7 @@ func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
// until at least two profiles exist. Intended for the Execute entry point —
// buildInternal must not call this directly to stay state-free.
func isSingleAppMode() bool {
raw, err := core.LoadMultiAppConfig()
raw, err := configpkg.LoadMultiAppConfig()
if err != nil || raw == nil {
return true
}

View File

@@ -8,8 +8,10 @@ import (
"os"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/secret"
"github.com/spf13/pflag"
)
@@ -58,8 +60,8 @@ func TestIsSingleAppMode_NoConfig(t *testing.T) {
func TestIsSingleAppMode_SingleApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
saveAppsForTest(t, []configpkg.AppConfig{
{Name: "default", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu},
})
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true for single-app config")
@@ -68,9 +70,9 @@ func TestIsSingleAppMode_SingleApp(t *testing.T) {
func TestIsSingleAppMode_MultiApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
saveAppsForTest(t, []configpkg.AppConfig{
{Name: "a", AppId: "cli_a", AppSecret: secret.PlainSecret("x"), Brand: brand.Feishu},
{Name: "b", AppId: "cli_b", AppSecret: secret.PlainSecret("y"), Brand: brand.Feishu},
})
if isSingleAppMode() {
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
@@ -101,10 +103,10 @@ func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
}
}
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
func saveAppsForTest(t *testing.T, apps []configpkg.AppConfig) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
if err := core.SaveMultiAppConfig(multi); err != nil {
multi := &configpkg.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

View File

@@ -14,10 +14,10 @@ import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/workspace"
)
// userPolicyFileName is the conventional filename for the user-layer Rule.
@@ -261,7 +261,7 @@ func splitCSV(s string) []string {
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
//
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
// core.GetBaseConfigDir) so that test isolation, container deployments
// workspace.GetBaseConfigDir) so that test isolation, container deployments
// and per-Agent config overrides all see a consistent policy location.
// Using vfs.UserHomeDir directly here would silently bypass the env
// override and route every test through the real ~/.lark-cli.
@@ -271,7 +271,7 @@ func splitCSV(s string) []string {
// the home dir can't be resolved, and the resolver already treats a
// missing file as "no policy".
func userPolicyPath() (string, error) {
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
return filepath.Join(workspace.GetBaseConfigDir(), userPolicyFileName), nil
}
// warnPolicyError writes a one-line stderr warning when the user policy

View File

@@ -12,11 +12,13 @@ import (
"github.com/spf13/cobra"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
secretpkg "github.com/larksuite/cli/internal/secret"
)
// NewCmdProfileAdd creates the profile add subcommand.
@@ -53,7 +55,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
}
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
if err := configpkg.ValidateProfileName(name); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
WithCause(err).
WithParam("--name")
@@ -90,12 +92,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
multi, err := configpkg.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
}
multi = &core.MultiAppConfig{}
multi = &configpkg.MultiAppConfig{}
}
// Check name uniqueness
@@ -115,12 +117,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
secret, err := secretpkg.ForStorage(appID, secretpkg.PlainSecret(appSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
}
parsedBrand := core.ParseBrand(brand)
parsedBrand := brandpkg.ParseBrand(brand)
// Capture current profile before appending (avoid setting PreviousApp to self)
var previousName string
@@ -131,13 +133,13 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
// Append profile
multi.Apps = append(multi.Apps, core.AppConfig{
multi.Apps = append(multi.Apps, configpkg.AppConfig{
Name: name,
AppId: appID,
AppSecret: secret,
Brand: parsedBrand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
Users: []configpkg.AppUser{},
})
if useAfter {
@@ -147,7 +149,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
multi.CurrentApp = name
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}

View File

@@ -9,21 +9,22 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
)
// profileListItem is the JSON output for a single profile entry.
type profileListItem struct {
Name string `json:"name"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
Active bool `json:"active"`
User string `json:"user,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
Name string `json:"name"`
AppID string `json:"appId"`
Brand brand.Brand `json:"brand"`
Active bool `json:"active"`
User string `json:"user,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
// NewCmdProfileList creates the profile list subcommand.
@@ -40,7 +41,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
}
func profileListRun(f *cmdutil.Factory) error {
multi, err := core.LoadMultiAppConfig()
multi, err := configpkg.LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
output.PrintJson(f.IOStreams.Out, []profileListItem{})

View File

@@ -11,11 +11,13 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/vfs"
)
@@ -75,7 +77,7 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -92,7 +94,7 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, _ := core.LoadMultiAppConfig()
saved, _ := configpkg.LoadMultiAppConfig()
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
}
@@ -115,13 +117,13 @@ func TestProfileAddRun_Lang(t *testing.T) {
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -132,7 +134,7 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -149,15 +151,15 @@ func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "target",
PreviousApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -166,7 +168,7 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
t.Fatalf("profileRemoveRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -183,17 +185,17 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "old",
PreviousApp: "old",
Apps: []core.AppConfig{{
Apps: []configpkg.AppConfig{{
Name: "old",
AppId: "app-old",
AppSecret: core.PlainSecret("secret-old"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-old"),
Brand: brand.Feishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -202,7 +204,7 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
t.Fatalf("profileRenameRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -219,17 +221,17 @@ func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "old",
PreviousApp: "old",
Apps: []core.AppConfig{{
Apps: []configpkg.AppConfig{{
Name: "old",
AppId: "app-old",
AppSecret: core.PlainSecret("secret-old"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-old"),
Brand: brand.Feishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -238,7 +240,7 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
t.Fatalf("profileRenameRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -255,15 +257,15 @@ func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
PreviousApp: "target",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -272,7 +274,7 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
t.Fatalf("profileUseRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
saved, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
@@ -286,14 +288,14 @@ func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
func TestProfileListRun_OutputsProfiles(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.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.PlainSecret("secret-target"), Brand: core.BrandLark},
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -339,14 +341,14 @@ func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "target",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -364,16 +366,16 @@ func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "old",
Apps: []core.AppConfig{{
Apps: []configpkg.AppConfig{{
Name: "old",
AppId: "app-old",
AppSecret: core.PlainSecret("secret-old"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-old"),
Brand: brand.Feishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -391,14 +393,14 @@ func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.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.PlainSecret("secret-target"), Brand: core.BrandLark},
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -461,14 +463,14 @@ func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wa
func saveTwoProfiles(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
multi := &configpkg.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.PlainSecret("secret-target"), Brand: core.BrandLark},
Apps: []configpkg.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: secret.PlainSecret("secret-default"), Brand: brand.Feishu},
{Name: "target", AppId: "app-target", AppSecret: secret.PlainSecret("secret-target"), Brand: brand.Lark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
@@ -609,13 +611,13 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
t.Run("cannot remove the only profile", func(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "solo",
Apps: []core.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
Apps: []configpkg.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: secret.PlainSecret("secret-solo"), Brand: brand.Feishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)

View File

@@ -12,8 +12,9 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
)
// NewCmdProfileRemove creates the profile remove subcommand.
@@ -34,7 +35,7 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
}
func profileRemoveRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadOrNotConfigured()
multi, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
@@ -66,12 +67,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
multi.PreviousApp = ""
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// Best-effort credential cleanup after config commit
core.RemoveSecretStore(appSecret, f.Keychain)
secret.RemoveSecretStore(appSecret, f.Keychain)
for _, user := range users {
larkauth.RemoveStoredToken(appId, user.UserOpenId)
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
)
@@ -30,11 +30,11 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
}
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
if err := configpkg.ValidateProfileName(newName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
}
multi, err := core.LoadOrNotConfigured()
multi, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
@@ -67,7 +67,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
multi.PreviousApp = newName
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
)
@@ -33,7 +33,7 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
}
func profileUseRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadOrNotConfigured()
multi, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
@@ -67,7 +67,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
}
multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}

View File

@@ -12,11 +12,11 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identity"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
func pruneForStrictMode(root *cobra.Command, mode identity.StrictMode) {
pruneIncompatible(root, mode)
pruneEmpty(root)
}
@@ -25,7 +25,7 @@ func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
// identities incompatible with the forced identity. Commands without annotation are kept.
// Hidden stubs preserve direct execution so users get a strict-mode error instead
// of Cobra's generic "unknown flag" fallback from the parent command.
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
func pruneIncompatible(parent *cobra.Command, mode identity.StrictMode) {
forced := string(mode.ForcedIdentity())
var toRemove []*cobra.Command
var toAdd []*cobra.Command
@@ -44,7 +44,7 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
}
}
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
func strictModeStubFrom(child *cobra.Command, mode identity.StrictMode) *cobra.Command {
// The denial annotations let the hook layer's populateInvocationDenial
// recognise this command as denied, so the Wrap chain is physically
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -75,7 +75,7 @@ func findCmd(root *cobra.Command, names ...string) *cobra.Command {
func TestPruneForStrictMode_Bot(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
if cmd := findCmd(root, "im", "+search"); cmd == nil || !cmd.Hidden {
t.Error("+search (user-only) should be replaced by a hidden stub in bot mode")
@@ -99,7 +99,7 @@ func TestPruneForStrictMode_Bot(t *testing.T) {
func TestPruneForStrictMode_User(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeUser)
pruneForStrictMode(root, identity.StrictModeUser)
if findCmd(root, "im", "+search") == nil {
t.Error("+search (user-only) should be kept in user mode")
@@ -117,7 +117,7 @@ func TestPruneForStrictMode_User(t *testing.T) {
func TestPruneEmpty(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden {
t.Error("resource 'messages' should be kept hidden when only hidden stubs remain")
@@ -144,7 +144,7 @@ func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
root.SetArgs([]string{"im", "+search", "--query", "hello"})
err := root.Execute()
@@ -160,7 +160,7 @@ func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testi
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
root.SetArgs([]string{"im", "messages", "search", "--query", "hello"})
err := root.Execute()
@@ -176,7 +176,7 @@ func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) {
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"})
err := root.Execute()
@@ -192,7 +192,7 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeUser)
pruneForStrictMode(root, identity.StrictModeUser)
root.SetArgs([]string{"im", "+subscribe", "--topic", "x"})
err := root.Execute()
@@ -215,7 +215,7 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
// stops at the stub and proceeds to its RunE.
func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
stub := findCmd(root, "auth", "login")
if stub == nil {
t.Fatal("auth/login stub should exist after StrictModeBot")
@@ -235,7 +235,7 @@ func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
// stub's RunE.
func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
stub := findCmd(root, "auth", "login")
if stub == nil {
t.Fatal("auth/login stub should exist after StrictModeBot")
@@ -256,7 +256,7 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
// still inspect the structured denial taxonomy via errors.As.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
stub := findCmd(root, "im", "+search")
if stub == nil {
t.Fatalf("expected im/+search stub")
@@ -318,7 +318,7 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
// and silently return nil, swallowing the strict-mode error.
func TestStrictModeStub_HasDenialAnnotation(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
// im/+search is user-only -> replaced by a stub in StrictModeBot.
stub := findCmd(root, "im", "+search")
@@ -356,7 +356,7 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
cmdutil.SetRisk(userOnly, "read")
svc.AddCommand(userOnly)
pruneForStrictMode(root, core.StrictModeBot)
pruneForStrictMode(root, identity.StrictModeBot)
stub := findCmd(root, "im", "+search")
if stub == nil {

View File

@@ -237,7 +237,7 @@ func configureFlagCompletions(args []string) {
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// constructed typed at their origin (internal/auth, internal/config), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.

View File

@@ -11,17 +11,20 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/envnames"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/secret"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/shortcuts"
@@ -155,37 +158,37 @@ func strictModeFixtureCatalog() apicatalog.Catalog {
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
func newStrictModeDefaultFactory(t *testing.T, profile string, mode identity.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envnames.CliAppID, "")
t.Setenv(envnames.CliAppSecret, "")
t.Setenv(envnames.CliUserAccessToken, "")
t.Setenv(envnames.CliTenantAccessToken, "")
t.Setenv(envnames.CliDefaultAs, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
targetMode := mode
multi := &core.MultiAppConfig{
multi := &configpkg.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
Apps: []configpkg.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-default"),
Brand: brand.Feishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.PlainSecret("secret-target"),
Brand: core.BrandFeishu,
AppSecret: secret.PlainSecret("secret-target"),
Brand: brand.Feishu,
StrictMode: &targetMode,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
if err := configpkg.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
@@ -206,7 +209,7 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
// --- service command ---
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
@@ -238,7 +241,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testin
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
@@ -315,7 +318,7 @@ func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMes
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
@@ -335,7 +338,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
// +chat-create supports both user and bot identities, so strict mode user
// should allow it and force user identity.
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
@@ -352,7 +355,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
}
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
@@ -370,7 +373,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
@@ -389,7 +392,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeUser)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
@@ -408,7 +411,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", identity.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
@@ -428,8 +431,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
// --- shortcut command ---
func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "e2e-sc-err", AppSecret: "secret", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",

View File

@@ -13,6 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
cmdconfig "github.com/larksuite/cli/cmd/config"
@@ -20,8 +21,9 @@ import (
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -305,7 +307,7 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
// baseline for auth/config errors: it pins the typed envelope and exit code the
// dispatcher produces for the two source-of-truth shapes, which are constructed
// typed at their origin in internal/auth and internal/core.
// typed at their origin in internal/auth and internal/configpkg.
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -345,7 +347,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
exit := handleRootError(f, configpkg.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
@@ -512,10 +514,10 @@ func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
f.ResolvedIdentity = core.AsUser
f.ResolvedIdentity = identity.AsUser
var target registry.CommandEntry
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
@@ -560,10 +562,10 @@ func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *tes
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
f.ResolvedIdentity = core.AsUser
f.ResolvedIdentity = identity.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}
@@ -585,10 +587,10 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
f.ResolvedIdentity = core.AsUser
f.ResolvedIdentity = identity.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "drive"}
@@ -611,10 +613,10 @@ func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
f.ResolvedIdentity = core.AsUser
f.ResolvedIdentity = identity.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}

View File

@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/workspace"
"github.com/spf13/cobra"
)
@@ -68,9 +68,9 @@ func TestOfferRootUpgrade(t *testing.T) {
// workspace detection; pin the process-global workspace to Local so
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
// subdir inherited from a prior test in the package.
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
origWS := workspace.CurrentWorkspace()
t.Cleanup(func() { workspace.SetCurrentWorkspace(origWS) })
workspace.SetCurrentWorkspace(workspace.WorkspaceLocal)
cases := []struct {
name string
@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
@@ -91,7 +91,7 @@ func schemaRun(opts *SchemaOptions) error {
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
func runSchema(out io.Writer, parts []string, mode identity.StrictMode) error {
catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline

View File

@@ -9,9 +9,10 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
)
func TestSchemaCmd_FlagParsing(t *testing.T) {
@@ -198,8 +199,8 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
}
func TestSchemaCmd_UnknownService(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := NewCmdSchema(f, nil)
@@ -227,8 +228,8 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := NewCmdSchema(f, nil)

View File

@@ -16,9 +16,10 @@ import (
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
identitypkg "github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
@@ -134,7 +135,7 @@ type ServiceMethodOptions struct {
// Flags
Params string
Data string
As core.Identity
As identitypkg.Identity
Output string
PageAll bool
PageLimit int
@@ -267,7 +268,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
RunE: func(cmd *cobra.Command, args []string) error {
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
opts.As = identitypkg.Identity(asStr)
if runF != nil {
return runF(opts)
}
@@ -370,7 +371,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return err
}
// Check if this API method supports the resolved identity.
// Check if this API method supports the resolved identitypkg.
if opts.Method.RestrictsIdentity() {
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
return err
@@ -453,7 +454,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity identitypkg.Identity, config *configpkg.CliConfig, method meta.Method) error {
if ctx.Err() != nil {
return ctx.Err()
}
@@ -667,7 +668,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
}
@@ -682,7 +683,7 @@ func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions)
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, identitypkg.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}

View File

@@ -13,11 +13,13 @@ import (
"net/http"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
)
@@ -44,10 +46,10 @@ func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buff
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
config := &configpkg.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
Brand: brand.Feishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
@@ -62,7 +64,7 @@ func servicePaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
As: identity.AsBot,
}
}

View File

@@ -15,19 +15,21 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
// ── helpers ──
var testConfig = &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
var testConfig = &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
}
func driveSpec() meta.Service {
@@ -131,8 +133,8 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
}
func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2,
})
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil)
@@ -193,7 +195,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
if captured == nil {
t.Fatal("runF was not called")
}
if captured.As != core.AsBot {
if captured.As != identity.AsBot {
t.Errorf("expected As=bot, got %s", captured.As)
}
if captured.SchemaPath != "drive.files.list" {
@@ -463,8 +465,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
}
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -531,8 +533,8 @@ func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -585,8 +587,8 @@ func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -633,8 +635,8 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -689,8 +691,8 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
}
func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -720,8 +722,8 @@ func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *t
}
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -750,8 +752,8 @@ func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing
}
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -796,8 +798,8 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
}
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
f, _, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -880,8 +882,8 @@ func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
}
func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -951,8 +953,8 @@ func TestServiceMethod_JqInvalidExpression(t *testing.T) {
}
func TestServiceMethod_PageAll_WithJq(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{
@@ -984,8 +986,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: brand.Feishu,
})
reg.Register(&httpmock.Stub{

View File

@@ -6,8 +6,9 @@ package cmd
import (
"os"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/envnames"
configpkg "github.com/larksuite/cli/internal/config"
)
// ResolveStartupBrand resolves the brand before the command tree is built, so
@@ -15,14 +16,14 @@ import (
// 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 {
if raw := os.Getenv(envvars.CliBrand); raw != "" {
return core.ParseBrand(raw)
func ResolveStartupBrand(profile string) brand.Brand {
if raw := os.Getenv(envnames.CliBrand); raw != "" {
return brand.ParseBrand(raw)
}
if cfg, err := core.LoadMultiAppConfig(); err == nil {
if cfg, err := configpkg.LoadMultiAppConfig(); err == nil {
if app := cfg.CurrentAppConfig(profile); app != nil {
return core.ParseBrand(string(app.Brand))
return brand.ParseBrand(string(app.Brand))
}
}
return core.BrandFeishu
return brand.Feishu
}

View File

@@ -14,8 +14,8 @@ import (
"testing"
"github.com/google/uuid"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
@@ -48,7 +48,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
os.Unsetenv("LARKSUITE_CLI_BRAND")
// No config at all → default brand.
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
if got := ResolveStartupBrand(""); got != brand.Feishu {
t.Errorf("empty state brand = %q, want feishu", got)
}
@@ -59,16 +59,16 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
if got := ResolveStartupBrand(""); got != brand.Feishu {
t.Errorf("default profile brand = %q, want feishu", got)
}
if got := ResolveStartupBrand("lark-prof"); got != core.BrandLark {
if got := ResolveStartupBrand("lark-prof"); got != brand.Lark {
t.Errorf("lark profile brand = %q, want lark (normalized)", got)
}
// Environment wins over the config file.
t.Setenv("LARKSUITE_CLI_BRAND", "lark")
if got := ResolveStartupBrand(""); got != core.BrandLark {
if got := ResolveStartupBrand(""); got != brand.Lark {
t.Errorf("env brand = %q, want lark", got)
}
}

View File

@@ -11,10 +11,11 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
@@ -175,17 +176,17 @@ func updateRun(opts *UpdateOptions) error {
// resolveSkillsBrand returns the skills-source brand: resolved config first,
// then the active profile's raw config entry (the brand is not a secret; a
// locked keychain must not flip the source), then the default with a notice.
func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) core.LarkBrand {
func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) brand.Brand {
if cfg, err := f.Config(); err == nil && cfg != nil {
return core.ParseBrand(string(cfg.Brand))
return brand.ParseBrand(string(cfg.Brand))
}
if raw, err := core.LoadMultiAppConfig(); err == nil {
if raw, err := configpkg.LoadMultiAppConfig(); err == nil {
if app := raw.CurrentAppConfig(f.Invocation.Profile); app != nil {
return core.ParseBrand(string(app.Brand))
return brand.ParseBrand(string(app.Brand))
}
}
fmt.Fprintf(errOut, "note: could not resolve the configured brand; syncing skills from the default source\n")
return core.BrandFeishu
return brand.Feishu
}
// --- Output helpers ---

View File

@@ -16,9 +16,10 @@ import (
"testing"
"time"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
@@ -29,7 +30,7 @@ const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{})
return f, stdout, stderr
}
@@ -1864,10 +1865,10 @@ func containsString(values []string, target string) bool {
func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
// Layer 1: resolved config wins.
var errBuf bytes.Buffer
f := &cmdutil.Factory{Config: func() (*core.CliConfig, error) {
return &core.CliConfig{Brand: core.LarkBrand(" LARK ")}, nil
f := &cmdutil.Factory{Config: func() (*configpkg.CliConfig, error) {
return &configpkg.CliConfig{Brand: brand.Brand(" LARK ")}, nil
}}
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark {
t.Errorf("resolved-config brand = %q, want lark", got)
}
@@ -1879,9 +1880,9 @@ func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
f = &cmdutil.Factory{Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") }}
f = &cmdutil.Factory{Config: func() (*configpkg.CliConfig, error) { return nil, errors.New("keychain locked") }}
errBuf.Reset()
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark {
t.Errorf("raw-config brand = %q, want lark", got)
}
if errBuf.Len() != 0 {
@@ -1891,7 +1892,7 @@ func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
// Layer 3: nothing readable → default brand with a notice.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
errBuf.Reset()
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandFeishu {
if got := resolveSkillsBrand(f, &errBuf); got != brand.Feishu {
t.Errorf("fallback brand = %q, want feishu", got)
}
if !strings.Contains(errBuf.String(), "could not resolve the configured brand") {
@@ -1911,10 +1912,10 @@ func TestResolveSkillsBrand_RespectsActiveProfile(t *testing.T) {
}
f := &cmdutil.Factory{
Invocation: cmdutil.InvocationContext{Profile: "lark-prof"},
Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") },
Config: func() (*configpkg.CliConfig, error) { return nil, errors.New("keychain locked") },
}
var errBuf bytes.Buffer
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark {
t.Errorf("brand = %q, want lark (the active profile's brand)", got)
}
if errBuf.Len() != 0 {

View File

@@ -8,8 +8,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
@@ -25,7 +27,7 @@ import (
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
Brand brand.Brand `json:"brand"`
DefaultAs string `json:"defaultAs"`
Identity string `json:"identity"`
IdentitySource string `json:"identitySource"`
@@ -80,7 +82,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
return err
}
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
flagAs := identity.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Validate as a real API call does (strict mode, then identity) so whoami
// can't preview an identity the next call would refuse.
@@ -107,8 +109,8 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
func resolveSource(changedAs bool, flagAs identity.Identity, autoDetected bool, strictForced identity.Identity) string {
if changedAs && (flagAs == identity.AsUser || flagAs == identity.AsBot) {
return "flag"
}
if autoDetected {
@@ -122,10 +124,10 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
func buildResult(cfg *configpkg.CliConfig, as identity.Identity, source string, diag identitydiag.Result) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
defaultAs = identity.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
@@ -138,7 +140,7 @@ func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag iden
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.
switch as {
case core.AsBot:
case identity.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {

View File

@@ -13,11 +13,13 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/identitydiag"
)
@@ -25,16 +27,16 @@ func TestResolveSource(t *testing.T) {
tests := []struct {
name string
changedAs bool
flagAs core.Identity
flagAs identity.Identity
autoDetected bool
strictForced core.Identity
strictForced identity.Identity
want string
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
{"explicit flag user", true, identity.AsUser, false, "", "flag"},
{"explicit flag bot", true, identity.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, identity.AsAuto, true, "", "auto_detect"},
{"auto detected", false, "", true, "", "auto_detect"},
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
{"strict mode", false, "", false, identity.AsBot, "strict_mode"},
{"default_as", false, "", false, "", "default_as"},
}
for _, tt := range tests {
@@ -48,11 +50,11 @@ func TestResolveSource(t *testing.T) {
}
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
cfg := &configpkg.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: brand.Lark, DefaultAs: identity.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
r := buildResult(cfg, identity.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -67,17 +69,17 @@ func TestBuildResult_UserValid(t *testing.T) {
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != brand.Lark {
t.Fatalf("app context = %#v", r)
}
}
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
cfg := &configpkg.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: brand.Lark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
r := buildResult(cfg, identity.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -96,11 +98,11 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
}
func TestBuildResult_BotReady(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
cfg := &configpkg.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu, DefaultAs: identity.AsBot}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag)
r := buildResult(cfg, identity.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -117,11 +119,11 @@ func TestBuildResult_BotReady(t *testing.T) {
}
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
cfg := &configpkg.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
r := buildResult(cfg, identity.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -135,8 +137,8 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
}
func TestWhoami_BotJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := NewCmdWhoami(f)
@@ -169,8 +171,8 @@ func TestWhoami_BotJSON(t *testing.T) {
func TestWhoami_RejectsInvalidAs(t *testing.T) {
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
t.Run("as="+bad, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", bad})
@@ -195,11 +197,11 @@ func TestWhoami_RejectsInvalidAs(t *testing.T) {
}
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
})
wantErr := fmt.Errorf("boom")
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
f.Config = func() (*configpkg.CliConfig, error) { return nil, wantErr }
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
@@ -218,8 +220,8 @@ func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
// Bot-only account → strict mode bot. A real `--as user` call would be
// rejected by CheckStrictMode; whoami must reject it identically rather than
// previewing a user identity the next call would refuse.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
SupportedIdentities: 2, // bot only
})
cmd := NewCmdWhoami(f)
@@ -247,7 +249,7 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
return nil, nil // no UAT served locally; whoami runs with verify=false
}
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
func externalWhoamiFactory(cfg *configpkg.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
nil, nil,
@@ -255,7 +257,7 @@ func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Config: func() (*configpkg.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
@@ -266,8 +268,8 @@ func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer
// an extension provider, a signed-in user must read as available, and an
// unavailable identity must not be told to "auth login" (which is blocked).
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
cfg := &configpkg.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu,
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
}
f, out := externalWhoamiFactory(cfg)
@@ -293,8 +295,8 @@ func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
}
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
cfg := &configpkg.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: brand.Feishu,
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
}
f, out := externalWhoamiFactory(cfg)

18
envnames/envnames.go Normal file
View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package envnames defines environment variable names shared by the CLI and
// its public extension packages.
package envnames
const (
CliAppID = "LARKSUITE_CLI_APP_ID"
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
CliBrand = "LARKSUITE_CLI_BRAND"
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY"
)

View File

@@ -8,7 +8,7 @@ import (
"encoding/json"
"github.com/larksuite/cli/internal/event"
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
"github.com/larksuite/cli/internal/imcontent"
)
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
@@ -74,11 +74,11 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
msg := envelope.Event.Message
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
content = imcontent.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
content = imcontent.ConvertBodyContent(msg.MessageType, &imcontent.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
MentionMap: imcontent.BuildMentionKeyMap(msg.Mentions),
})
}

View File

@@ -8,9 +8,8 @@ import (
"fmt"
"os"
"github.com/larksuite/cli/envnames"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// Provider resolves credentials from environment variables.
@@ -19,33 +18,33 @@ type Provider struct{}
func (p *Provider) Name() string { return "env" }
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
appID := os.Getenv(envvars.CliAppID)
appSecret := os.Getenv(envvars.CliAppSecret)
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
appID := os.Getenv(envnames.CliAppID)
appSecret := os.Getenv(envnames.CliAppSecret)
hasUAT := os.Getenv(envnames.CliUserAccessToken) != ""
hasTAT := os.Getenv(envnames.CliTenantAccessToken) != ""
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
return nil, &credential.BlockError{Provider: "env", Reason: envnames.CliUserAccessToken + " is set but " + envnames.CliAppID + " is missing"}
case hasTAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
return nil, &credential.BlockError{Provider: "env", Reason: envnames.CliTenantAccessToken + " is set but " + envnames.CliAppID + " is missing"}
default:
return nil, nil
}
}
if appID == "" {
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
return nil, &credential.BlockError{Provider: "env", Reason: envnames.CliAppSecret + " is set but " + envnames.CliAppID + " is missing"}
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, &credential.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
Reason: envnames.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.ParseBrand(os.Getenv(envnames.CliBrand))
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
switch id := credential.Identity(os.Getenv(envnames.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
@@ -53,12 +52,12 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envnames.CliDefaultAs, id),
}
}
// Explicit strict mode policy takes priority
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
switch strictMode := os.Getenv(envnames.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
@@ -76,7 +75,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envnames.CliStrictMode, strictMode),
}
}
@@ -96,9 +95,9 @@ func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (
var envKey string
switch req.Type {
case credential.TokenTypeUAT:
envKey = envvars.CliUserAccessToken
envKey = envnames.CliUserAccessToken
case credential.TokenTypeTAT:
envKey = envvars.CliTenantAccessToken
envKey = envnames.CliTenantAccessToken
default:
return nil, nil
}

View File

@@ -9,8 +9,8 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/envnames"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
)
func TestProvider_Name(t *testing.T) {
@@ -20,9 +20,9 @@ func TestProvider_Name(t *testing.T) {
}
func TestResolveAccount_BothSet(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "secret_test")
t.Setenv(envvars.CliBrand, " LARK ")
t.Setenv(envnames.CliAppID, "cli_test")
t.Setenv(envnames.CliAppSecret, "secret_test")
t.Setenv(envnames.CliBrand, " LARK ")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
@@ -41,7 +41,7 @@ func TestResolveAccount_NeitherSet(t *testing.T) {
}
func TestResolveAccount_OnlyIDSet(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envnames.CliAppID, "cli_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
@@ -50,8 +50,8 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) {
}
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliUserAccessToken, "uat_test")
t.Setenv(envnames.CliAppID, "cli_test")
t.Setenv(envnames.CliUserAccessToken, "uat_test")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
@@ -69,7 +69,7 @@ func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
}
func TestResolveAccount_OnlySecretSet(t *testing.T) {
t.Setenv(envvars.CliAppSecret, "secret_test")
t.Setenv(envnames.CliAppSecret, "secret_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
@@ -78,21 +78,21 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) {
}
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
t.Setenv(envvars.CliUserAccessToken, "uat_test")
t.Setenv(envnames.CliUserAccessToken, "uat_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
if !strings.Contains(err.Error(), envnames.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envnames.CliAppID)
}
}
func TestResolveAccount_DefaultBrand(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "secret_test")
t.Setenv(envnames.CliAppID, "cli_test")
t.Setenv(envnames.CliAppSecret, "secret_test")
acct, _ := (&Provider{}).ResolveAccount(context.Background())
if acct.Brand != "feishu" {
t.Errorf("expected 'feishu', got %q", acct.Brand)
@@ -100,9 +100,9 @@ func TestResolveAccount_DefaultBrand(t *testing.T) {
}
func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "secret_test")
t.Setenv(envvars.CliDefaultAs, "user")
t.Setenv(envnames.CliAppID, "cli_test")
t.Setenv(envnames.CliAppSecret, "secret_test")
t.Setenv(envnames.CliDefaultAs, "user")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
@@ -114,23 +114,23 @@ func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
}
func TestResolveToken_UATSet(t *testing.T) {
t.Setenv(envvars.CliUserAccessToken, "u-env")
t.Setenv(envnames.CliUserAccessToken, "u-env")
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken {
if tok.Value != "u-env" || tok.Source != "env:"+envnames.CliUserAccessToken {
t.Errorf("unexpected: %+v", tok)
}
}
func TestResolveToken_TATSet(t *testing.T) {
t.Setenv(envvars.CliTenantAccessToken, "t-env")
t.Setenv(envnames.CliTenantAccessToken, "t-env")
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatal(err)
}
if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken {
if tok.Value != "t-env" || tok.Source != "env:"+envnames.CliTenantAccessToken {
t.Errorf("unexpected: %+v", tok)
}
}
@@ -143,9 +143,9 @@ func TestResolveToken_NotSet(t *testing.T) {
}
func TestResolveAccount_StrictModeBot(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "bot")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliStrictMode, "bot")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
@@ -156,9 +156,9 @@ func TestResolveAccount_StrictModeBot(t *testing.T) {
}
func TestResolveAccount_StrictModeUser(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "user")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliStrictMode, "user")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
@@ -169,9 +169,9 @@ func TestResolveAccount_StrictModeUser(t *testing.T) {
}
func TestResolveAccount_StrictModeOff(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "off")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliStrictMode, "off")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
@@ -182,9 +182,9 @@ func TestResolveAccount_StrictModeOff(t *testing.T) {
}
func TestResolveAccount_InferFromUATOnly(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliUserAccessToken, "u-tok")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliUserAccessToken, "u-tok")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
@@ -198,9 +198,9 @@ func TestResolveAccount_InferFromUATOnly(t *testing.T) {
}
func TestResolveAccount_InferFromTATOnly(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliTenantAccessToken, "t-tok")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
@@ -214,10 +214,10 @@ func TestResolveAccount_InferFromTATOnly(t *testing.T) {
}
func TestResolveAccount_InferBothTokens(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliUserAccessToken, "u-tok")
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliUserAccessToken, "u-tok")
t.Setenv(envnames.CliTenantAccessToken, "t-tok")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
@@ -231,11 +231,11 @@ func TestResolveAccount_InferBothTokens(t *testing.T) {
}
func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliUserAccessToken, "u-tok")
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
t.Setenv(envvars.CliStrictMode, "bot")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliUserAccessToken, "u-tok")
t.Setenv(envnames.CliTenantAccessToken, "t-tok")
t.Setenv(envnames.CliStrictMode, "bot")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
@@ -246,9 +246,9 @@ func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
}
func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "invalid")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliStrictMode, "invalid")
_, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
@@ -258,15 +258,15 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
if !strings.Contains(err.Error(), envnames.CliStrictMode) {
t.Fatalf("error = %v, want mention of %s", err, envnames.CliStrictMode)
}
}
func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliDefaultAs, "invalid")
t.Setenv(envnames.CliAppID, "app")
t.Setenv(envnames.CliAppSecret, "secret")
t.Setenv(envnames.CliDefaultAs, "invalid")
_, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
@@ -276,7 +276,7 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
if !strings.Contains(err.Error(), envnames.CliDefaultAs) {
t.Fatalf("error = %v, want mention of %s", err, envnames.CliDefaultAs)
}
}

View File

@@ -15,9 +15,8 @@ import (
"fmt"
"os"
"github.com/larksuite/cli/envnames"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
@@ -32,7 +31,7 @@ func (p *Provider) Priority() int { return 0 }
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
proxyAddr := os.Getenv(envnames.CliAuthProxy)
if proxyAddr == "" {
return nil, nil // not in sidecar mode, skip
}
@@ -40,26 +39,26 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
Reason: fmt.Sprintf("invalid %s %q: %v", envnames.CliAuthProxy, proxyAddr, err),
}
}
appID := os.Getenv(envvars.CliAppID)
appID := os.Getenv(envnames.CliAppID)
if appID == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
Reason: envnames.CliAuthProxy + " is set but " + envnames.CliAppID + " is missing",
}
}
if os.Getenv(envvars.CliProxyKey) == "" {
if os.Getenv(envnames.CliProxyKey) == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
Reason: envnames.CliAuthProxy + " is set but " + envnames.CliProxyKey + " is missing",
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.ParseBrand(os.Getenv(envnames.CliBrand))
acct := &credential.Account{
AppID: appID,
@@ -68,7 +67,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
}
// Parse DefaultAs
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
switch id := credential.Identity(os.Getenv(envnames.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
@@ -76,12 +75,12 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envnames.CliDefaultAs, id),
}
}
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
switch strictMode := os.Getenv(envnames.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
@@ -91,7 +90,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envnames.CliStrictMode, strictMode),
}
}
@@ -103,7 +102,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
// (user vs bot), strips it, and the sidecar injects the real token.
// Returns nil, nil when sidecar mode is not active.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
if os.Getenv(envvars.CliAuthProxy) == "" {
if os.Getenv(envnames.CliAuthProxy) == "" {
return nil, nil
}

View File

@@ -10,8 +10,8 @@ import (
"os"
"testing"
"github.com/larksuite/cli/envnames"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
@@ -40,7 +40,7 @@ func unsetEnv(t *testing.T, key string) {
}
func TestResolveAccount_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
unsetEnv(t, envnames.CliAuthProxy)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
@@ -53,12 +53,12 @@ func TestResolveAccount_NotActive(t *testing.T) {
}
func TestResolveAccount_Active(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test123")
setEnv(t, envvars.CliBrand, " LARK ")
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envnames.CliProxyKey, "test-key")
setEnv(t, envnames.CliAppID, "cli_test123")
setEnv(t, envnames.CliBrand, " LARK ")
unsetEnv(t, envnames.CliDefaultAs)
unsetEnv(t, envnames.CliStrictMode)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
@@ -83,9 +83,9 @@ func TestResolveAccount_Active(t *testing.T) {
}
func TestResolveAccount_MissingProxyKey(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
unsetEnv(t, envvars.CliProxyKey)
setEnv(t, envvars.CliAppID, "cli_test")
setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384")
unsetEnv(t, envnames.CliProxyKey)
setEnv(t, envnames.CliAppID, "cli_test")
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
@@ -98,9 +98,9 @@ func TestResolveAccount_MissingProxyKey(t *testing.T) {
}
func TestResolveAccount_MissingAppID(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
unsetEnv(t, envvars.CliAppID)
setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envnames.CliProxyKey, "test-key")
unsetEnv(t, envnames.CliAppID)
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
@@ -113,9 +113,9 @@ func TestResolveAccount_MissingAppID(t *testing.T) {
}
func TestResolveAccount_StrictMode(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envnames.CliProxyKey, "test-key")
setEnv(t, envnames.CliAppID, "cli_test")
tests := []struct {
mode string
@@ -131,9 +131,9 @@ func TestResolveAccount_StrictMode(t *testing.T) {
for _, tt := range tests {
t.Run("strict_"+tt.mode, func(t *testing.T) {
if tt.mode == "" {
unsetEnv(t, envvars.CliStrictMode)
unsetEnv(t, envnames.CliStrictMode)
} else {
setEnv(t, envvars.CliStrictMode, tt.mode)
setEnv(t, envnames.CliStrictMode, tt.mode)
}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
@@ -147,7 +147,7 @@ func TestResolveAccount_StrictMode(t *testing.T) {
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
unsetEnv(t, envnames.CliAuthProxy)
p := &Provider{}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
@@ -160,8 +160,8 @@ func TestResolveToken_NotActive(t *testing.T) {
}
func TestResolveToken_Sentinels(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envnames.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envnames.CliProxyKey, "test-key")
p := &Provider{}

View File

@@ -3,16 +3,29 @@
package credential
import "context"
import (
"context"
"github.com/larksuite/cli/brand"
)
// Brand represents the Lark platform brand.
type Brand string
type Brand = brand.Brand
const (
BrandLark Brand = "lark"
BrandFeishu Brand = "feishu"
BrandLark = brand.Lark
BrandFeishu = brand.Feishu
)
// ParseBrand maps a brand string to a Brand, defaulting to BrandFeishu.
// It forwards to the repository-root brand package so every credential source
// and the rest of the CLI resolve the brand through one implementation;
// re-spelling the rule here would let the two copies drift when the brand set
// changes. It stays exported so SDK callers need not import brand directly.
func ParseBrand(value string) Brand {
return brand.ParseBrand(value)
}
// NoAppSecret marks that a credential source does not provide a real app secret.
// Token-only sources should return this value instead of inventing placeholder text.
const NoAppSecret = ""

View File

@@ -20,8 +20,8 @@ import (
"os"
"strings"
"github.com/larksuite/cli/envnames"
"github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
@@ -36,15 +36,15 @@ func (p *Provider) Name() string { return "sidecar" }
// the non-sidecar transport path (where the credential layer will typically
// block them for lack of a valid account).
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
proxyAddr := os.Getenv(envnames.CliAuthProxy)
if proxyAddr == "" {
return nil
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envnames.CliAuthProxy, err)
return nil
}
key := os.Getenv(envvars.CliProxyKey)
key := os.Getenv(envnames.CliProxyKey)
return &Interceptor{
key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr),
@@ -166,12 +166,12 @@ func detectSentinel(req *http.Request) (identity, authHeader string) {
}
func init() {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
proxyAddr := os.Getenv(envnames.CliAuthProxy)
if proxyAddr == "" {
return
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envnames.CliAuthProxy, err)
return
}
transport.Register(&Provider{})

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