Compare commits

...

65 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
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
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
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
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
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
shanglei
5bae5bbbc2 fix(lint): follow the extracted brand resolver 2026-07-28 14:50:25 +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
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
397 changed files with 8348 additions and 3472 deletions

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

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

@@ -49,6 +49,7 @@ 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/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

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

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

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

View File

@@ -14,7 +14,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/core"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/authlog"
)
// Terminal registration outcomes, exposed for typed classification by callers.
@@ -26,7 +27,7 @@ var (
// Protocol defaults, mirroring the official SDK registration flow.
const (
registrationBootstrapBrand = core.BrandFeishu
registrationBootstrapBrand = brandpkg.Feishu
defaultPollIntervalSeconds = 5
defaultExpireInSeconds = 600
beginRequestTimeout = 30 * time.Second
@@ -81,14 +82,14 @@ type AppRegUserInfo struct {
}
// appRegistrationEndpoint returns the brand's accounts registration endpoint.
func appRegistrationEndpoint(brand core.LarkBrand) string {
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
func appRegistrationEndpoint(brand brandpkg.Brand) string {
return brandpkg.ResolveEndpoints(brand).Accounts + PathAppRegistration
}
// RequestAppRegistration initiates the device flow. The registration protocol
// always bootstraps on Feishu; brand selects the user-facing verification host.
// The request is bounded by ctx and a begin timeout.
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand brandpkg.Brand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -96,7 +97,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout)
defer cancel()
ep := core.ResolveEndpoints(brand)
ep := brandpkg.ResolveEndpoints(brand)
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
form := url.Values{}
@@ -116,7 +117,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
logHTTPResponse(newAuthLogger(), resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -180,7 +181,7 @@ func BuildVerificationURL(baseURL, cliVersion string) string {
}
// pollOnce performs one ctx-bound poll request and decodes the payload.
func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string) (map[string]interface{}, error) {
func pollOnce(ctx context.Context, httpClient *http.Client, brand brandpkg.Brand, deviceCode string, logger *authlog.Logger) (map[string]interface{}, error) {
form := url.Values{}
form.Set("action", "poll")
form.Set("device_code", deviceCode)
@@ -196,7 +197,7 @@ func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
return nil, fmt.Errorf("poll network error: %w", err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
logHTTPResponse(logger, resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -214,7 +215,7 @@ func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
// non-error responses without complete credentials keep polling, and one
// deadline from the begin expiry bounds all waits and in-flight requests.
// The returned brand is the one the credentials were issued on.
func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, core.LarkBrand, error) {
func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, brandpkg.Brand, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -230,6 +231,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
effectiveBrand := currentBrand
switched := false
waitBeforePoll := false
authLogger := newAuthLogger()
for {
if waitBeforePoll {
@@ -244,7 +246,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
return nil, effectiveBrand, registrationContextError(ctx)
}
data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode)
data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode, authLogger)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: %v\n", err)
interval = minInt(interval+1, maxPollIntervalSeconds)
@@ -257,7 +259,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
if !switched {
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
if tb := getStr(userInfoRaw, "tenant_brand"); tb != "" {
if actual := core.ParseBrand(tb); actual != currentBrand {
if actual := brandpkg.ParseBrand(tb); actual != currentBrand {
currentBrand = actual
effectiveBrand = actual
switched = true
@@ -285,7 +287,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
// The issuing domain is authoritative; a contradictory final
// tenant report is a protocol violation, not a brand override.
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" &&
core.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand {
brandpkg.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand {
return nil, effectiveBrand, fmt.Errorf("app registration returned credentials with a contradictory tenant brand %q", result.UserInfo.TenantBrand)
}
return result, effectiveBrand, nil

View File

@@ -12,7 +12,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/core"
brandpkg "github.com/larksuite/cli/brand"
"github.com/smartystreets/goconvey/convey"
)
@@ -51,11 +51,11 @@ func Test_BuildVerificationURL(t *testing.T) {
func TestAppRegistrationEndpoint(t *testing.T) {
cases := []struct {
brand core.LarkBrand
brand brandpkg.Brand
want string
}{
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
{brandpkg.Feishu, "https://accounts.feishu.cn" + PathAppRegistration},
{brandpkg.Lark, "https://accounts.larksuite.com" + PathAppRegistration},
}
for _, c := range cases {
if got := appRegistrationEndpoint(c.brand); got != c.want {
@@ -66,11 +66,11 @@ func TestAppRegistrationEndpoint(t *testing.T) {
func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) {
cases := []struct {
brand core.LarkBrand
brand brandpkg.Brand
verificationHost string
}{
{core.BrandFeishu, "open.feishu.cn"},
{core.BrandLark, "open.larksuite.com"},
{brandpkg.Feishu, "open.feishu.cn"},
{brandpkg.Lark, "open.larksuite.com"},
}
for _, c := range cases {
t.Run(string(c.brand), func(t *testing.T) {
@@ -115,7 +115,7 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
t.Errorf("unexpected host polled: %s", r.URL.Host)
return jsonResponse(`{}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
resp, err := RequestAppRegistration(context.Background(), client, brandpkg.Lark, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration error = %v", err)
}
@@ -127,8 +127,8 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandLark {
t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, core.BrandLark)
if finalBrand != brandpkg.Lark {
t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, brandpkg.Lark)
}
if result.ClientID != "cli_x" || result.ClientSecret != "test-secret" {
t.Errorf("credentials = (%q, %q), want (cli_x, test-secret)", result.ClientID, result.ClientSecret)
@@ -162,8 +162,8 @@ func TestRegisterAppWithDiscovery_BootstrapBrandSinglePoll(t *testing.T) {
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandFeishu {
t.Errorf("finalBrand = %q, want %q", finalBrand, core.BrandFeishu)
if finalBrand != brandpkg.Feishu {
t.Errorf("finalBrand = %q, want %q", finalBrand, brandpkg.Feishu)
}
if polls != 1 {
t.Errorf("polls = %d, want 1", polls)
@@ -214,7 +214,7 @@ func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
if polls != 3 {
t.Errorf("polls = %d, want 3", polls)
}
if result.ClientSecret != "test-secret" || finalBrand != core.BrandFeishu {
if result.ClientSecret != "test-secret" || finalBrand != brandpkg.Feishu {
t.Errorf("result = (%q, %q), want (test-secret, feishu)", result.ClientSecret, finalBrand)
}
}
@@ -240,7 +240,7 @@ func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("discovery waited an interval somewhere: took %v", elapsed)
}
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
if finalBrand != brandpkg.Lark || result.ClientSecret != "test-secret" {
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
}
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
@@ -286,7 +286,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err := RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
serve(`{"device_code":"d","expire_in":60,"interval":3}`), brandpkg.Feishu, io.Discard)
if err != nil {
t.Fatalf("begin error = %v", err)
}
@@ -295,7 +295,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
serve(`{"device_code":"d","expires_in":45}`), brandpkg.Feishu, io.Discard)
if err != nil {
t.Fatalf("legacy begin error = %v", err)
}
@@ -304,7 +304,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
serve(`{"device_code":"d","interval":0}`), brandpkg.Feishu, io.Discard)
if err != nil {
t.Fatalf("defaults begin error = %v", err)
}
@@ -313,7 +313,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
if _, err := RequestAppRegistration(context.Background(),
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
serve(`{"interval":5}`), brandpkg.Feishu, io.Discard); err == nil {
t.Error("missing device_code: expected error, got nil")
}
}
@@ -335,7 +335,7 @@ func TestRegisterAppWithDiscovery_PendingWithTenantSignalSwitches(t *testing.T)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
if finalBrand != brandpkg.Lark || result.ClientSecret != "test-secret" {
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
}
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
@@ -394,7 +394,7 @@ func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
Header: make(http.Header),
}, nil
})}
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
_, err := RequestAppRegistration(context.Background(), client, brandpkg.Feishu, io.Discard)
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want a context.Canceled cause", err)
}

View File

@@ -6,14 +6,25 @@ package auth
import (
"net/http"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/authlog"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
type authLogger interface {
LogResponse(path string, status int, logID string)
}
// newAuthLogger returns the process-wide authentication logger. It is shared on
// purpose: one file handle and one log-prune per process, and keychain errors
// land in the same file as the responses they explain.
func newAuthLogger() *authlog.Logger {
return authlog.Shared()
}
// logHTTPResponse logs the HTTP response details for an authentication request.
// It extracts the request path, status code, and x-tt-logid from the given HTTP response.
func logHTTPResponse(resp *http.Response) {
if resp == nil {
func logHTTPResponse(logger authLogger, resp *http.Response) {
if logger == nil || resp == nil {
return
}
@@ -22,20 +33,23 @@ func logHTTPResponse(resp *http.Response) {
path = resp.Request.URL.Path
}
keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid"))
logger.LogResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid"))
}
// logSDKResponse logs the SDK response details for an authentication request.
// It extracts the status code and x-tt-logid from the given API response object.
func logSDKResponse(path string, apiResp *larkcore.ApiResp) {
func logSDKResponse(logger authLogger, path string, apiResp *larkcore.ApiResp) {
if logger == nil {
return
}
if path == "" {
path = "missing"
}
if apiResp == nil {
keychain.LogAuthResponse(path, 0, "")
logger.LogResponse(path, 0, "")
return
}
keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid"))
logger.LogResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid"))
}

View File

@@ -14,7 +14,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/core"
brandpkg "github.com/larksuite/cli/brand"
)
// DeviceAuthResponse is the response from the device authorization endpoint.
@@ -52,8 +52,8 @@ type OAuthEndpoints struct {
}
// ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand.
func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand)
func ResolveOAuthEndpoints(brand brandpkg.Brand) OAuthEndpoints {
ep := brandpkg.ResolveEndpoints(brand)
return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
Revoke: ep.Accounts + PathOAuthRevoke,
@@ -62,7 +62,11 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
}
// RequestDeviceAuthorization requests a device authorization code.
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
return requestDeviceAuthorization(httpClient, appId, appSecret, brand, scope, errOut, newAuthLogger())
}
func requestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, scope string, errOut io.Writer, logger authLogger) (*DeviceAuthResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -95,7 +99,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
logHTTPResponse(logger, resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -139,7 +143,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string
}
// PollDeviceToken polls the token endpoint until authorization completes or times out.
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
if errOut == nil {
errOut = io.Discard
}
@@ -155,6 +159,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0
authLogger := newAuthLogger()
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
@@ -186,7 +191,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
logHTTPResponse(resp)
logHTTPResponse(authLogger, resp)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()

View File

@@ -14,9 +14,9 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/authlog"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/keychain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -25,9 +25,17 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func testAuthLogger(buf *bytes.Buffer, now func() time.Time, args func() []string) *authlog.Logger {
return authlog.New(authlog.Options{
Logger: log.New(buf, "", 0),
Now: now,
Args: args,
})
}
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandFeishu)
ep := ResolveOAuthEndpoints(brand.Feishu)
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
@@ -41,7 +49,7 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
// TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand.
func TestResolveOAuthEndpoints_Lark(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandLark)
ep := ResolveOAuthEndpoints(brand.Lark)
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
@@ -76,14 +84,13 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
})
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
authLogger := testAuthLogger(&buf, func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"}
})
t.Cleanup(restore)
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
_, err := requestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", brand.Feishu, "", nil, authLogger)
if err != nil {
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
}
@@ -108,7 +115,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
got := keychain.FormatAuthCmdline([]string{
got := authlog.FormatAuthCmdline([]string{
"lark-cli",
"auth",
"login",
@@ -126,11 +133,10 @@ func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
// TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully.
func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil)
t.Cleanup(restore)
authLogger := testAuthLogger(&buf, nil, nil)
var resp *http.Response
logHTTPResponse(resp)
logHTTPResponse(authLogger, resp)
if got := buf.String(); got != "" {
t.Fatalf("expected no log output, got %q", got)
@@ -140,14 +146,13 @@ func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) {
// TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking.
func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
authLogger := testAuthLogger(&buf, func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "status", "--verify"}
})
t.Cleanup(restore)
logSDKResponse(PathUserInfoV1, nil)
logSDKResponse(authLogger, PathUserInfoV1, nil)
got := buf.String()
if !strings.Contains(got, "path="+PathUserInfoV1) {
@@ -160,14 +165,13 @@ func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) {
func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
authLogger := testAuthLogger(&buf, func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "login", "--device-code", "secret"}
})
t.Cleanup(restore)
keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse))
authLogger.LogError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse))
got := buf.String()
if !strings.Contains(got, "auth-error") {
@@ -205,7 +209,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", brand.Feishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}

View File

@@ -11,12 +11,12 @@ import (
"net/url"
"strings"
brandpkg "github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// RevokeToken revokes a previously issued OAuth token.
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand brandpkg.Brand, token, tokenTypeHint string) error {
endpoints := ResolveOAuthEndpoints(brand)
form := url.Values{}
@@ -38,7 +38,7 @@ func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.La
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
logHTTPResponse(newAuthLogger(), resp)
body, err := io.ReadAll(resp.Body)
if err != nil {

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