mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
75 Commits
feat/plugi
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cbaa7ff21 | ||
|
|
b79827d60a | ||
|
|
0f35676a28 | ||
|
|
946964e093 | ||
|
|
cfe76ad56a | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 | ||
|
|
a72297b026 | ||
|
|
7512f017d6 | ||
|
|
87be09ef5f | ||
|
|
d0b26821df | ||
|
|
a575a8ba60 | ||
|
|
7faf9da3a4 | ||
|
|
ae56d30ce3 | ||
|
|
7e34eccf3a | ||
|
|
4c22015464 | ||
|
|
1698ac1ff3 | ||
|
|
8f3e9630a1 | ||
|
|
aa93b3f3a6 | ||
|
|
0885ec2eae | ||
|
|
b39258c169 | ||
|
|
2a252d2a80 | ||
|
|
1ba4d1fd77 | ||
|
|
bb7342c3cc | ||
|
|
f25ef0ae75 | ||
|
|
5bae5bbbc2 | ||
|
|
d7cf797bdd | ||
|
|
0266a7e9a1 | ||
|
|
939fc1eeb8 | ||
|
|
c69a61c672 | ||
|
|
ee27d0cdc2 | ||
|
|
3732c6bcce | ||
|
|
9d8e93c682 | ||
|
|
38312d3a9c | ||
|
|
342d1a247d | ||
|
|
770c23035c | ||
|
|
2634092ff2 | ||
|
|
9c50045f14 | ||
|
|
7b6962a726 | ||
|
|
1af34e6649 | ||
|
|
c12ab91349 | ||
|
|
57bf8ccd1e | ||
|
|
97e397cf0c | ||
|
|
c138f29972 | ||
|
|
2662729cd6 | ||
|
|
475f04a8dd | ||
|
|
52a1187c20 | ||
|
|
bf56e903ba | ||
|
|
b028c33e8f | ||
|
|
4073e75def | ||
|
|
72ea02875c | ||
|
|
aec9c4677d | ||
|
|
e5b2e96df4 | ||
|
|
602f6719dc | ||
|
|
85490cb3da | ||
|
|
2d1341aff6 | ||
|
|
aa50bec07e | ||
|
|
5157c3a00e | ||
|
|
5e23bbeddb | ||
|
|
d62f8dcbe8 | ||
|
|
8ba2431192 | ||
|
|
217f4e5567 | ||
|
|
820305536c | ||
|
|
d48c218d0d | ||
|
|
abe0d09d4b | ||
|
|
7c2ca4e465 | ||
|
|
cbe0fb12df | ||
|
|
59b6393250 | ||
|
|
f1ce88b48e | ||
|
|
c09b0d5dd3 | ||
|
|
1772afe22d | ||
|
|
acd50f25fa | ||
|
|
e488cf4cd3 |
13
.github/workflows/arch-audit.yml
vendored
13
.github/workflows/arch-audit.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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)
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -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.
|
||||
|
||||
1
Makefile
1
Makefile
@@ -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
|
||||
|
||||
@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +search-bot
|
||||
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-bot.md
|
||||
|
||||
### Avoid when
|
||||
- Looking for a person rather than a bot → use [[+search-user]]
|
||||
- Running as a bot — this shortcut is user-only
|
||||
|
||||
### Tips
|
||||
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||
|
||||
### Examples
|
||||
|
||||
**Find bots by keyword**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "会议助手" --as user
|
||||
```
|
||||
|
||||
**Search inside one chat**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||
```
|
||||
|
||||
**Find bots you've chatted with**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||
```
|
||||
|
||||
**Search several bot keywords in one call**
|
||||
```bash
|
||||
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# docs
|
||||
> skill: lark-doc
|
||||
|
||||
## +create
|
||||
Create a document from XML or Markdown content.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-create.md
|
||||
|
||||
## +fetch
|
||||
Fetch a document or a focused portion of its content.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-fetch.md
|
||||
|
||||
## +update
|
||||
Update document content with a supported document command.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-update.md
|
||||
|
||||
## +history-list
|
||||
List document history versions.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-history.md
|
||||
|
||||
## +history-revert
|
||||
Revert a document to a history version.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-history.md
|
||||
|
||||
## +history-revert-status
|
||||
Check the status of a document history revert.
|
||||
|
||||
### Skills
|
||||
- lark-doc/references/lark-doc-history.md
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -16,23 +16,12 @@ 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/recovery"
|
||||
"github.com/larksuite/cli/internal/identity"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
return newCmdAuth(f, nil)
|
||||
}
|
||||
|
||||
// NewCmdAuthWithRecovery creates the auth command with a build-local recovery
|
||||
// presenter while preserving NewCmdAuth's established function signature.
|
||||
func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
return newCmdAuth(f, projector)
|
||||
}
|
||||
|
||||
func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
@@ -51,10 +40,10 @@ func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comman
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(newCmdAuthStatus(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(newCmdAuthList(f, nil, projector))
|
||||
cmd.AddCommand(newCmdAuthCheck(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
@@ -141,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
|
||||
}
|
||||
@@ -181,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// CheckOptions holds all inputs for auth check.
|
||||
@@ -25,14 +24,6 @@ type CheckOptions struct {
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command {
|
||||
return newCmdAuthCheck(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdAuthCheck(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*CheckOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &CheckOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -42,7 +33,7 @@ func newCmdAuthCheck(
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authCheckRunWithRecovery(opts, projector)
|
||||
return authCheckRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,10 +46,6 @@ func newCmdAuthCheck(
|
||||
}
|
||||
|
||||
func authCheckRun(opts *CheckOptions) error {
|
||||
return authCheckRunWithRecovery(opts, nil)
|
||||
}
|
||||
|
||||
func authCheckRunWithRecovery(opts *CheckOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
@@ -95,7 +82,7 @@ func authCheckRunWithRecovery(opts *CheckOptions, projector *recovery.Projector)
|
||||
|
||||
ok := len(missing) == 0
|
||||
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
|
||||
if len(missing) > 0 && projector.CanReference(recovery.TargetAuthLogin) {
|
||||
if len(missing) > 0 {
|
||||
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
|
||||
@@ -6,16 +6,14 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"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/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
@@ -26,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.
|
||||
})
|
||||
|
||||
@@ -58,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",
|
||||
})
|
||||
|
||||
@@ -95,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",
|
||||
}
|
||||
@@ -153,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: " "})
|
||||
@@ -165,70 +163,3 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ConcealedLoginOmitsSuggestion(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "im:message",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
visibleFactory, visibleStdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
if err := authCheckRun(&CheckOptions{
|
||||
Factory: visibleFactory,
|
||||
Scope: "calendar:calendar:read",
|
||||
}); output.ExitCodeOf(err) != 1 {
|
||||
t.Fatalf("default check exit = %d, want predicate miss exit 1", output.ExitCodeOf(err))
|
||||
}
|
||||
var visiblePayload map[string]any
|
||||
if err := json.Unmarshal(visibleStdout.Bytes(), &visiblePayload); err != nil {
|
||||
t.Fatalf("default stdout must be valid JSON: %v", err)
|
||||
}
|
||||
if suggestion, _ := visiblePayload["suggestion"].(string); !strings.Contains(suggestion, "auth login") {
|
||||
t.Fatalf("default output lost established login suggestion: %#v", visiblePayload)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
err := authCheckRunWithRecovery(
|
||||
&CheckOptions{Factory: f, Scope: "calendar:calendar:read"},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
)
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Fatalf("exit code = %d, want predicate miss exit 1", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr must stay empty, got:\n%s", stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if _, ok := payload["suggestion"]; ok {
|
||||
t.Fatalf("concealed auth/login left a dead suggestion: %#v", payload["suggestion"])
|
||||
}
|
||||
if missing, ok := payload["missing"].([]any); !ok || len(missing) != 1 {
|
||||
t.Fatalf("projection removed missing-scope facts: %#v", payload["missing"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ 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/recovery"
|
||||
)
|
||||
|
||||
// ListOptions holds all inputs for auth list.
|
||||
@@ -25,14 +24,6 @@ type ListOptions struct {
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
return newCmdAuthList(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdAuthList(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*ListOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &ListOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -42,7 +33,7 @@ func newCmdAuthList(
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authListRunWithRecovery(opts, projector)
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
@@ -52,13 +43,9 @@ func newCmdAuthList(
|
||||
}
|
||||
|
||||
func authListRun(opts *ListOptions) error {
|
||||
return authListRunWithRecovery(opts, nil)
|
||||
}
|
||||
|
||||
func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) 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{}{
|
||||
@@ -74,7 +61,7 @@ func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) e
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(projector.Render(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)
|
||||
@@ -93,11 +80,7 @@ func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) e
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprint(f.IOStreams.ErrOut, "No logged-in users.")
|
||||
if projector.CanReference(recovery.TargetAuthLogin) {
|
||||
fmt.Fprint(f.IOStreams.ErrOut, " Run `lark-cli auth login` to log in.")
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
@@ -71,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 {
|
||||
@@ -128,49 +126,7 @@ func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
|
||||
!strings.Contains(got, "auth login") {
|
||||
t.Errorf("stderr = %q, want established no-users login hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_ConcealedLoginKeepsStateWithoutDeadRecovery(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
if err := authListRunWithRecovery(
|
||||
&ListOptions{Factory: f},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
); err != nil {
|
||||
t.Fatalf("auth list should remain a successful probe: %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout must stay empty, got:\n%s", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
|
||||
strings.Contains(got, "auth login") {
|
||||
t.Fatalf("concealed recovery = %q, want state without dead login action", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_ConcealedConfigInitProjectsManualErrorOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandConfigInit: surface.CommandConcealed,
|
||||
})
|
||||
if err := authListRunWithRecovery(
|
||||
&ListOptions{Factory: f},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
); err != nil {
|
||||
t.Fatalf("auth list should remain a successful probe: %v", err)
|
||||
}
|
||||
if got := stderr.String(); strings.Contains(got, "config init") {
|
||||
t.Fatalf("manual config error rendering retained concealed recovery: %q", got)
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// StatusOptions holds all inputs for auth status.
|
||||
@@ -23,14 +22,6 @@ type StatusOptions struct {
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||
return newCmdAuthStatus(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdAuthStatus(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*StatusOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &StatusOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -40,7 +31,7 @@ func newCmdAuthStatus(
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authStatusRun(opts, projector)
|
||||
return authStatusRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,7 +42,7 @@ func newCmdAuthStatus(
|
||||
return cmd
|
||||
}
|
||||
|
||||
func authStatusRun(opts *StatusOptions, projector *recovery.Projector) error {
|
||||
func authStatusRun(opts *StatusOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := f.Config()
|
||||
@@ -69,14 +60,11 @@ func authStatusRun(opts *StatusOptions, projector *recovery.Projector) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
diagnostics := identitydiag.FilterRecovery(
|
||||
identitydiag.Diagnose(context.Background(), f, config, opts.Verify),
|
||||
projector.CanReference,
|
||||
)
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics, projector.CanReference(recovery.TargetAuthLogin))
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
@@ -118,21 +106,13 @@ func addEffectiveVerification(result map[string]interface{}, d identitydiag.Resu
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result, canAuthLogin bool) {
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
note := "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls."
|
||||
if canAuthLogin {
|
||||
note += " Run `lark-cli auth login` to enable user identity."
|
||||
}
|
||||
result["note"] = note
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
|
||||
case d.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
|
||||
case !d.User.Available && !d.Bot.Available:
|
||||
note := "No usable identity is available. Configure bot credentials"
|
||||
if canAuthLogin {
|
||||
note += " or run `lark-cli auth login`"
|
||||
}
|
||||
result["note"] = note + "."
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,18 @@ 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}, nil); err != nil {
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -54,7 +55,7 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}, nil); err != nil {
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -73,18 +70,3 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDeferredBootstrapProfileError(t *testing.T) {
|
||||
if !isDeferredBootstrapProfileError(errors.New("flag needs an argument: --profile")) {
|
||||
t.Fatal("missing --profile value must be deferred to the completed Cobra tree")
|
||||
}
|
||||
for _, err := range []error{
|
||||
nil,
|
||||
errors.New("flag needs an argument: --future"),
|
||||
errors.New("invalid argument for --profile"),
|
||||
} {
|
||||
if isDeferredBootstrapProfileError(err) {
|
||||
t.Fatalf("unexpected deferred bootstrap error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
196
cmd/build.go
196
cmd/build.go
@@ -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"
|
||||
@@ -21,20 +22,13 @@ import (
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"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"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
"github.com/larksuite/cli/internal/skillref"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -43,29 +37,14 @@ import (
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
presentation restrictionPresentationConfig
|
||||
skipPlugins bool
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
deferStartup bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand core.LarkBrand
|
||||
startupBrandSet bool
|
||||
hideProfileSet bool
|
||||
}
|
||||
|
||||
// buildRuntime owns presentation state for exactly one command tree. Factory
|
||||
// remains the business dependency container; distribution policy never enters
|
||||
// it. The embedded pointer preserves convenient access to Factory fields in
|
||||
// cmd-internal tests without exposing the surface plan to business packages.
|
||||
type buildRuntime struct {
|
||||
*cmdutil.Factory
|
||||
surface *surface.Plan
|
||||
recovery *recovery.Projector
|
||||
skillReferences *skillref.Resolver
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
skipPlugins bool
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand brandpkg.Brand
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
@@ -73,10 +52,9 @@ type buildRuntime 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
|
||||
c.startupBrandSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,12 +85,6 @@ var embeddedSkillContent fs.FS
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// SetEmbeddedAffordanceContent registers the per-domain command guidance tree.
|
||||
// Wrapper mains should wire the repository's affordance directory alongside
|
||||
// embedded skills so generic --help presentation remains complete and skill
|
||||
// references follow the composed distribution.
|
||||
func SetEmbeddedAffordanceContent(fsys fs.FS) { affordance.SetSource(fsys) }
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -120,7 +92,6 @@ func SetEmbeddedAffordanceContent(fsys fs.FS) { affordance.SetSource(fsys) }
|
||||
func HideProfile(hide bool) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.globals.HideProfile = hide
|
||||
c.hideProfileSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,11 +147,11 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
//
|
||||
// Returns (runtime, rootCmd, registry). The registry is nil when plugin
|
||||
// Returns (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
// hooks; callers that wire Shutdown emit must nil-check before calling
|
||||
// hook.Emit.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*buildRuntime, *cobra.Command, *hook.Registry) {
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
|
||||
// cfg.globals.Profile is left zero here; it's bound to the --profile
|
||||
// flag in RegisterGlobalFlags and filled by cobra's parse step.
|
||||
cfg := &buildConfig{}
|
||||
@@ -189,16 +160,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
o(cfg)
|
||||
}
|
||||
}
|
||||
return buildInternalWithConfig(ctx, inv, cfg)
|
||||
}
|
||||
|
||||
// buildInternalWithConfig assembles one command tree from an already-applied
|
||||
// option snapshot. Execute uses this boundary so stateful BuildOptions are
|
||||
// never evaluated once for bootstrap inspection and a second time for Build.
|
||||
func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, cfg *buildConfig) (*buildRuntime, *cobra.Command, *hook.Registry) {
|
||||
if cfg == nil {
|
||||
cfg = &buildConfig{}
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
@@ -206,28 +167,18 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
|
||||
if cfg.streams == nil {
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
// Initialize the registry brand before anything touches the runtime
|
||||
// catalog (its sync.Once would otherwise lock onto the Feishu default).
|
||||
if cfg.startupBrand != "" {
|
||||
registry.InitWithBrand(cfg.startupBrand)
|
||||
}
|
||||
|
||||
// Reset the legacy process-global diagnostic snapshots before paths that
|
||||
// may return early. Distribution presentation state is deliberately not
|
||||
// stored here; it belongs to this build's immutable surface plan.
|
||||
cmdpolicy.SetActive(nil)
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
runtime := &buildRuntime{Factory: f}
|
||||
runtime.recovery = recovery.NewProjector(func() *surface.Plan {
|
||||
return runtime.surface
|
||||
})
|
||||
f.Recovery = runtime.recovery
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -244,17 +195,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
// Framework-generated skill pointers read this build's final content and
|
||||
// exact command surface lazily. A second Build therefore cannot rewrite
|
||||
// help rendered by the first tree.
|
||||
installTipsHelpFunc(rootCmd, func() fs.FS {
|
||||
if !runtime.surface.CanReference(surface.CommandSkillsRead) {
|
||||
return nil
|
||||
}
|
||||
return runtime.SkillContent
|
||||
}, func() *skillref.Resolver {
|
||||
return runtime.skillReferences
|
||||
}, runtime.recovery)
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
@@ -270,11 +211,11 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
|
||||
f.CurrentCommand = cmd
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfigWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(auth.NewCmdAuthWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctorWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoamiWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -290,93 +231,52 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
|
||||
}
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
classifyRootCommands(rootCmd)
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd, runtime.recovery)
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
var (
|
||||
installResult *internalplatform.InstallResult
|
||||
pluginRules []cmdpolicy.PluginRule
|
||||
pluginSkills []skillpolicy.PluginSkill
|
||||
hookRegistry *hook.Registry
|
||||
denied map[string]cmdpolicy.Denial
|
||||
)
|
||||
if cfg.skipPlugins {
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
|
||||
if !cfg.skipPlugins {
|
||||
var installErr error
|
||||
installResult, installErr = installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
return finalizeFailedBuild(runtime, rootCmd)
|
||||
}
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
pluginSkills = installResult.PluginSkills
|
||||
hookRegistry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
var policyErr error
|
||||
denied, policyErr = applyUserPolicyPruning(rootCmd, pluginRules)
|
||||
if policyErr != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, policyErr)
|
||||
return finalizeFailedBuild(runtime, rootCmd)
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, policyErr)
|
||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var pluginRules []cmdpolicy.PluginRule
|
||||
var registry *hook.Registry
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
registry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
}
|
||||
|
||||
// Presentation is an explicit host projection over the exact enforcement
|
||||
// decisions. With no opt-in, legacy Restrict and YAML policy behavior is
|
||||
// mechanically unchanged.
|
||||
var hasConcealedCommands bool
|
||||
runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied)
|
||||
|
||||
// Resolve skill assets and canonical references before installing hooks.
|
||||
// A declared customization is a build-integrity boundary: failure must
|
||||
// happen before Startup so no lifecycle side effect is stranded.
|
||||
skillResolution, skillErr := skillpolicy.ResolveWithReferences(embeddedSkillContent, pluginSkills)
|
||||
if skillErr != nil {
|
||||
installPluginSkillErrorGuard(rootCmd, skillErr)
|
||||
return finalizeFailedBuild(runtime, rootCmd)
|
||||
}
|
||||
f.SkillContent = skillResolution.Content
|
||||
runtime.skillReferences = skillResolution.References
|
||||
|
||||
// Install hooks only on business commands. The concealment-specific help
|
||||
// command is attached afterwards, preserving Cobra's historical contract
|
||||
// that help is not observed or wrapped by plugins.
|
||||
if hookRegistry != nil {
|
||||
installHooks(rootCmd, hookRegistry)
|
||||
}
|
||||
if hasConcealedCommands {
|
||||
installHelpCommand(rootCmd)
|
||||
}
|
||||
finalizeRootCommandGroups(rootCmd, runtime.surface)
|
||||
|
||||
if hookRegistry != nil && !cfg.deferStartup {
|
||||
if err := emitStartup(ctx, hookRegistry); err != nil {
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
recordInventory(installResult)
|
||||
return runtime, rootCmd, nil
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
}
|
||||
|
||||
recordInventory(installResult)
|
||||
return runtime, rootCmd, hookRegistry
|
||||
}
|
||||
|
||||
func finalizeFailedBuild(runtime *buildRuntime, root *cobra.Command) (*buildRuntime, *cobra.Command, *hook.Registry) {
|
||||
finalizeRootCommandGroups(root, runtime.surface)
|
||||
return runtime, root, nil
|
||||
return f, rootCmd, registry
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -29,10 +28,6 @@ func TestBuild_ExternalAPI(t *testing.T) {
|
||||
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
|
||||
SetDefaultFS(vfs.OsFs{})
|
||||
SetDefaultFS(nil)
|
||||
SetEmbeddedAffordanceContent(fstest.MapFS{
|
||||
"docs.md": {Data: []byte("# docs\n")},
|
||||
})
|
||||
t.Cleanup(func() { SetEmbeddedAffordanceContent(nil) })
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
rootCmd := Build(
|
||||
|
||||
@@ -14,13 +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/recovery"
|
||||
"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.
|
||||
@@ -60,14 +62,6 @@ type BindOptions struct {
|
||||
|
||||
// NewCmdConfigBind creates the config bind subcommand.
|
||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||
return newCmdConfigBind(f, runF, nil)
|
||||
}
|
||||
|
||||
func newCmdConfigBind(
|
||||
f *cmdutil.Factory,
|
||||
runF func(*BindOptions) error,
|
||||
projector *recovery.Projector,
|
||||
) *cobra.Command {
|
||||
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -107,7 +101,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return configBindRunWithRecovery(opts, projector)
|
||||
return configBindRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -125,10 +119,6 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
// helper whose signature declares its contract; the body reads as the shape of
|
||||
// the bind flow itself, not its mechanics.
|
||||
func configBindRun(opts *BindOptions) error {
|
||||
return configBindRunWithRecovery(opts, nil)
|
||||
}
|
||||
|
||||
func configBindRunWithRecovery(opts *BindOptions, projector *recovery.Projector) error {
|
||||
if err := validateBindFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,8 +131,8 @@ func configBindRunWithRecovery(opts *BindOptions, projector *recovery.Projector)
|
||||
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 {
|
||||
@@ -167,7 +157,7 @@ func configBindRunWithRecovery(opts *BindOptions, projector *recovery.Projector)
|
||||
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath, projector)
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
}
|
||||
|
||||
// existingBinding is the outcome of checking whether a workspace was already
|
||||
@@ -199,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"
|
||||
}
|
||||
|
||||
@@ -277,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
|
||||
@@ -320,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
|
||||
}
|
||||
}
|
||||
@@ -382,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)
|
||||
}
|
||||
@@ -402,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 ""
|
||||
}
|
||||
@@ -417,16 +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,
|
||||
projector *recovery.Projector,
|
||||
) 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, "", " ")
|
||||
@@ -481,7 +465,7 @@ func commitBinding(
|
||||
case "bot-only":
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
case "user-default":
|
||||
envelope["message"] = userDefaultBindMessage(prefMsg, appConfig.AppId, display, projector)
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(envelope)
|
||||
@@ -489,25 +473,14 @@ func commitBinding(
|
||||
return nil
|
||||
}
|
||||
|
||||
func userDefaultBindMessage(
|
||||
messages *bindMsg,
|
||||
appID, display string,
|
||||
projector *recovery.Projector,
|
||||
) string {
|
||||
if projector.CanReference(recovery.TargetAuthLogin) {
|
||||
return fmt.Sprintf(messages.MessageUserDefault, appID, display, display)
|
||||
}
|
||||
return fmt.Sprintf(messages.MessageUserDefaultFallback, appID, display)
|
||||
}
|
||||
|
||||
// cleanupKeychainFromData removes keychain entries referenced by a previous
|
||||
// config snapshot, skipping any entry whose keychain ID is still in use by
|
||||
// the new app config. This prevents rebinding the same appId from deleting
|
||||
// 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
|
||||
}
|
||||
@@ -519,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,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
|
||||
@@ -612,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,
|
||||
|
||||
@@ -37,9 +37,6 @@ type bindMsg struct {
|
||||
// MessageBotOnly format: app_id, source display name, brand.
|
||||
// MessageUserDefault format: app_id, source display name, source display
|
||||
// name (second source ref anchors the "run in this chat" directive).
|
||||
// MessageUserDefaultFallback format: app_id, source display name. It keeps
|
||||
// the completed bind facts but uses target-free recovery when auth/login
|
||||
// is not part of this distribution.
|
||||
// MessageUserDefault directs the Agent at the blocking single-call
|
||||
// `auth login --recommend` flow: the CLI streams verification_url to
|
||||
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
|
||||
@@ -47,9 +44,8 @@ type bindMsg struct {
|
||||
// The Agent also needs an explicit "do not navigate the URL yourself"
|
||||
// guard — its own browser is sandboxed and cannot complete the user's
|
||||
// authorization.
|
||||
MessageBotOnly string
|
||||
MessageUserDefault string
|
||||
MessageUserDefaultFallback string
|
||||
MessageBotOnly string
|
||||
MessageUserDefault string
|
||||
|
||||
// Identity preset (collapses strict-mode + default-as into one choice).
|
||||
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
|
||||
@@ -112,9 +108,8 @@ var bindMsgZh = &bindMsg{
|
||||
ConflictCancel: "保留当前配置",
|
||||
ConflictCancelled: "已保留当前配置",
|
||||
|
||||
MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用%s API,现在可以继续执行用户的请求。",
|
||||
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
MessageUserDefaultFallback: "已绑定应用 %s 到 %s。请通过该发行版支持的授权流程获取或刷新用户凭证,然后再继续执行用户的请求。",
|
||||
MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用%s API,现在可以继续执行用户的请求。",
|
||||
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
|
||||
SelectIdentity: "你希望 AI 如何与你协作?",
|
||||
IdentityBotOnly: "以机器人身份",
|
||||
@@ -149,9 +144,8 @@ var bindMsgEn = &bindMsg{
|
||||
ConflictCancel: "Keep current config",
|
||||
ConflictCancelled: "Current config kept. No changes made.",
|
||||
|
||||
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
|
||||
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
|
||||
MessageUserDefaultFallback: "Bound app %s to %s. Obtain or refresh a user credential through this distribution's supported authorization flow before continuing with the user's request.",
|
||||
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
|
||||
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
|
||||
|
||||
SelectIdentity: "How should the AI work with you?",
|
||||
IdentityBotOnly: "As bot",
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
|
||||
@@ -62,29 +60,3 @@ func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
|
||||
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDefaultBindMessageProjectsConcealedLogin(t *testing.T) {
|
||||
visible := userDefaultBindMessage(bindMsgEn, "cli_test", "Hermes", nil)
|
||||
if !strings.Contains(visible, "lark-cli auth login --recommend") {
|
||||
t.Fatalf("default message lost established login action: %q", visible)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
concealed := userDefaultBindMessage(
|
||||
bindMsgEn,
|
||||
"cli_test",
|
||||
"Hermes",
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
)
|
||||
if strings.Contains(concealed, "auth login") ||
|
||||
!strings.Contains(concealed, "supported authorization flow") {
|
||||
t.Fatalf("concealed message = %q, want target-free authorization fallback", concealed)
|
||||
}
|
||||
for _, want := range []string{"cli_test", "Hermes"} {
|
||||
if !strings.Contains(concealed, want) {
|
||||
t.Errorf("concealed message lost binding fact %q: %q", want, concealed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,24 +4,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/brand"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfig creates the config command with subcommands.
|
||||
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
return newCmdConfig(f, nil)
|
||||
}
|
||||
|
||||
// NewCmdConfigWithRecovery creates the config command with build-local
|
||||
// recovery projection while preserving NewCmdConfig's established signature.
|
||||
func NewCmdConfigWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
return newCmdConfig(f, projector)
|
||||
}
|
||||
|
||||
func newCmdConfig(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Global CLI configuration management",
|
||||
@@ -37,7 +26,7 @@ func newCmdConfig(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comm
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdConfigInit(f, nil))
|
||||
cmd.AddCommand(newCmdConfigBind(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdConfigBind(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
@@ -49,6 +38,6 @@ func newCmdConfig(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comm
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseBrand(value string) core.LarkBrand {
|
||||
return core.ParseBrand(value)
|
||||
func parseBrand(value string) brand.Brand {
|
||||
return brand.ParseBrand(value)
|
||||
}
|
||||
|
||||
@@ -12,16 +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/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -68,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
|
||||
@@ -110,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)
|
||||
}
|
||||
|
||||
@@ -188,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)
|
||||
}
|
||||
@@ -320,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)
|
||||
}
|
||||
|
||||
@@ -359,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)
|
||||
}
|
||||
@@ -379,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")
|
||||
}
|
||||
@@ -430,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")
|
||||
}
|
||||
@@ -566,59 +566,3 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The "no active profile" producer annotates its profile/list recovery target.
|
||||
// Rendering against one build's surface filters a clone without mutating the
|
||||
// value another command tree may render.
|
||||
func TestConfigShowRun_ProfileHintUsesBuildLocalSurface(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
source := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
var original *errs.ConfigError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T %v", source, source)
|
||||
}
|
||||
if original.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Fatalf("subtype = %q, want not_configured", original.Subtype)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "lark-cli profile list") {
|
||||
t.Fatalf("producer hint = %q, want profile list", original.Hint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandProfileList: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ConfigError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
|
||||
}
|
||||
if concealed == original {
|
||||
t.Fatal("Render must clone the typed error")
|
||||
}
|
||||
if strings.Contains(concealed.Hint, "profile list") ||
|
||||
!strings.Contains(concealed.Hint, "select or configure an available profile") {
|
||||
t.Errorf("concealed hint = %q, want target-free profile recovery", concealed.Hint)
|
||||
}
|
||||
|
||||
var visible *errs.ConfigError
|
||||
if !errors.As(recovery.Render(source, nil), &visible) ||
|
||||
!strings.Contains(visible.Hint, "lark-cli profile list") {
|
||||
t.Errorf("visible render must keep profile list, got %+v", visible)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "lark-cli profile list") {
|
||||
t.Errorf("concealed render mutated source hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,14 +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"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
secretpkg "github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
)
|
||||
|
||||
// ConfigInitOptions holds all inputs for config init.
|
||||
@@ -41,44 +43,13 @@ type ConfigInitOptions struct {
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses so the distribution's
|
||||
// supported Agent-app setup flow remains the default. Manual users with
|
||||
// a legitimate need for a separate app can pass --force-init to bypass.
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
// users with a legitimate need for a separate app can pass --force-init
|
||||
// to bypass.
|
||||
ForceInit bool
|
||||
}
|
||||
|
||||
const (
|
||||
configInitLongPrefix = `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command`
|
||||
|
||||
configInitBindGuidance = `
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one.`
|
||||
|
||||
configInitBindFallback = `
|
||||
refuses by default to avoid creating a parallel app alongside Agent-managed
|
||||
credentials. Reuse the Agent's existing app through this distribution's
|
||||
supported setup flow.`
|
||||
|
||||
configInitBindSuffix = ` Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`
|
||||
|
||||
configInitFallbackSuffix = ` Pass --force-init only if the user explicitly wants a
|
||||
separate app inside the Agent workspace.`
|
||||
|
||||
configInitLongWithBind = configInitLongPrefix + configInitBindGuidance + configInitBindSuffix
|
||||
configInitLongWithoutBind = configInitLongPrefix + configInitBindFallback + configInitFallbackSuffix
|
||||
|
||||
forceInitUsageWithBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
|
||||
|
||||
forceInitUsageWithoutBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
|
||||
)
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
@@ -86,7 +57,16 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
|
||||
Long: configInitLongWithBind,
|
||||
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
@@ -109,30 +89,12 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, forceInitUsageWithBind)
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ProjectInitHelp keeps the default command-specific guidance intact and
|
||||
// replaces it only when this build conceals config bind. The config package
|
||||
// owns both variants; the root presentation pass supplies the build-local
|
||||
// availability decision after plugin policy has finalized the command tree.
|
||||
func ProjectInitHelp(cmd *cobra.Command, canReferenceBind bool) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
long, forceInitUsage := configInitLongWithBind, forceInitUsageWithBind
|
||||
if !canReferenceBind {
|
||||
long, forceInitUsage = configInitLongWithoutBind, forceInitUsageWithoutBind
|
||||
}
|
||||
cmd.Long = long
|
||||
if flag := cmd.Flags().Lookup("force-init"); flag != nil {
|
||||
flag.Usage = forceInitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// printLangPreferenceConfirmation echoes the set preference to stderr, only
|
||||
// when --lang explicitly set a non-empty value.
|
||||
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
|
||||
@@ -162,18 +124,13 @@ 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
|
||||
}
|
||||
return recovery.Attach(
|
||||
errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
recovery.Join(" ",
|
||||
recovery.Command(recovery.TargetConfigBind, "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead."),
|
||||
recovery.Text("Pass --force-init only if the user explicitly wants a separate app in this workspace."),
|
||||
),
|
||||
)
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
|
||||
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
@@ -182,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
|
||||
}
|
||||
@@ -190,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)
|
||||
}
|
||||
@@ -198,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)
|
||||
}
|
||||
@@ -241,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
|
||||
@@ -267,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
|
||||
}
|
||||
@@ -291,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
|
||||
}
|
||||
@@ -318,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]
|
||||
@@ -348,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 {
|
||||
@@ -369,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)
|
||||
}
|
||||
}
|
||||
@@ -384,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 {
|
||||
@@ -423,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)
|
||||
}
|
||||
@@ -450,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)
|
||||
}
|
||||
@@ -489,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("")
|
||||
}
|
||||
@@ -540,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
|
||||
}
|
||||
@@ -559,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 {
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
@@ -46,82 +44,6 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_BindRecoveryUsesBuildLocalSurface(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
source := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
var original *errs.ConfigError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("guardAgentWorkspace() error = %T, want *errs.ConfigError", source)
|
||||
}
|
||||
const visibleHint = "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace."
|
||||
if original.Hint != visibleHint {
|
||||
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandConfigBind: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ConfigError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
|
||||
}
|
||||
const forceInitHint = "Pass --force-init only if the user explicitly wants a separate app in this workspace."
|
||||
if concealed.Hint != forceInitHint {
|
||||
t.Errorf("concealed hint = %q, want %q", concealed.Hint, forceInitHint)
|
||||
}
|
||||
if original.Hint != visibleHint {
|
||||
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectInitHelpPreservesDefaultAndProjectsConcealedBind(t *testing.T) {
|
||||
cmd := NewCmdConfigInit(nil, nil)
|
||||
forceInit := cmd.Flags().Lookup("force-init")
|
||||
if forceInit == nil {
|
||||
t.Fatal("config init command has no --force-init flag")
|
||||
}
|
||||
const defaultLong = `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`
|
||||
const defaultForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
|
||||
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
|
||||
t.Fatalf("default help lost config bind recovery:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
|
||||
ProjectInitHelp(cmd, false)
|
||||
const concealedLong = `Initialize configuration (app-id / app-secret-stdin / brand).
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default to avoid creating a parallel app alongside Agent-managed
|
||||
credentials. Reuse the Agent's existing app through this distribution's
|
||||
supported setup flow. Pass --force-init only if the user explicitly wants a
|
||||
separate app inside the Agent workspace.`
|
||||
const concealedForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
|
||||
if cmd.Long != concealedLong || forceInit.Usage != concealedForceInitUsage {
|
||||
t.Fatalf("concealed help was not projected:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "config bind") || strings.Contains(forceInit.Usage, "config bind") {
|
||||
t.Fatalf("concealed help retained config bind:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
|
||||
ProjectInitHelp(cmd, true)
|
||||
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
|
||||
t.Fatalf("visible projection did not restore default help:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
t.Setenv("HERMES_HOME", t.TempDir())
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,6 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
if len(p.Rules) > 0 {
|
||||
entry["rules"] = p.Rules
|
||||
}
|
||||
if p.EmbeddedSkills != nil {
|
||||
entry["embedded_skills"] = p.EmbeddedSkills
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// config plugins show must surface a plugin's EmbeddedSkills contribution in
|
||||
// the rendered JSON, not only in the internal inventory struct: this command is
|
||||
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
|
||||
// Base summary has to reach stdout. Guards the render layer, which asserting the
|
||||
// inventory struct alone does not exercise.
|
||||
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
EmbeddedSkills: &internalplatform.SkillsOverlayView{
|
||||
Allow: []string{"lark-im"},
|
||||
Remove: []string{"lark-a"},
|
||||
Overlay: true,
|
||||
Base: true,
|
||||
},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Plugins []struct {
|
||||
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got.Plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
|
||||
}
|
||||
es := got.Plugins[0].EmbeddedSkills
|
||||
if es == nil {
|
||||
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
|
||||
}
|
||||
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
|
||||
!es.Overlay || !es.Base {
|
||||
t.Errorf("embedded_skills summary mismatch: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that did not customize embedded skills must not emit an
|
||||
// embedded_skills key, so the field's presence is a reliable signal that a fork
|
||||
// trimmed the tree.
|
||||
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("not json: %v", err)
|
||||
}
|
||||
plugins, ok := raw["plugins"].([]any)
|
||||
if !ok || len(plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin in output, got: %s", out.String())
|
||||
}
|
||||
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
|
||||
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||
out := map[string]any{
|
||||
"source": string(active.Source.Kind),
|
||||
"source_name": sourceName,
|
||||
"denied_paths": active.DeniedPathCount(),
|
||||
"denied_paths": active.DeniedPaths,
|
||||
}
|
||||
if len(active.Rules) > 0 {
|
||||
rules := make([]map[string]any, 0, len(active.Rules))
|
||||
|
||||
@@ -62,10 +62,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
},
|
||||
DeniedByPath: map[string]cmdpolicy.Denial{
|
||||
"docs/create": {},
|
||||
"docs/update": {},
|
||||
},
|
||||
DeniedPaths: 42,
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
@@ -83,8 +80,8 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
t.Errorf("source_name = %v, want secaudit", got["source_name"])
|
||||
}
|
||||
// json.Unmarshal returns float64 for numbers.
|
||||
if got["denied_paths"] != float64(2) {
|
||||
t.Errorf("denied_paths = %v, want 2", got["denied_paths"])
|
||||
if got["denied_paths"] != float64(42) {
|
||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||
}
|
||||
rulesAny, ok := got["rules"].([]any)
|
||||
if !ok || len(rulesAny) != 1 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -11,9 +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/recovery"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -44,26 +44,19 @@ 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 {
|
||||
hint := recovery.Join("",
|
||||
recovery.Command(recovery.TargetProfileList, "run: lark-cli profile list")).
|
||||
WithFallback("select or configure an available profile through this distribution")
|
||||
return recovery.Annotate(
|
||||
errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
|
||||
WithHint("%s", hint.String()),
|
||||
hint,
|
||||
)
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
}
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
@@ -74,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": "****",
|
||||
@@ -82,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
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,15 +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/recovery"
|
||||
"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.
|
||||
@@ -34,17 +35,6 @@ type DoctorOptions struct {
|
||||
|
||||
// NewCmdDoctor creates the doctor command.
|
||||
func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
return newCmdDoctor(f, nil)
|
||||
}
|
||||
|
||||
// NewCmdDoctorWithRecovery creates the doctor command with a build-local
|
||||
// recovery presenter. Distribution assembly uses this boundary; ordinary
|
||||
// callers keep NewCmdDoctor's original function signature and default output.
|
||||
func NewCmdDoctorWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
return newCmdDoctor(f, projector)
|
||||
}
|
||||
|
||||
func newCmdDoctor(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
|
||||
opts := &DoctorOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -52,7 +42,7 @@ func newCmdDoctor(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comm
|
||||
Short: "CLI health check: config, auth, and connectivity",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
return doctorRun(opts, projector)
|
||||
return doctorRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
@@ -86,18 +76,18 @@ func skip(name, msg string) checkResult {
|
||||
return checkResult{Name: name, Status: "skip", Message: msg}
|
||||
}
|
||||
|
||||
func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
|
||||
func doctorRun(opts *DoctorOptions) error {
|
||||
f := opts.Factory
|
||||
var checks []checkResult
|
||||
|
||||
// ── 0. CLI version & update check ──
|
||||
checks = append(checks, pass("cli_version", build.Version))
|
||||
if !opts.Offline && projector.CanReference(recovery.TargetUpdate) {
|
||||
if !opts.Offline {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
@@ -108,7 +98,7 @@ func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
|
||||
if errors.As(configpkg.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
@@ -122,7 +112,7 @@ func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(projector.Render(err), &cfgErr) {
|
||||
if errors.As(err, &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
checks = append(checks, fail("app_resolved", err.Error(), hint))
|
||||
@@ -130,13 +120,10 @@ func doctorRun(opts *DoctorOptions, projector *recovery.Projector) 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.FilterRecovery(
|
||||
identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline),
|
||||
projector.CanReference,
|
||||
)
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
@@ -164,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)"),
|
||||
@@ -230,7 +217,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
func checkCLIUpdate() []checkResult {
|
||||
latest, err := fetchLatestForDoctor()
|
||||
latest, err := update.FetchLatest()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
@@ -243,8 +230,6 @@ func checkCLIUpdate() []checkResult {
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
var fetchLatestForDoctor = update.FetchLatest
|
||||
|
||||
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
allOK := true
|
||||
for _, c := range checks {
|
||||
@@ -256,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)
|
||||
|
||||
@@ -13,17 +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/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"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)
|
||||
@@ -90,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,55 +103,30 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRunDoesNotFetchUpdateWhenCommandIsConcealed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
oldFetch := fetchLatestForDoctor
|
||||
t.Cleanup(func() { fetchLatestForDoctor = oldFetch })
|
||||
|
||||
fetches := 0
|
||||
fetchLatestForDoctor = func() (string, error) {
|
||||
fetches++
|
||||
return "9.9.9", nil
|
||||
}
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
})
|
||||
projector := recovery.NewProjector(func() *surface.Plan { return plan })
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
_ = doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
}, projector)
|
||||
if fetches != 0 {
|
||||
t.Fatalf("concealed update triggered %d npm fetch(es)", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
@@ -207,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,
|
||||
@@ -224,12 +199,12 @@ 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{}},
|
||||
}
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}, nil); err == nil {
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -13,65 +14,12 @@ 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"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
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"
|
||||
)
|
||||
|
||||
// rootErrorPresenter owns the final command-facing error transformation for
|
||||
// one Cobra tree. Producers report typed facts and optional semantic recovery;
|
||||
// this boundary clones, completes, and projects them without exposing the
|
||||
// build-local surface plan to business packages.
|
||||
type rootErrorPresenter struct {
|
||||
f *cmdutil.Factory
|
||||
projector *recovery.Projector
|
||||
}
|
||||
|
||||
func newRootErrorPresenter(f *cmdutil.Factory, projector *recovery.Projector) *rootErrorPresenter {
|
||||
return &rootErrorPresenter{f: f, projector: projector}
|
||||
}
|
||||
|
||||
func (p *rootErrorPresenter) Present(err error) error {
|
||||
if err == nil || errs.IsRaw(err) {
|
||||
return err
|
||||
}
|
||||
rendered := p.projector.Render(err)
|
||||
p.completePermissionRecovery(rendered)
|
||||
applyNeedAuthorizationHint(p.f, rendered)
|
||||
return rendered
|
||||
}
|
||||
|
||||
// completePermissionRecovery supplies the canonical recovery for direct
|
||||
// PermissionError producers. API classification paths that already carry an
|
||||
// owned structured annotation keep their rendered Hint unchanged.
|
||||
func (p *rootErrorPresenter) completePermissionRecovery(err error) {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
permissionErr, ok := typed.(*errs.PermissionError) //nolint:errorlint // presentation must not descend into the clone's original Cause
|
||||
if !ok || permissionErr.Hint != "" {
|
||||
return
|
||||
}
|
||||
identity := permissionErr.Identity
|
||||
if identity == "" && p.f != nil {
|
||||
identity = string(p.f.ResolvedIdentity)
|
||||
}
|
||||
if identity == "" {
|
||||
identity = string(core.AsUser)
|
||||
}
|
||||
hint := errclass.PermissionRecovery(
|
||||
permissionErr.MissingScopes,
|
||||
identity,
|
||||
permissionErr.Subtype,
|
||||
permissionErr.ConsoleURL,
|
||||
)
|
||||
permissionErr.Hint = p.projector.RenderHint(hint)
|
||||
}
|
||||
|
||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||
// a need_user_authorization signal AND the current command declares scopes
|
||||
@@ -84,12 +32,8 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
if !internalauth.IsNeedUserAuthorizationError(err) {
|
||||
return
|
||||
}
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
authErr, ok := typed.(*errs.AuthenticationError) //nolint:errorlint // enrich only the presented clone, never a nested producer Cause
|
||||
if !ok {
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
@@ -114,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
|
||||
}
|
||||
|
||||
@@ -186,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 {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootErrorPresenterCompletesDirectPermissionRecoveryWithoutMutatingProducer(t *testing.T) {
|
||||
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
|
||||
WithMissingScopes("docx:document").
|
||||
WithIdentity("user")
|
||||
|
||||
visible := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
||||
recovery.NewProjector(nil),
|
||||
).Present(source)
|
||||
visibleProblem, _ := errs.ProblemOf(visible)
|
||||
if !strings.Contains(visibleProblem.Hint, `auth login --scope "docx:document"`) {
|
||||
t.Fatalf("visible recovery = %q, want scoped auth login", visibleProblem.Hint)
|
||||
}
|
||||
if source.Hint != "" {
|
||||
t.Fatalf("presenter mutated producer hint: %q", source.Hint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandAuthLogin: surface.CommandConcealed,
|
||||
})
|
||||
concealed := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
||||
recovery.NewProjector(func() *surface.Plan { return plan }),
|
||||
).Present(source)
|
||||
concealedProblem, _ := errs.ProblemOf(concealed)
|
||||
if strings.Contains(concealedProblem.Hint, "auth login") ||
|
||||
!strings.Contains(concealedProblem.Hint, "supported authorization flow") {
|
||||
t.Fatalf("concealed recovery = %q, want target-free fallback", concealedProblem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootErrorPresenterDoesNotRecommendUserLoginForBotPermission(t *testing.T) {
|
||||
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
|
||||
WithMissingScopes("drive:file:download").
|
||||
WithIdentity("bot")
|
||||
|
||||
rendered := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsBot},
|
||||
recovery.NewProjector(nil),
|
||||
).Present(source)
|
||||
problem, _ := errs.ProblemOf(rendered)
|
||||
if strings.Contains(problem.Hint, "auth login") ||
|
||||
!strings.Contains(problem.Hint, "app developer") {
|
||||
t.Fatalf("bot recovery = %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootErrorPresenterDoesNotMutateNestedPermissionCause(t *testing.T) {
|
||||
inner := errs.NewPermissionError(errs.SubtypeMissingScope, "inner permission").
|
||||
WithMissingScopes("docx:document").
|
||||
WithIdentity("user")
|
||||
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
|
||||
WithHint("retry the operation").
|
||||
WithCause(inner)
|
||||
|
||||
rendered := newRootErrorPresenter(
|
||||
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
||||
recovery.NewProjector(nil),
|
||||
).Present(outer)
|
||||
|
||||
if inner.Hint != "" {
|
||||
t.Fatalf("presenter mutated nested producer hint: %q", inner.Hint)
|
||||
}
|
||||
problem, _ := errs.ProblemOf(rendered)
|
||||
if got, want := problem.Hint, "retry the operation"; got != want {
|
||||
t.Fatalf("rendered outer hint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootErrorPresenterDoesNotMutateNestedAuthenticationCause(t *testing.T) {
|
||||
f := factoryWithDeclaredServiceScope(t)
|
||||
source := internalauth.NewNeedUserAuthorizationError("ou_nested")
|
||||
var inner *errs.AuthenticationError
|
||||
if !errors.As(source, &inner) {
|
||||
t.Fatalf("source = %T, want nested *errs.AuthenticationError", source)
|
||||
}
|
||||
originalHint := inner.Hint
|
||||
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
|
||||
WithHint("retry the operation").
|
||||
WithCause(source)
|
||||
|
||||
rendered := newRootErrorPresenter(f, recovery.NewProjector(nil)).Present(outer)
|
||||
|
||||
if got := inner.Hint; got != originalHint {
|
||||
t.Fatalf("presenter mutated nested authentication hint: got %q want %q", got, originalHint)
|
||||
}
|
||||
problem, _ := errs.ProblemOf(rendered)
|
||||
if got, want := problem.Hint, "retry the operation"; got != want {
|
||||
t.Fatalf("rendered outer hint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func factoryWithDeclaredServiceScope(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
f := &cmdutil.Factory{ResolvedIdentity: core.AsUser}
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) > 0 {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a service command with declared user scopes")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("service command = %q, want resource and method", target.Command)
|
||||
}
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
domain := &cobra.Command{Use: "calendar"}
|
||||
resource := &cobra.Command{Use: parts[0]}
|
||||
method := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(domain)
|
||||
domain.AddCommand(resource)
|
||||
resource.AddCommand(method)
|
||||
f.CurrentCommand = method
|
||||
return f
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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}}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -278,24 +279,27 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
permissionErr := errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...)
|
||||
if pf.identity.IsBot() {
|
||||
permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing))
|
||||
}
|
||||
return permissionErr
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
// The bot-specific scan-to-enable link adds the scopes to the app manifest,
|
||||
// after which the tenant token carries them. User recovery is generated from
|
||||
// the PermissionError's identity and missing_scopes by the root presenter.
|
||||
func botScopeRemediationHint(brand core.LarkBrand, appID string, missing []string) string {
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(core.AsBot, missing)))
|
||||
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
|
||||
// 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 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)))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "))
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
@@ -365,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
|
||||
@@ -376,7 +380,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
if result == nil || result.Token == "" {
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("check that app_secret is configured for this distribution")
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
@@ -264,9 +265,18 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotScopeRemediationHintUsesScanLink(t *testing.T) {
|
||||
bot := botScopeRemediationHint(core.BrandFeishu, "cli_x", []string{"im:message"})
|
||||
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||
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(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)
|
||||
}
|
||||
if strings.Contains(user, "/page/launcher") {
|
||||
t.Errorf("user hint must NOT use the scan link, got: %s", user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
// globalFlagTargets maps each root persistent flag to the command capability
|
||||
// it belongs to. A new domain-tied global flag must add a row.
|
||||
var globalFlagTargets = map[string]surface.CommandID{
|
||||
"profile": surface.CommandProfile,
|
||||
}
|
||||
|
||||
// flagGateAnnotation distinguishes a surface-retired flag from one hidden
|
||||
// cosmetically (single-app mode force-shows the latter in root help).
|
||||
const flagGateAnnotation = "lark:surface_concealed_flag"
|
||||
|
||||
// applyPluginFlagGate hides and rejects global flags whose exact command
|
||||
// capability is absent from this build. It is called only by the explicit
|
||||
// distribution presentation pass.
|
||||
func applyPluginFlagGate(root *cobra.Command, plan *surface.Plan) {
|
||||
for flagName, target := range globalFlagTargets {
|
||||
if plan.CanReference(target) {
|
||||
continue
|
||||
}
|
||||
fl := root.PersistentFlags().Lookup(flagName)
|
||||
if fl == nil {
|
||||
continue
|
||||
}
|
||||
fl.Hidden = true
|
||||
if fl.Annotations == nil {
|
||||
fl.Annotations = map[string][]string{}
|
||||
}
|
||||
fl.Annotations[flagGateAnnotation] = []string{"true"}
|
||||
fl.Value = &gatedFlagValue{name: flagName, inner: fl.Value}
|
||||
}
|
||||
}
|
||||
|
||||
func isPolicyGatedFlag(fl *pflag.Flag) bool {
|
||||
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
|
||||
}
|
||||
|
||||
// gatedFlagValue rejects at parse time, before cobra's help/version fast
|
||||
// paths (which never reach PersistentPreRunE). Its Set error carries
|
||||
// cobra's own unknown-flag wording so the root FlagErrorFunc classifies it
|
||||
// as an ordinary unknown flag without exposing policy state. Cobra may add
|
||||
// different parse context on root/group paths than on leaf commands.
|
||||
type gatedFlagValue struct {
|
||||
name string
|
||||
inner pflag.Value
|
||||
}
|
||||
|
||||
func (g *gatedFlagValue) String() string { return g.inner.String() }
|
||||
func (g *gatedFlagValue) Type() string { return g.inner.Type() }
|
||||
func (g *gatedFlagValue) Set(string) error {
|
||||
// Intermediate parse error, not a final envelope: pflag wraps it and
|
||||
// the root FlagErrorFunc (flagDidYouMean) converts it to the typed
|
||||
// unknown-flag validation error.
|
||||
return errors.New("unknown flag: --" + g.name) //nolint:forbidigo // intermediate parse error; flagDidYouMean emits the typed envelope
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice(nil)
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(nil); got != nil {
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
|
||||
@@ -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.
|
||||
@@ -35,10 +35,7 @@ const userPolicyFileName = "policy.yml"
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
//
|
||||
// The returned denied map (nil when no rule denied anything) feeds the
|
||||
// optional, build-local distribution presentation pass in build.go.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||
@@ -68,7 +65,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return nil, lerr
|
||||
return lerr
|
||||
}
|
||||
yamlRules = loaded
|
||||
}
|
||||
@@ -80,11 +77,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// RuleName attributes a denial to a specific rule in the envelope.
|
||||
@@ -103,12 +100,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedByPath: denied,
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
})
|
||||
|
||||
return denied, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
@@ -160,22 +156,7 @@ func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
|
||||
for _, ps := range installResult.PluginSkills {
|
||||
if ps.SkillsOverlay == nil {
|
||||
continue
|
||||
}
|
||||
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
|
||||
PluginName: ps.PluginName,
|
||||
View: internalplatform.SkillsOverlayView{
|
||||
Allow: ps.SkillsOverlay.Allow,
|
||||
Remove: ps.SkillsOverlay.Remove,
|
||||
Overlay: ps.SkillsOverlay.Overlay != nil,
|
||||
Base: ps.SkillsOverlay.Base != nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
@@ -186,20 +167,7 @@ func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry)
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
installHooks(rootCmd, reg)
|
||||
return emitStartup(ctx, reg)
|
||||
}
|
||||
|
||||
func installHooks(rootCmd *cobra.Command, reg *hook.Registry) {
|
||||
if reg != nil {
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
}
|
||||
}
|
||||
|
||||
func emitStartup(ctx context.Context, reg *hook.Registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
return hook.Emit(ctx, reg, platform.Startup, nil)
|
||||
}
|
||||
|
||||
@@ -293,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.
|
||||
@@ -303,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
|
||||
|
||||
@@ -116,7 +116,7 @@ max_risk: write
|
||||
`)
|
||||
|
||||
root := fakeTree(t)
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("apply policy: %v", err)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
||||
tmpHome(t) // home set but no policy.yml written
|
||||
|
||||
root := fakeTree(t)
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("missing policy should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||
|
||||
root := fakeTree(t)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("malformed yaml should produce an error")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
root := fakeTree(t)
|
||||
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||
|
||||
root := fakeTree(t)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid MaxRisk should produce an error")
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
)
|
||||
|
||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||
@@ -111,33 +110,6 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
|
||||
// error before any command runs. Two failure modes, split by reason code:
|
||||
//
|
||||
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
|
||||
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
|
||||
//
|
||||
// The CLI must NOT silently fall back to default skills once an
|
||||
// integrator has declared a customization.
|
||||
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() error {
|
||||
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
|
||||
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
|
||||
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
|
||||
}
|
||||
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithCause(err)
|
||||
if errors.Is(err, skillpolicy.ErrNoBaseSkillContent) {
|
||||
return typed.WithHint("this build embeds no base skill content; call cmd.SetEmbeddedSkillContent before Execute or provide a non-empty EmbeddedSkills.Base (reason_code %s)", reasonCode)
|
||||
}
|
||||
if errors.Is(err, skillpolicy.ErrInvalidHostBase) {
|
||||
return typed.WithHint("the wrapper's embedded base skill tree is invalid; fix the content passed to cmd.SetEmbeddedSkillContent (reason_code %s)", reasonCode)
|
||||
}
|
||||
return typed.WithHint("skill customization is broken (reason_code %s); fix the plugin's EmbeddedSkills configuration or remove the conflicting plugin", reasonCode)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a typed validation error (failed_precondition). The hint's
|
||||
// reason code splits returned-error vs panic so consumers (audit /
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
configcmd "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
const annotationUnavailableMessage = "lark:presentation_unavailable_message"
|
||||
|
||||
type projectedCommand struct {
|
||||
state surface.CommandState
|
||||
denial cmdpolicy.Denial
|
||||
}
|
||||
|
||||
// presentationProjection keeps one distribution's build-time presentation
|
||||
// state and denial provenance together, so a concealed command retains the
|
||||
// cause installed on its unavailable projection.
|
||||
type presentationProjection struct {
|
||||
commands map[surface.CommandID]projectedCommand
|
||||
}
|
||||
|
||||
func newPresentationProjection(denied map[string]cmdpolicy.Denial) *presentationProjection {
|
||||
projection := &presentationProjection{
|
||||
commands: make(map[surface.CommandID]projectedCommand, len(denied)),
|
||||
}
|
||||
for path, denial := range denied {
|
||||
projection.commands[surface.CommandID(path)] = projectedCommand{
|
||||
state: surface.CommandDeniedVisible,
|
||||
denial: denial,
|
||||
}
|
||||
}
|
||||
return projection
|
||||
}
|
||||
|
||||
func (p *presentationProjection) recordConcealed(path string, denial cmdpolicy.Denial) {
|
||||
p.commands[surface.CommandID(path)] = projectedCommand{
|
||||
state: surface.CommandConcealed,
|
||||
denial: denial,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *presentationProjection) denial(path string) (cmdpolicy.Denial, bool) {
|
||||
command, ok := p.commands[surface.CommandID(path)]
|
||||
if !ok || command.state != surface.CommandConcealed {
|
||||
return cmdpolicy.Denial{}, false
|
||||
}
|
||||
return command.denial, true
|
||||
}
|
||||
|
||||
func (p *presentationProjection) plan() *surface.Plan {
|
||||
states := make(map[surface.CommandID]surface.CommandState, len(p.commands))
|
||||
for id, command := range p.commands {
|
||||
states[id] = command.state
|
||||
}
|
||||
return surface.NewPlan(states)
|
||||
}
|
||||
|
||||
func (p *presentationProjection) hasConcealedCommands() bool {
|
||||
for _, command := range p.commands {
|
||||
if command.state == surface.CommandConcealed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyDistributionPresentation projects enforcement decisions onto the
|
||||
// command surface of this one build. Enforcement has already installed its
|
||||
// policy-rich deny stubs. Without an explicit presentation option, those stubs
|
||||
// and their legacy help/completion behavior are left untouched.
|
||||
func applyDistributionPresentation(
|
||||
root *cobra.Command,
|
||||
cfg restrictionPresentationConfig,
|
||||
denied map[string]cmdpolicy.Denial,
|
||||
) (*surface.Plan, bool) {
|
||||
projection := newPresentationProjection(denied)
|
||||
if !cfg.enabled {
|
||||
return projection.plan(), false
|
||||
}
|
||||
|
||||
collectPluginConcealments(root, denied, projection)
|
||||
if cfg.hidePolicyDiagnostics {
|
||||
collectDiagnosticConcealments(root, projection)
|
||||
}
|
||||
propagateConcealedPureGroups(root, projection)
|
||||
|
||||
installUnavailableProjections(root, projection, cfg.effectiveUnavailableMessage())
|
||||
|
||||
plan := projection.plan()
|
||||
applyPresentationAffordances(root, plan)
|
||||
return plan, projection.hasConcealedCommands()
|
||||
}
|
||||
|
||||
func collectPluginConcealments(
|
||||
root *cobra.Command,
|
||||
denied map[string]cmdpolicy.Denial,
|
||||
projection *presentationProjection,
|
||||
) {
|
||||
for path, denial := range denied {
|
||||
if !cmdpolicy.IsPluginPolicySource(denial.PolicySource) {
|
||||
continue
|
||||
}
|
||||
cmd := findByPath(root, path)
|
||||
if cmd == nil || commandDenialLayer(cmd) == cmdpolicy.LayerStrictMode {
|
||||
continue
|
||||
}
|
||||
projection.recordConcealed(path, denial)
|
||||
}
|
||||
}
|
||||
|
||||
func collectDiagnosticConcealments(
|
||||
root *cobra.Command,
|
||||
projection *presentationProjection,
|
||||
) {
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
if findByPath(root, path) == nil {
|
||||
continue
|
||||
}
|
||||
projection.recordConcealed(path, cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "distribution:presentation",
|
||||
ReasonCode: "diagnostics_concealed",
|
||||
Reason: "policy diagnostics concealed by the distribution",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func propagateConcealedPureGroups(
|
||||
root *cobra.Command,
|
||||
projection *presentationProjection,
|
||||
) {
|
||||
// A pure parent becomes absent only when every live child is absent. Repeat
|
||||
// bottom-up until all newly-empty intermediate groups converge.
|
||||
for {
|
||||
changed := false
|
||||
plan := projection.plan()
|
||||
walkCommandsPostOrder(root, func(cmd *cobra.Command) {
|
||||
path, denial, ok := concealedPureGroup(cmd, plan, projection)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projection.recordConcealed(path, denial)
|
||||
changed = true
|
||||
})
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func concealedPureGroup(
|
||||
cmd *cobra.Command,
|
||||
plan *surface.Plan,
|
||||
projection *presentationProjection,
|
||||
) (string, cmdpolicy.Denial, bool) {
|
||||
path := cmdpolicy.CanonicalPath(cmd)
|
||||
if !cmd.HasParent() || !isPresentationPureGroup(cmd) ||
|
||||
plan.IsConcealed(surface.CommandID(path)) {
|
||||
return "", cmdpolicy.Denial{}, false
|
||||
}
|
||||
children := cmd.Commands()
|
||||
if len(children) == 0 {
|
||||
return "", cmdpolicy.Denial{}, false
|
||||
}
|
||||
|
||||
var cause cmdpolicy.Denial
|
||||
for _, child := range children {
|
||||
childPath := cmdpolicy.CanonicalPath(child)
|
||||
if !plan.IsConcealed(surface.CommandID(childPath)) {
|
||||
return "", cmdpolicy.Denial{}, false
|
||||
}
|
||||
if denial, ok := projection.denial(childPath); ok && cause.Layer == "" {
|
||||
cause = denial
|
||||
}
|
||||
}
|
||||
if cause.Layer == "" {
|
||||
cause = cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "distribution:presentation",
|
||||
ReasonCode: "all_children_concealed",
|
||||
Reason: "all child commands are concealed",
|
||||
}
|
||||
}
|
||||
return path, cause, true
|
||||
}
|
||||
|
||||
func installUnavailableProjections(
|
||||
root *cobra.Command,
|
||||
projection *presentationProjection,
|
||||
message string,
|
||||
) {
|
||||
for id, command := range projection.commands {
|
||||
if command.state != surface.CommandConcealed {
|
||||
continue
|
||||
}
|
||||
path := string(id)
|
||||
if cmd := findByPath(root, path); cmd != nil {
|
||||
installUnavailableProjection(cmd, path, command.denial, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyPresentationAffordances(root *cobra.Command, plan *surface.Plan) {
|
||||
applyPluginFlagGate(root, plan)
|
||||
configcmd.ProjectInitHelp(
|
||||
findByPath(root, string(surface.CommandConfigInit)),
|
||||
plan.CanReference(surface.CommandConfigBind),
|
||||
)
|
||||
root.Long = renderRootHelpSections(rootLongSections, plan)
|
||||
root.SetUsageTemplate(renderRootUsageTemplate(plan))
|
||||
}
|
||||
|
||||
func commandDenialLayer(cmd *cobra.Command) string {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return ""
|
||||
}
|
||||
return cmd.Annotations[cmdpolicy.AnnotationDenialLayer]
|
||||
}
|
||||
|
||||
func isPresentationPureGroup(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
return (cmd.Run == nil && cmd.RunE == nil) || cmdpolicy.IsPureGroup(cmd)
|
||||
}
|
||||
|
||||
func walkCommandsPostOrder(cmd *cobra.Command, visit func(*cobra.Command)) {
|
||||
for _, child := range cmd.Commands() {
|
||||
walkCommandsPostOrder(child, visit)
|
||||
}
|
||||
visit(cmd)
|
||||
}
|
||||
|
||||
// installUnavailableProjection changes presentation only. It preserves the
|
||||
// enforcement denial as the in-process cause when one exists, while the wire
|
||||
// intentionally exposes no policy source, rule name, or reason code.
|
||||
func installUnavailableProjection(cmd *cobra.Command, path string, denial cmdpolicy.Denial, message string) {
|
||||
cmd.Hidden = true
|
||||
cmd.DisableFlagParsing = true
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
}
|
||||
cmd.PersistentPreRun = nil
|
||||
cmd.PreRunE = nil
|
||||
cmd.PreRun = nil
|
||||
|
||||
hideFlags := func(flags *pflag.FlagSet) {
|
||||
flags.VisitAll(func(flag *pflag.Flag) {
|
||||
flag.Hidden = true
|
||||
})
|
||||
}
|
||||
// Hide only flags owned by this command. cmd.Flags() may contain inherited
|
||||
// flag pointers after Cobra merges sets; mutating those would hide a global
|
||||
// flag from unrelated commands.
|
||||
hideFlags(cmd.LocalNonPersistentFlags())
|
||||
hideFlags(cmd.PersistentFlags())
|
||||
cmd.ValidArgs = nil
|
||||
cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[annotationUnavailableMessage] = message
|
||||
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] == "" {
|
||||
cmd.Annotations[cmdpolicy.AnnotationDenialLayer] = denial.Layer
|
||||
cmd.Annotations[cmdpolicy.AnnotationDenialSource] = denial.PolicySource
|
||||
}
|
||||
|
||||
cmd.RunE = func(*cobra.Command, []string) error {
|
||||
err := errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", message)
|
||||
if denial.Layer != "" {
|
||||
err.WithCause(cmdpolicy.CommandDeniedFromDenial(path, denial))
|
||||
}
|
||||
return err
|
||||
}
|
||||
cmd.Run = nil
|
||||
}
|
||||
|
||||
// unavailableHelpMessage is deliberately keyed only by the opt-in projection
|
||||
// annotation. A legacy Restrict denial carries enforcement annotations but
|
||||
// continues to use Cobra's stock explicit-help behavior.
|
||||
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
|
||||
for current := cmd; current != nil; current = current.Parent() {
|
||||
if current.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if message := current.Annotations[annotationUnavailableMessage]; message != "" {
|
||||
return message, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// findByPath resolves a canonical slash path (for example
|
||||
// "config/policy/show") to a command node.
|
||||
func findByPath(root *cobra.Command, path string) *cobra.Command {
|
||||
cur := root
|
||||
for _, segment := range strings.Split(path, "/") {
|
||||
var next *cobra.Command
|
||||
for _, child := range cur.Commands() {
|
||||
if child.Name() == segment {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
// defaultRestrictedCommandUnavailableMessage is the distribution-neutral
|
||||
// fallback for a concealed command. It lives in the presentation layer rather
|
||||
// than extension/platform.Rule: the same enforcement rule may be rendered as a
|
||||
// visible policy denial by one host and as an absent capability by another.
|
||||
const defaultRestrictedCommandUnavailableMessage = "command not included in this build"
|
||||
|
||||
// restrictionPresentationConfig is a per-Build snapshot. It is deliberately
|
||||
// private so adding a future presentation knob cannot break downstream
|
||||
// unkeyed struct literals.
|
||||
type restrictionPresentationConfig struct {
|
||||
enabled bool
|
||||
unavailableMessage string
|
||||
hidePolicyDiagnostics bool
|
||||
}
|
||||
|
||||
func (c restrictionPresentationConfig) effectiveUnavailableMessage() string {
|
||||
if c.unavailableMessage != "" {
|
||||
return c.unavailableMessage
|
||||
}
|
||||
return defaultRestrictedCommandUnavailableMessage
|
||||
}
|
||||
|
||||
// RestrictionPresentationOption configures the presentation of commands
|
||||
// denied by an embedded distribution's Restrict plugin.
|
||||
//
|
||||
// Values are accepted only by ConcealRestrictedCommands. The pointed-to
|
||||
// configuration type is private by design; callers use the constructors in
|
||||
// this file instead of depending on a public struct layout.
|
||||
type RestrictionPresentationOption func(*restrictionPresentationConfig)
|
||||
|
||||
// ConcealRestrictedCommands opts one command tree into presenting
|
||||
// plugin-restricted commands as capabilities absent from the distribution.
|
||||
//
|
||||
// Restrict remains the enforcement boundary. Without this BuildOption,
|
||||
// existing Restrict plugins keep their established failed_precondition
|
||||
// envelope, explicit-help, and completion behavior.
|
||||
//
|
||||
// Pass the returned option to Build, or to ExecuteWithOptions when using the
|
||||
// standard host entrypoint.
|
||||
func ConcealRestrictedCommands(opts ...RestrictionPresentationOption) BuildOption {
|
||||
presentation := restrictionPresentationConfig{enabled: true}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&presentation)
|
||||
}
|
||||
}
|
||||
return func(cfg *buildConfig) {
|
||||
cfg.presentation = presentation
|
||||
}
|
||||
}
|
||||
|
||||
// UnavailableMessage customizes the error message for a concealed command.
|
||||
// An empty message selects the distribution-neutral default.
|
||||
func UnavailableMessage(message string) RestrictionPresentationOption {
|
||||
return func(cfg *restrictionPresentationConfig) {
|
||||
cfg.unavailableMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
// HidePolicyDiagnostics removes the policy self-inspection commands from a
|
||||
// concealed distribution. Without it, those commands remain the operator's
|
||||
// recovery and inspection escape hatch.
|
||||
func HidePolicyDiagnostics() RestrictionPresentationOption {
|
||||
return func(cfg *restrictionPresentationConfig) {
|
||||
cfg.hidePolicyDiagnostics = true
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
// Preserve callers that store the original entrypoint as a function value.
|
||||
// Making Execute variadic would compile at ordinary call sites but break this
|
||||
// established source contract.
|
||||
var _ func() int = Execute
|
||||
|
||||
func TestConcealRestrictedCommandsDefaults(t *testing.T) {
|
||||
cfg := &buildConfig{}
|
||||
ConcealRestrictedCommands()(cfg)
|
||||
|
||||
if !cfg.presentation.enabled {
|
||||
t.Fatal("concealment must be explicitly enabled by the BuildOption")
|
||||
}
|
||||
if cfg.presentation.hidePolicyDiagnostics {
|
||||
t.Fatal("policy diagnostics must remain available by default")
|
||||
}
|
||||
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
|
||||
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealRestrictedCommandsOptions(t *testing.T) {
|
||||
cfg := &buildConfig{}
|
||||
ConcealRestrictedCommands(
|
||||
UnavailableMessage("not part of acme-cli"),
|
||||
HidePolicyDiagnostics(),
|
||||
)(cfg)
|
||||
|
||||
if !cfg.presentation.enabled {
|
||||
t.Fatal("concealment must be enabled")
|
||||
}
|
||||
if !cfg.presentation.hidePolicyDiagnostics {
|
||||
t.Fatal("HidePolicyDiagnostics option was not applied")
|
||||
}
|
||||
if got := cfg.presentation.effectiveUnavailableMessage(); got != "not part of acme-cli" {
|
||||
t.Errorf("message = %q, want custom message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealRestrictedCommandsIsBuildLocal(t *testing.T) {
|
||||
concealed := &buildConfig{}
|
||||
ordinary := &buildConfig{}
|
||||
|
||||
ConcealRestrictedCommands(
|
||||
UnavailableMessage("acme only"),
|
||||
HidePolicyDiagnostics(),
|
||||
)(concealed)
|
||||
|
||||
if ordinary.presentation.enabled {
|
||||
t.Fatal("applying an option to one build must not enable another")
|
||||
}
|
||||
if ordinary.presentation.hidePolicyDiagnostics {
|
||||
t.Fatal("applying an option to one build must not mutate another")
|
||||
}
|
||||
if got := ordinary.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
|
||||
t.Errorf("ordinary message = %q, want default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableMessageEmptyUsesDefault(t *testing.T) {
|
||||
cfg := &buildConfig{}
|
||||
ConcealRestrictedCommands(UnavailableMessage(""))(cfg)
|
||||
|
||||
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
|
||||
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,757 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
func registerRestriction(t *testing.T, deny []string, configure func(*platform.Builder) *platform.Builder) {
|
||||
t.Helper()
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
builder := platform.NewPlugin("acme", "1.0").
|
||||
Restrict(&platform.Rule{Deny: deny})
|
||||
if configure != nil {
|
||||
builder = configure(builder)
|
||||
}
|
||||
platform.Register(builder.MustBuild())
|
||||
}
|
||||
|
||||
func TestBuildInternalRestrictDefaultPreservesLegacyContract(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, nil)
|
||||
|
||||
runtime, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := findByPath(root, "skills/read")
|
||||
if leaf == nil {
|
||||
t.Fatal("skills/read not found")
|
||||
}
|
||||
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandDeniedVisible {
|
||||
t.Fatalf("surface state = %v, want denied-visible", got)
|
||||
}
|
||||
if _, projected := unavailableHelpMessage(leaf); projected {
|
||||
t.Fatal("legacy Restrict unexpectedly received concealment presentation")
|
||||
}
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validation.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", validation.Subtype)
|
||||
}
|
||||
if !strings.Contains(validation.Hint, "source plugin:acme") ||
|
||||
!strings.Contains(validation.Hint, "reason_code") {
|
||||
t.Errorf("legacy policy metadata missing from hint: %q", validation.Hint)
|
||||
}
|
||||
if flag := leaf.Flags().Lookup("json"); flag == nil || flag.Hidden {
|
||||
t.Errorf("legacy Restrict must preserve local flag presentation; flag=%+v", flag)
|
||||
}
|
||||
|
||||
var help bytes.Buffer
|
||||
root.SetOut(&help)
|
||||
root.SetErr(&help)
|
||||
if err := root.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, title := range []string{"Lark domains:", "Agent tooling:", "CLI management:"} {
|
||||
if !strings.Contains(help.String(), title) {
|
||||
t.Errorf("default root help lost group %q", title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternalConcealmentIsExplicitAndKeepsDenialAsCause(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, nil)
|
||||
|
||||
runtime, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(UnavailableMessage("not shipped by acme")),
|
||||
)
|
||||
leaf := findByPath(root, "skills/read")
|
||||
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandConcealed {
|
||||
t.Fatalf("surface state = %v, want concealed", got)
|
||||
}
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validation.Subtype != errs.SubtypeCommandUnavailable ||
|
||||
validation.Message != "not shipped by acme" || validation.Hint != "" {
|
||||
t.Errorf("concealed error = %+v", validation)
|
||||
}
|
||||
var denied *platform.CommandDeniedError
|
||||
if !errors.As(err, &denied) || denied.Path != "skills/read" ||
|
||||
denied.PolicySource != "plugin:acme" {
|
||||
t.Errorf("enforcement cause not preserved: %T %+v", err, denied)
|
||||
}
|
||||
|
||||
if flag := leaf.Flags().Lookup("json"); flag == nil || !flag.Hidden {
|
||||
t.Errorf("concealed command must hide owned flags; flag=%+v", flag)
|
||||
}
|
||||
if flag := root.PersistentFlags().Lookup("profile"); flag == nil || flag.Hidden {
|
||||
t.Errorf("concealing a leaf must not mutate inherited global flags; flag=%+v", flag)
|
||||
}
|
||||
if args, _ := leaf.ValidArgsFunction(leaf, nil, ""); len(args) != 0 {
|
||||
t.Errorf("concealed command completed positionals: %v", args)
|
||||
}
|
||||
|
||||
help := findByPath(root, "help")
|
||||
if help == nil || help.RunE == nil {
|
||||
t.Fatal("concealment-specific help command not installed")
|
||||
}
|
||||
err = help.RunE(help, []string{"skills", "read"})
|
||||
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("help on concealed command = %v, want command_unavailable", err)
|
||||
}
|
||||
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil || active.DeniedByPath["skills/read"].PolicySource != "plugin:acme" {
|
||||
t.Fatalf("presentation overwrote enforcement snapshot: %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPresentationNeverConcealsYAMLPolicy(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
leaf := &cobra.Command{Use: "probe", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
root.AddCommand(leaf)
|
||||
denial := cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "yaml:/tmp/policy.yml",
|
||||
ReasonCode: "command_denylisted",
|
||||
Reason: "denied by user policy",
|
||||
}
|
||||
denied := map[string]cmdpolicy.Denial{"probe": denial}
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
plan, concealed := applyDistributionPresentation(
|
||||
root,
|
||||
restrictionPresentationConfig{enabled: true},
|
||||
denied,
|
||||
)
|
||||
if concealed {
|
||||
t.Fatal("user-owned YAML denial must not be projected as absent")
|
||||
}
|
||||
if got := plan.State("probe"); got != surface.CommandDeniedVisible {
|
||||
t.Fatalf("surface state = %v, want denied-visible", got)
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("YAML denial changed by distribution presentation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootGroupsFollowSurfaceConcealmentNotLegacyHiddenState(t *testing.T) {
|
||||
newRoot := func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
child := &cobra.Command{
|
||||
Use: "skills",
|
||||
GroupID: groupTooling,
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
root.AddCommand(child)
|
||||
return root
|
||||
}
|
||||
|
||||
yamlRoot := newRoot()
|
||||
yamlChild := findByPath(yamlRoot, "skills")
|
||||
yamlChild.Hidden = true
|
||||
finalizeRootCommandGroups(yamlRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSkills: surface.CommandDeniedVisible,
|
||||
}))
|
||||
if len(yamlRoot.Groups()) != 1 || yamlRoot.Groups()[0].ID != groupTooling {
|
||||
t.Fatalf("legacy/YAML hidden command removed its group: %+v", yamlRoot.Groups())
|
||||
}
|
||||
|
||||
concealedRoot := newRoot()
|
||||
finalizeRootCommandGroups(concealedRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSkills: surface.CommandConcealed,
|
||||
}))
|
||||
if len(concealedRoot.Groups()) != 0 {
|
||||
t.Fatalf("concealed-only group remained visible: %+v", concealedRoot.Groups())
|
||||
}
|
||||
if got := findByPath(concealedRoot, "skills").GroupID; got != "" {
|
||||
t.Fatalf("concealed child retained undefined GroupID %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationDropsRootSkillsFooterWithSkillsRead(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.SetUsageTemplate(rootUsageTemplate)
|
||||
applyPresentationAffordances(root, surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandSkillsRead: surface.CommandConcealed,
|
||||
}))
|
||||
if strings.Contains(root.UsageTemplate(), "Skills setup (one-time, humans)") {
|
||||
t.Fatalf("concealed skills/read left the root skills footer:\n%s", root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationProjectsEveryFrameworkOwnedRootHelpTarget(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
|
||||
root.SetUsageTemplate(rootUsageTemplate)
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
rootHelpAPI: surface.CommandConcealed,
|
||||
surface.CommandSchema: surface.CommandConcealed,
|
||||
rootHelpCalendarAgenda: surface.CommandConcealed,
|
||||
rootHelpMailList: surface.CommandConcealed,
|
||||
})
|
||||
|
||||
applyPresentationAffordances(root, plan)
|
||||
|
||||
for _, dead := range []string{
|
||||
"lark-cli api ",
|
||||
"lark-cli schema ",
|
||||
"lark-cli calendar +agenda",
|
||||
"lark-cli mail user_mailbox.messages list",
|
||||
} {
|
||||
if strings.Contains(root.Long, dead) || strings.Contains(root.UsageTemplate(), dead) {
|
||||
t.Errorf("concealed root-help target %q survived:\nLong:\n%s\nTemplate:\n%s",
|
||||
dead, root.Long, root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(root.Long, "Browse commands:") ||
|
||||
!strings.Contains(root.UsageTemplate(), "lark-cli <command>") {
|
||||
t.Fatalf("target-independent root guidance was removed:\nLong:\n%s\nTemplate:\n%s",
|
||||
root.Long, root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkOwnedRootHelpTargetsExistInDefaultTree(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
var fragments []rootHelpFragment
|
||||
for _, section := range rootLongSections {
|
||||
fragments = append(fragments, section.fragments...)
|
||||
}
|
||||
fragments = append(fragments, rootUsageSynopsis...)
|
||||
for _, fragment := range fragments {
|
||||
if fragment.target == "" {
|
||||
continue
|
||||
}
|
||||
if command := findByPath(root, string(fragment.target)); command == nil {
|
||||
t.Errorf("root-help target %q does not resolve in the default command tree", fragment.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationKeepsDefaultRootHelpByteStable(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
|
||||
root.SetUsageTemplate(rootUsageTemplate)
|
||||
wantLong, wantUsage := root.Long, root.UsageTemplate()
|
||||
|
||||
applyPresentationAffordances(root, nil)
|
||||
|
||||
if root.Long != wantLong {
|
||||
t.Fatalf("default root Long changed:\nwant:\n%s\n\ngot:\n%s", wantLong, root.Long)
|
||||
}
|
||||
if root.UsageTemplate() != wantUsage {
|
||||
t.Fatalf("default root usage template changed:\nwant:\n%s\n\ngot:\n%s", wantUsage, root.UsageTemplate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpRejectsDescendantOfConcealedParent(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
parent := &cobra.Command{Use: "apps"}
|
||||
child := &cobra.Command{Use: "+db-execute", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
parent.AddCommand(child)
|
||||
root.AddCommand(parent)
|
||||
installUnavailableProjection(parent, "apps", cmdpolicy.Denial{}, "not shipped")
|
||||
installHelpCommand(root)
|
||||
|
||||
help := findByPath(root, "help")
|
||||
if help == nil || help.RunE == nil {
|
||||
t.Fatal("help command not installed")
|
||||
}
|
||||
err := help.RunE(help, []string{"apps", "+db-execute"})
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable ||
|
||||
validation.Message != "not shipped" {
|
||||
t.Fatalf("help descendant error = %#v, want command_unavailable inherited from parent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHidePolicyDiagnosticsIsHostPresentationOnly(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"config/**"}, nil)
|
||||
|
||||
runtime, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(HidePolicyDiagnostics()),
|
||||
)
|
||||
for _, path := range []string{
|
||||
"config",
|
||||
"config/policy",
|
||||
"config/policy/show",
|
||||
"config/plugins",
|
||||
"config/plugins/show",
|
||||
} {
|
||||
cmd := findByPath(root, path)
|
||||
if cmd == nil || cmd.RunE == nil {
|
||||
t.Fatalf("%s missing unavailable projection", path)
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("%s error = %v, want command_unavailable", path, err)
|
||||
}
|
||||
if !runtime.surface.IsConcealed(surface.CommandID(path)) {
|
||||
t.Errorf("%s not recorded in build-local surface", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic presentation decisions must not be reported as policy facts.
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil {
|
||||
t.Fatal("missing active enforcement policy")
|
||||
}
|
||||
if _, exists := active.DeniedByPath["config/policy/show"]; exists {
|
||||
t.Errorf("presentation-only diagnostic concealment leaked into ActivePolicy: %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealedBuildOmitsEmptyRootGroup(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{
|
||||
"auth", "auth/**",
|
||||
"config", "config/**",
|
||||
"profile", "profile/**",
|
||||
"doctor",
|
||||
"update",
|
||||
}, nil)
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(HidePolicyDiagnostics()),
|
||||
)
|
||||
var help bytes.Buffer
|
||||
root.SetOut(&help)
|
||||
root.SetErr(&help)
|
||||
if err := root.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(help.String(), "CLI management:") {
|
||||
t.Errorf("empty management group leaked into help:\n%s", help.String())
|
||||
}
|
||||
if !strings.Contains(help.String(), "Agent tooling:") {
|
||||
t.Errorf("non-empty tooling group disappeared:\n%s", help.String())
|
||||
}
|
||||
|
||||
// Cobra's Execute path validates GroupID definitions before parsing flags.
|
||||
// Calling root.Help directly does not exercise this invariant.
|
||||
help.Reset()
|
||||
root.SetOut(&help)
|
||||
root.SetErr(&help)
|
||||
root.SetArgs([]string{"--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("concealed root Execute --help: %v", err)
|
||||
}
|
||||
if strings.Contains(help.String(), "CLI management:") {
|
||||
t.Errorf("empty management group leaked through Execute:\n%s", help.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRenderingUsesExactBuildLocalSurfaceAndDoesNotMutate(t *testing.T) {
|
||||
tmpHome(t)
|
||||
previousWorkspace := core.CurrentWorkspace()
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
|
||||
|
||||
registerRestriction(t, []string{"config/init"}, nil)
|
||||
concealedRuntime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
|
||||
platform.ResetForTesting()
|
||||
defaultRuntime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
|
||||
if concealedRuntime.surface.CanReference(surface.CommandConfigInit) {
|
||||
t.Fatal("config/init should be concealed")
|
||||
}
|
||||
if !concealedRuntime.surface.CanReference(surface.CommandConfigStrictMode) {
|
||||
t.Fatal("exact leaf concealment incorrectly removed config/strict-mode")
|
||||
}
|
||||
|
||||
original := core.NotConfiguredError()
|
||||
originalProblem, ok := errs.ProblemOf(original)
|
||||
if !ok || originalProblem.Hint == "" {
|
||||
t.Fatalf("invalid test error: %v", original)
|
||||
}
|
||||
wantHint := originalProblem.Hint
|
||||
|
||||
concealed := concealedRuntime.recovery.Render(original)
|
||||
concealedProblem, _ := errs.ProblemOf(concealed)
|
||||
if strings.Contains(concealedProblem.Hint, "config init") ||
|
||||
!strings.Contains(concealedProblem.Hint, "configure this distribution") {
|
||||
t.Errorf("concealed tree did not use target-free recovery fallback: %q", concealedProblem.Hint)
|
||||
}
|
||||
if originalProblem.Hint != wantHint {
|
||||
t.Fatalf("rendering mutated source hint: %q -> %q", wantHint, originalProblem.Hint)
|
||||
}
|
||||
|
||||
visible := defaultRuntime.recovery.Render(original)
|
||||
visibleProblem, _ := errs.ProblemOf(visible)
|
||||
if visibleProblem.Hint != wantHint {
|
||||
t.Errorf("default tree lost recovery after second Build: %q", visibleProblem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentBuildsKeepIndependentSurfacePlans(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"config/init"}, nil)
|
||||
inv := buildInvocationForTest(t)
|
||||
|
||||
const pairs = 4
|
||||
type result struct {
|
||||
concealed bool
|
||||
state surface.CommandState
|
||||
}
|
||||
results := make(chan result, pairs*2)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < pairs; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
runtime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
inv,
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
results <- result{concealed: true, state: runtime.surface.State(surface.CommandConfigInit)}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
runtime, _, _ := buildInternal(
|
||||
context.Background(),
|
||||
inv,
|
||||
WithoutPlugins(),
|
||||
)
|
||||
results <- result{state: runtime.surface.State(surface.CommandConfigInit)}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
for got := range results {
|
||||
want := surface.CommandAvailable
|
||||
if got.concealed {
|
||||
want = surface.CommandConcealed
|
||||
}
|
||||
if got.state != want {
|
||||
t.Errorf("concealed=%v state=%v, want %v", got.concealed, got.state, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAffordancesDisappearWithoutDroppingIndependentRecovery(t *testing.T) {
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"})
|
||||
skillscheck.SetPending(&skillscheck.StaleNotice{Current: "1.0.0", Target: "2.0.0"})
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
update.SetPending(nil)
|
||||
skillscheck.SetPending(nil)
|
||||
deprecation.SetPending(nil)
|
||||
})
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
})
|
||||
got := composePendingNotice(plan)
|
||||
if got == nil {
|
||||
t.Fatal("independent deprecation recovery was dropped")
|
||||
}
|
||||
if _, exists := got["update"]; exists {
|
||||
t.Errorf("update notice survived concealed update: %+v", got)
|
||||
}
|
||||
if _, exists := got["skills"]; exists {
|
||||
t.Errorf("skills drift notice survived concealed update: %+v", got)
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command: %+v", got)
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" || entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("independent deprecation fields lost: %+v", entry)
|
||||
}
|
||||
if _, exists := entry["action"]; exists {
|
||||
t.Errorf("unavailable update action survived: %+v", entry)
|
||||
}
|
||||
if strings.Contains(entry["message"].(string), "lark-cli update") {
|
||||
t.Errorf("dead update pointer survived in message: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupNoticesDoesNoProviderWorkWhenUpdateIsConcealed(t *testing.T) {
|
||||
oldCheck, oldRefresh, oldSkills := checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck
|
||||
oldPending := output.PendingNotice
|
||||
t.Cleanup(func() {
|
||||
checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck = oldCheck, oldRefresh, oldSkills
|
||||
output.PendingNotice = oldPending
|
||||
})
|
||||
|
||||
var checks, refreshes, skillChecks int
|
||||
checkCachedUpdate = func(string) *update.UpdateInfo {
|
||||
checks++
|
||||
return nil
|
||||
}
|
||||
refreshUpdateCache = func(string) { refreshes++ }
|
||||
initializeSkillsCheck = func(string) { skillChecks++ }
|
||||
|
||||
setupNotices(surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
}))
|
||||
if checks != 0 || refreshes != 0 || skillChecks != 0 {
|
||||
t.Fatalf("concealed update performed provider work: cache=%d refresh=%d skills=%d",
|
||||
checks, refreshes, skillChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteProfileBootstrapPreservesDefaultAndDefersOnlyForOptIn(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
|
||||
t.Run("default remains plain exit one", func(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
code, stdout, stderr := executeWithCapturedOS(t, nil, "--profile")
|
||||
if code != 1 || stdout != "" ||
|
||||
stderr != "Error: flag needs an argument: --profile\n" {
|
||||
t.Fatalf("default --profile: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("opt-in concealed profile is an unknown flag", func(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"profile", "profile/**"}, nil)
|
||||
|
||||
code, _, stderr := executeWithCapturedOS(
|
||||
t,
|
||||
[]BuildOption{ConcealRestrictedCommands()},
|
||||
"--profile",
|
||||
)
|
||||
if code != 2 ||
|
||||
!strings.Contains(stderr, `"subtype": "invalid_argument"`) ||
|
||||
!strings.Contains(stderr, `unknown flag \"--profile\"`) {
|
||||
t.Fatalf("concealed --profile: exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteWithOptionsAppliesEachBuildOptionOnce(t *testing.T) {
|
||||
tmpHome(t)
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
var applied int
|
||||
option := BuildOption(func(*buildConfig) { applied++ })
|
||||
code, _, stderr := executeWithCapturedOS(t, []BuildOption{option}, "--version")
|
||||
if code != 0 {
|
||||
t.Fatalf("--version exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("BuildOption applied %d times, want exactly once", applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealmentHelpIsOutsideBusinessHooks(t *testing.T) {
|
||||
tmpHome(t)
|
||||
var observed, wrapped int
|
||||
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
|
||||
return builder.
|
||||
Observer(platform.Before, "observe", platform.All(), func(context.Context, platform.Invocation) {
|
||||
observed++
|
||||
}).
|
||||
Wrap("wrap", platform.All(), func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
wrapped++
|
||||
return next(ctx, inv)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
help := findByPath(root, "help")
|
||||
err := help.RunE(help, []string{"skills", "read"})
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Fatalf("help error = %v", err)
|
||||
}
|
||||
if observed != 0 || wrapped != 0 {
|
||||
t.Fatalf("help entered business hooks: observed=%d wrapped=%d", observed, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapperCannotSwallowConcealedCommandEnforcement(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
|
||||
return builder.Wrap("swallow", platform.All(), func(platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error { return nil }
|
||||
})
|
||||
})
|
||||
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
leaf := findByPath(root, "skills/read")
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) ||
|
||||
validation.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Fatalf("wrapper swallowed denial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcealedCommandLeavesFlagAndPositionalCompletion(t *testing.T) {
|
||||
tmpHome(t)
|
||||
registerRestriction(t, []string{"skills/read"}, nil)
|
||||
_, root, _ := buildInternal(
|
||||
context.Background(),
|
||||
buildInvocationForTest(t),
|
||||
ConcealRestrictedCommands(),
|
||||
)
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"__complete", "skills", "read", "--"},
|
||||
{"__complete", "skills", "read", ""},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
_ = root.Execute()
|
||||
if strings.Contains(out.String(), "--json") || strings.Contains(out.String(), "lark-") {
|
||||
t.Errorf("%v exposed concealed completion:\n%s", args, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStrictStubWinsOverPluginDenial(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login strict stub missing")
|
||||
}
|
||||
|
||||
cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{
|
||||
"auth/login": {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: "plugin:acme",
|
||||
},
|
||||
})
|
||||
if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode {
|
||||
t.Fatalf("denial layer = %q, want strict_mode", got)
|
||||
}
|
||||
err := stub.RunE(stub, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("double-restricted command lost strict-mode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeWithCapturedOS(
|
||||
t *testing.T,
|
||||
opts []BuildOption,
|
||||
args ...string,
|
||||
) (int, string, string) {
|
||||
t.Helper()
|
||||
oldArgs, oldStdout, oldStderr := os.Args, os.Stdout, os.Stderr
|
||||
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := false
|
||||
restore := func() {
|
||||
if restored {
|
||||
return
|
||||
}
|
||||
restored = true
|
||||
os.Args, os.Stdout, os.Stderr = oldArgs, oldStdout, oldStderr
|
||||
}
|
||||
defer restore()
|
||||
|
||||
os.Args = append([]string{"e2e-cli"}, args...)
|
||||
os.Stdout, os.Stderr = stdout, stderr
|
||||
code := ExecuteWithOptions(opts...)
|
||||
restore()
|
||||
|
||||
if err := stdout.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stderr.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdoutData, err := os.ReadFile(stdout.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderrData, err := os.ReadFile(stderr.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return code, string(stdoutData), string(stderrData)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -11,13 +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/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -77,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)
|
||||
}
|
||||
@@ -94,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)
|
||||
}
|
||||
@@ -117,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)
|
||||
}
|
||||
|
||||
@@ -134,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)
|
||||
}
|
||||
@@ -151,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)
|
||||
}
|
||||
|
||||
@@ -168,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,60 +183,19 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_AddRecoveryUsesBuildLocalSurface(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "only",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "only",
|
||||
AppId: "app-only",
|
||||
AppSecret: core.PlainSecret("secret-only"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
source := profileRemoveRun(nil, "only")
|
||||
var original *errs.ValidationError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("profileRemoveRun() error = %T, want *errs.ValidationError", source)
|
||||
}
|
||||
const visibleHint = "add another profile first: lark-cli profile add"
|
||||
if original.Hint != visibleHint {
|
||||
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandProfileAdd: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ValidationError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
|
||||
}
|
||||
const fallback = "configure another profile through this distribution before removing the only profile"
|
||||
if concealed.Hint != fallback {
|
||||
t.Errorf("concealed hint = %q, want %q", concealed.Hint, fallback)
|
||||
}
|
||||
if original.Hint != visibleHint {
|
||||
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -245,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)
|
||||
}
|
||||
@@ -262,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)
|
||||
}
|
||||
|
||||
@@ -281,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)
|
||||
}
|
||||
@@ -298,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)
|
||||
}
|
||||
|
||||
@@ -315,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)
|
||||
}
|
||||
@@ -329,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)
|
||||
}
|
||||
|
||||
@@ -382,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)
|
||||
}
|
||||
|
||||
@@ -407,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)
|
||||
}
|
||||
|
||||
@@ -434,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)
|
||||
}
|
||||
|
||||
@@ -504,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)
|
||||
}
|
||||
}
|
||||
@@ -652,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)
|
||||
|
||||
@@ -12,9 +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/recovery"
|
||||
"github.com/larksuite/cli/internal/secret"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
@@ -35,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
|
||||
}
|
||||
@@ -46,12 +46,8 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return recovery.Attach(
|
||||
errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile"),
|
||||
recovery.Join("",
|
||||
recovery.Command(recovery.TargetProfileAdd, "add another profile first: lark-cli profile add"),
|
||||
).WithFallback("configure another profile through this distribution before removing the only profile"),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
|
||||
WithHint("add another profile first: lark-cli profile add")
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
@@ -71,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
22
cmd/prune.go
22
cmd/prune.go
@@ -12,12 +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/recovery"
|
||||
"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)
|
||||
}
|
||||
@@ -26,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
|
||||
@@ -45,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
|
||||
@@ -106,16 +105,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
hint := recovery.Join("; ",
|
||||
recovery.Text(fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)),
|
||||
recovery.Command(recovery.TargetConfigStrictMode, stubHint),
|
||||
)
|
||||
return recovery.Annotate(
|
||||
errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("%s", hint.String()).
|
||||
WithCause(cd),
|
||||
hint,
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
||||
WithCause(cd)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,8 @@ 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/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -77,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")
|
||||
@@ -101,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")
|
||||
@@ -119,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")
|
||||
@@ -146,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()
|
||||
@@ -162,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()
|
||||
@@ -178,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()
|
||||
@@ -194,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()
|
||||
@@ -217,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")
|
||||
@@ -237,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")
|
||||
@@ -258,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")
|
||||
@@ -320,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")
|
||||
@@ -358,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 {
|
||||
@@ -381,48 +379,3 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
t.Errorf("denial annotation overwritten or missing")
|
||||
}
|
||||
}
|
||||
|
||||
// The strict-mode stub carries a targeted config/strict-mode action alongside
|
||||
// non-command policy context. Rendering for a concealed tree drops only the
|
||||
// dead pointer and does not mutate the source error.
|
||||
func TestStrictModeStub_ConfigHintUsesBuildLocalSurface(t *testing.T) {
|
||||
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
stub := strictModeStubFrom(child, core.StrictModeBot)
|
||||
source := stub.RunE(stub, nil)
|
||||
var original *errs.ValidationError
|
||||
if !errors.As(source, &original) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", source, source)
|
||||
}
|
||||
if original.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %q, want failed_precondition", original.Subtype)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "config strict-mode") {
|
||||
t.Fatalf("producer hint = %q, want config strict-mode", original.Hint)
|
||||
}
|
||||
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandConfigStrictMode: surface.CommandConcealed,
|
||||
})
|
||||
var concealed *errs.ValidationError
|
||||
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
|
||||
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
|
||||
}
|
||||
if concealed == original {
|
||||
t.Fatal("Render must clone the typed error")
|
||||
}
|
||||
if strings.Contains(concealed.Hint, "config strict-mode") {
|
||||
t.Errorf("concealed hint still contains config strict-mode: %q", concealed.Hint)
|
||||
}
|
||||
if !strings.Contains(concealed.Hint, "reason_code identity_not_supported") {
|
||||
t.Errorf("non-command policy guidance was lost: %q", concealed.Hint)
|
||||
}
|
||||
|
||||
var visible *errs.ValidationError
|
||||
if !errors.As(recovery.Render(source, nil), &visible) ||
|
||||
!strings.Contains(visible.Hint, "config strict-mode") {
|
||||
t.Errorf("visible render must keep config strict-mode, got %+v", visible)
|
||||
}
|
||||
if !strings.Contains(original.Hint, "config strict-mode") {
|
||||
t.Errorf("concealed render mutated source hint: %q", original.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
388
cmd/root.go
388
cmd/root.go
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -22,16 +21,70 @@ import (
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/skillref"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
||||
|
||||
EXAMPLES (one per command style, in order of preference):
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
||||
|
||||
// rootUsageTemplate is cobra's default usage template with two root-only
|
||||
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
||||
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
||||
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
||||
// cobra so the command groups and flags are untouched.
|
||||
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
@@ -41,69 +94,25 @@ import (
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
return executeWithOptions(nil)
|
||||
}
|
||||
|
||||
// ExecuteWithOptions is the standard entrypoint for wrapper distributions that
|
||||
// need host-level Build options such as ConcealRestrictedCommands. Execute
|
||||
// intentionally keeps its original non-variadic signature for source
|
||||
// compatibility with callers that store it as a func() int value.
|
||||
func ExecuteWithOptions(opts ...BuildOption) int {
|
||||
return executeWithOptions(opts)
|
||||
}
|
||||
|
||||
func executeWithOptions(opts []BuildOption) int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, bootstrapErr := BootstrapInvocationContext(os.Args[1:])
|
||||
cfg := &buildConfig{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(cfg)
|
||||
}
|
||||
}
|
||||
deferProfileError := cfg.presentation.enabled &&
|
||||
isDeferredBootstrapProfileError(bootstrapErr)
|
||||
if bootstrapErr != nil && !deferProfileError {
|
||||
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
if cfg.streams == nil {
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr)(cfg)
|
||||
}
|
||||
if !cfg.hideProfileSet {
|
||||
HideProfile(isSingleAppMode())(cfg)
|
||||
}
|
||||
if !cfg.startupBrandSet {
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile))(cfg)
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
ctx := context.Background()
|
||||
if deferProfileError {
|
||||
cfg.deferStartup = true
|
||||
}
|
||||
runtime, rootCmd, reg := buildInternalWithConfig(ctx, inv, cfg)
|
||||
f := runtime.Factory
|
||||
|
||||
if deferProfileError {
|
||||
if runtime.surface.CanReference(surface.CommandProfile) {
|
||||
// The completed distribution still ships --profile. Replay the
|
||||
// exact pre-Build legacy failure and do not emit Startup, notices,
|
||||
// or Shutdown for an invocation that never passed bootstrap.
|
||||
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
|
||||
return 1
|
||||
}
|
||||
if reg != nil {
|
||||
if err := emitStartup(ctx, reg); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
reg = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupNotices(runtime.surface)
|
||||
setupNotices()
|
||||
}
|
||||
|
||||
runErr := rootCmd.Execute()
|
||||
@@ -117,98 +126,69 @@ func executeWithOptions(opts []BuildOption) int {
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return handleRootError(f, runErr, runtime.recovery)
|
||||
return handleRootError(f, runErr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// isDeferredBootstrapProfileError identifies the one bootstrap parse failure
|
||||
// an explicitly concealed distribution may need the completed tree to render.
|
||||
// Default and legacy builds never defer it.
|
||||
func isDeferredBootstrapProfileError(err error) bool {
|
||||
return err != nil && err.Error() == "flag needs an argument: --profile"
|
||||
}
|
||||
|
||||
// Notice provider seams keep the "concealed update means no cache, network, or
|
||||
// skills-state access" contract directly testable. Production always uses the
|
||||
// concrete implementations below.
|
||||
var (
|
||||
checkCachedUpdate = update.CheckCached
|
||||
refreshUpdateCache = update.RefreshCache
|
||||
initializeSkillsCheck = skillscheck.Init
|
||||
)
|
||||
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
// or both may be present in any given envelope.
|
||||
func setupNotices(plan *surface.Plan) {
|
||||
if plan.CanReference(surface.CommandUpdate) {
|
||||
// Binary update — synchronous cache check + async refresh.
|
||||
if info := checkCachedUpdate(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
ver := build.Version
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
refreshUpdateCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := checkCachedUpdate(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
func setupNotices() {
|
||||
// Binary update — synchronous cache check + async refresh
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
ver := build.Version
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Skills drift has only one recovery action: lark-cli update. Do not
|
||||
// even inspect local drift state when that action is absent.
|
||||
initializeSkillsCheck(build.Version)
|
||||
}
|
||||
// Skills check — synchronous, local-only (no network, no goroutine).
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Capture this build's immutable plan; never consult another Build's state.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
return composePendingNotice(plan)
|
||||
}
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice(plan *surface.Plan) map[string]interface{} {
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
canUpdate := plan.CanReference(surface.CommandUpdate)
|
||||
// Update and skills-drift notices have no recovery path of their own:
|
||||
// both exist solely to steer the caller to `lark-cli update`.
|
||||
if canUpdate {
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.MessageWithoutUpdateAction(),
|
||||
}
|
||||
if canUpdate {
|
||||
entry["message"] = dep.Message()
|
||||
entry["action"] = "lark-cli update"
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
@@ -257,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.
|
||||
@@ -265,22 +245,15 @@ func configureFlagCompletions(args []string) {
|
||||
// argument validation): typed as an invalid_argument envelope (exit 2),
|
||||
// matching the explicit flag/subcommand guards. Flag parse errors are
|
||||
// already typed upstream by the root FlagErrorFunc.
|
||||
func handleRootError(
|
||||
f *cmdutil.Factory,
|
||||
err error,
|
||||
projector *recovery.Projector,
|
||||
) int {
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
renderedErr := err
|
||||
|
||||
// When the typed error is a need_user_authorization signal, fold in the
|
||||
// current command's declared scopes as a Hint so the user/AI sees the
|
||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
||||
// local shortcut/service metadata. Both semantic recovery filtering and
|
||||
// dynamic enrichment operate on a concrete clone, never the producer's
|
||||
// reusable error value.
|
||||
// local shortcut/service metadata — it never depends on server state.
|
||||
if !errs.IsRaw(err) {
|
||||
renderedErr = newRootErrorPresenter(f, projector).Present(err)
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
}
|
||||
|
||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
||||
@@ -291,7 +264,7 @@ func handleRootError(
|
||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
||||
// Problem; in that case we fall through to the signal / plain-text paths.
|
||||
typedExit := output.ExitCodeOf(err)
|
||||
if output.WriteTypedErrorEnvelope(errOut, renderedErr, string(f.ResolvedIdentity)) {
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
@@ -584,10 +557,15 @@ const (
|
||||
groupManagement = "cli-management"
|
||||
)
|
||||
|
||||
// classifyRootCommands assigns root children to help groups after registration.
|
||||
// Group definitions are attached separately, after optional distribution
|
||||
// projection, so a concealed build can omit a now-empty heading.
|
||||
func classifyRootCommands(root *cobra.Command) {
|
||||
// groupRootCommands classifies root's direct children into the help groups,
|
||||
// called once after all commands are registered. Unclassified commands fall to
|
||||
// cobra's "Additional Commands" section.
|
||||
func groupRootCommands(root *cobra.Command) {
|
||||
root.AddGroup(
|
||||
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
@@ -605,46 +583,6 @@ func classifyRootCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeRootCommandGroups attaches Cobra group definitions once. A group is
|
||||
// omitted only when this build's surface plan concealed all its children.
|
||||
// Hidden legacy/YAML commands remain referenceable and therefore keep the
|
||||
// historical (possibly empty) heading.
|
||||
func finalizeRootCommandGroups(root *cobra.Command, plan *surface.Plan) {
|
||||
if root == nil || len(root.Groups()) != 0 {
|
||||
return
|
||||
}
|
||||
groups := []*cobra.Group{
|
||||
{ID: groupDomains, Title: "Lark domains:"},
|
||||
{ID: groupTooling, Title: "Agent tooling:"},
|
||||
{ID: groupManagement, Title: "CLI management:"},
|
||||
}
|
||||
for _, group := range groups {
|
||||
if plan != nil && !rootGroupHasReferenceableChild(root, group.ID, plan) {
|
||||
// Cobra validates that every non-empty child GroupID has a
|
||||
// matching definition before dispatch, including hidden children.
|
||||
// If presentation removes an entire group, clear those now-hidden
|
||||
// assignments as well as omitting the heading.
|
||||
for _, child := range root.Commands() {
|
||||
if child.GroupID == group.ID {
|
||||
child.GroupID = ""
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
root.AddGroup(group)
|
||||
}
|
||||
}
|
||||
|
||||
func rootGroupHasReferenceableChild(root *cobra.Command, groupID string, plan *surface.Plan) bool {
|
||||
for _, child := range root.Commands() {
|
||||
if child.GroupID == groupID &&
|
||||
plan.CanReference(surface.CommandID(cmdpolicy.CanonicalPath(child))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
||||
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
||||
func isLarkDomain(c *cobra.Command) bool {
|
||||
@@ -663,15 +601,6 @@ func isLarkDomain(c *cobra.Command) bool {
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
// A policy-gated flag invoked bare ("flag needs an argument")
|
||||
// never reaches its rejecting Value; it still presents as
|
||||
// unregistered, exactly like a set one.
|
||||
if gated, ok := gatedFlagFromNeedsArg(c, ferr); ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+gated, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + gated, Reason: "unknown flag"}).
|
||||
WithHint("run `%s --help` to see valid flags", c.CommandPath())
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
@@ -694,25 +623,6 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// gatedFlagFromNeedsArg reports whether ferr is pflag's "flag needs an
|
||||
// argument: --name" for a policy-gated flag on this command's flag set.
|
||||
func gatedFlagFromNeedsArg(c *cobra.Command, ferr error) (string, bool) {
|
||||
const p = "flag needs an argument: --"
|
||||
msg := ferr.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
name := msg[i+len(p):]
|
||||
if j := strings.IndexAny(name, " \t"); j >= 0 {
|
||||
name = name[:j]
|
||||
}
|
||||
if fl := c.Root().PersistentFlags().Lookup(name); isPolicyGatedFlag(fl) {
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
@@ -749,59 +659,16 @@ func visibleFlagNames(c *cobra.Command) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// installHelpCommand upgrades Cobra's default help command so that
|
||||
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
|
||||
// instead of printing an envelope and exiting 0 — cobra's stock help
|
||||
// command has no error channel.
|
||||
func installHelpCommand(root *cobra.Command) {
|
||||
root.InitDefaultHelpCmd()
|
||||
helpCmd := findByPath(root, "help")
|
||||
if helpCmd == nil {
|
||||
return
|
||||
}
|
||||
helpCmd.Run = nil
|
||||
helpCmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
target, _, err := root.Find(args)
|
||||
if err != nil || target == nil {
|
||||
c.Printf("Unknown help topic %#q\n", args)
|
||||
return root.Usage()
|
||||
}
|
||||
if msg, ok := unavailableHelpMessage(target); ok {
|
||||
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
|
||||
}
|
||||
target.SetContext(c.Context())
|
||||
target.InitDefaultHelpFlag()
|
||||
target.InitDefaultVersionFlag()
|
||||
return target.Help()
|
||||
}
|
||||
// help attaches after policy evaluation (framework meta command, never
|
||||
// policy-evaluated). No risk annotation: it would render a "Risk:"
|
||||
// line that stock cobra help output does not carry.
|
||||
cmdutil.DisableAuthCheck(helpCmd)
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
// when rendering the root command's own help, so users discovering the CLI
|
||||
// still see them at `lark-cli --help`.
|
||||
//
|
||||
// skillContent is read lazily at help-render time (not captured up front) so
|
||||
// the domain-guide pointer reflects the resolved skill tree -- the same
|
||||
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
|
||||
// customization is applied after this help func is installed.
|
||||
func installTipsHelpFunc(
|
||||
root *cobra.Command,
|
||||
skillContent func() fs.FS,
|
||||
skillReferences func() *skillref.Resolver,
|
||||
projector *recovery.Projector,
|
||||
) {
|
||||
func installTipsHelpFunc(root *cobra.Command) {
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd == root {
|
||||
// Force-show flags hidden by single-app mode; never a
|
||||
// policy-retired one.
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
||||
f.Hidden = false
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
@@ -809,22 +676,15 @@ func installTipsHelpFunc(
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
var refs *skillref.Resolver
|
||||
if skillReferences != nil {
|
||||
refs = skillReferences()
|
||||
}
|
||||
content := skillContent()
|
||||
if service.PrepareDomainHelpWithReferences(cmd, content, refs) {
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelpWithProjection(cmd, content, refs, func() bool {
|
||||
return projector.CanReference(recovery.TargetSchema)
|
||||
}) {
|
||||
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelpWithReferences(cmd, content, refs) {
|
||||
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
154
cmd/root_help.go
154
cmd/root_help.go
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
)
|
||||
|
||||
// rootHelpFragment is one framework-owned root-help fragment. A fragment with
|
||||
// a target is emitted only while that exact command remains referenceable in
|
||||
// this build. Keeping the target next to the text prevents curated examples
|
||||
// from becoming dead pointers in reduced distributions.
|
||||
type rootHelpFragment struct {
|
||||
target surface.CommandID
|
||||
text string
|
||||
}
|
||||
|
||||
// rootHelpSection keeps a heading coupled to the target-aware entries it
|
||||
// introduces. When projection removes every entry, the heading disappears
|
||||
// with them instead of leaving an empty section in reduced builds.
|
||||
type rootHelpSection struct {
|
||||
heading string
|
||||
fragments []rootHelpFragment
|
||||
}
|
||||
|
||||
const (
|
||||
rootHelpAPI surface.CommandID = "api"
|
||||
rootHelpCalendarAgenda surface.CommandID = "calendar/+agenda"
|
||||
rootHelpMailList surface.CommandID = "mail/user_mailbox.messages/list"
|
||||
)
|
||||
|
||||
var rootLongSections = []rootHelpSection{
|
||||
{fragments: []rootHelpFragment{
|
||||
{text: `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources`},
|
||||
{target: surface.CommandSchema, text: `
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples`},
|
||||
{text: `
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).`},
|
||||
}},
|
||||
{
|
||||
heading: "\n\nEXAMPLES (one per command style, in order of preference):",
|
||||
fragments: []rootHelpFragment{
|
||||
{target: rootHelpCalendarAgenda, text: `
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these`},
|
||||
{target: rootHelpMailList, text: `
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method`},
|
||||
{target: surface.CommandSchema, text: `
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling`},
|
||||
{target: rootHelpAPI, text: `
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// rootLong is the fully-visible default text retained as a compatibility
|
||||
// oracle. Reduced builds derive their text from the same typed fragments.
|
||||
var rootLong = renderRootHelpSections(rootLongSections, nil)
|
||||
|
||||
func renderRootHelpSections(sections []rootHelpSection, plan *surface.Plan) string {
|
||||
var b strings.Builder
|
||||
for _, section := range sections {
|
||||
body := renderRootHelpFragments(section.fragments, plan)
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(section.heading)
|
||||
b.WriteString(body)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderRootHelpFragments(fragments []rootHelpFragment, plan *surface.Plan) string {
|
||||
var b strings.Builder
|
||||
for _, fragment := range fragments {
|
||||
if fragment.target != "" && !plan.CanReference(fragment.target) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fragment.text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var rootUsageSynopsis = []rootHelpFragment{
|
||||
{text: `Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]`},
|
||||
{target: rootHelpAPI, text: `
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]`},
|
||||
{target: surface.CommandSchema, text: `
|
||||
lark-cli schema <service.resource.method>`},
|
||||
}
|
||||
|
||||
const rootUsageTemplatePrefix = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}`
|
||||
|
||||
// rootUsageTemplateSuffix is Cobra's default usage template after the root
|
||||
// synopsis. Root-only framework affordances are assembled separately above
|
||||
// and below it so each command reference carries an explicit target.
|
||||
const rootUsageTemplateSuffix = `{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}`
|
||||
|
||||
// skillsSetupFooter is the root-help pointer at the human one-time skills
|
||||
// setup. It is emitted only while skills/read remains referenceable.
|
||||
const skillsSetupFooter = `{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}`
|
||||
|
||||
var rootUsageTemplate = renderRootUsageTemplate(nil)
|
||||
|
||||
func renderRootUsageTemplate(plan *surface.Plan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(rootUsageTemplatePrefix)
|
||||
b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan))
|
||||
b.WriteString(rootUsageTemplateSuffix)
|
||||
if plan.CanReference(surface.CommandSkillsRead) {
|
||||
b.WriteString(skillsSetupFooter)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
return b.String()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -59,7 +62,7 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err, nil)
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -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",
|
||||
@@ -505,7 +508,7 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices(nil)
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
@@ -539,7 +542,7 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices(nil)
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice != nil {
|
||||
@@ -572,7 +575,7 @@ func TestSetupNotices_Drift(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices(nil)
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
@@ -621,7 +624,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices(nil)
|
||||
setupNotices()
|
||||
|
||||
// After setupNotices, skills pending is set (drift). Manually populate
|
||||
// the update side so the composed envelope has both keys — the update
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -13,10 +12,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// nilSkills is the skill-content getter used by help-func tests that do
|
||||
// not exercise the domain-guide pointer.
|
||||
func nilSkills() fs.FS { return nil }
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
@@ -29,7 +24,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
@@ -43,7 +38,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
@@ -56,7 +51,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -162,7 +164,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
||||
ChallengeURL: "https://example.com/challenge",
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr, nil)
|
||||
gotExit := handleRootError(f, spErr)
|
||||
if gotExit != int(output.ExitContentSafety) {
|
||||
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
|
||||
}
|
||||
@@ -209,7 +211,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr, nil)
|
||||
gotExit := handleRootError(f, spErr)
|
||||
if gotExit != int(output.ExitContentSafety) {
|
||||
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
|
||||
}
|
||||
@@ -286,7 +288,7 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
|
||||
// errs.* error, so it reaches the deprecation fallback.
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -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())
|
||||
|
||||
@@ -314,7 +316,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"), nil)
|
||||
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -345,7 +347,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, core.NotConfiguredError(), nil)
|
||||
exit := handleRootError(f, configpkg.NotConfiguredError())
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -393,7 +395,7 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -424,7 +426,7 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF), nil)
|
||||
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
|
||||
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if got := errObj["type"]; got != "internal" {
|
||||
@@ -449,7 +451,7 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
|
||||
f.IOStreams.ErrOut = w
|
||||
|
||||
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
||||
exit := handleRootError(f, err, nil)
|
||||
exit := handleRootError(f, err)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -466,7 +468,7 @@ func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, output.ErrBare(output.ExitAuth), nil)
|
||||
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
|
||||
}
|
||||
@@ -492,7 +494,7 @@ func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
|
||||
WithHint("custom producer hint").
|
||||
WithCause(innerLegacy)
|
||||
|
||||
exit := handleRootError(f, outer, nil)
|
||||
exit := handleRootError(f, outer)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (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"}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -29,8 +28,6 @@ var runRootUpgrade = func(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
var checkRootCachedUpdate = update.CheckCached
|
||||
|
||||
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
||||
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
||||
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
||||
@@ -54,10 +51,7 @@ func readYes(r io.Reader) bool {
|
||||
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
||||
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
||||
// failure is swallowed — it must never affect help output or the exit code.
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recovery.Projector) {
|
||||
if f == nil || !projector.CanReference(recovery.TargetUpdate) {
|
||||
return
|
||||
}
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
ios := f.IOStreams
|
||||
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
||||
// stdout TTY too so this only fires in a pure foreground terminal session.
|
||||
@@ -67,11 +61,21 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recover
|
||||
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
||||
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
||||
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
||||
info := checkRootCachedUpdate(build.Version)
|
||||
info := update.CheckCached(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
// Deliberately no target version here: info.Latest comes from the on-disk
|
||||
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
|
||||
// failed refresh leaves the old value in place), so it can name a version
|
||||
// that is no longer the one npm would install. The version actually
|
||||
// installed is resolved live by the update subcommand, which prints
|
||||
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
|
||||
// that is where the user sees the real target. Keep going through the
|
||||
// update subcommand rather than calling RunNpmInstall directly, otherwise
|
||||
// that line disappears and the user approves a global install without ever
|
||||
// being told what gets installed.
|
||||
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
@@ -82,18 +86,14 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recover
|
||||
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
||||
// invocation offers an interactive upgrade before printing help. Non-bare
|
||||
// invocations are passed straight through, unchanged.
|
||||
func installRootUpgradePrompt(
|
||||
f *cmdutil.Factory,
|
||||
root *cobra.Command,
|
||||
projector *recovery.Projector,
|
||||
) {
|
||||
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
||||
inner := root.RunE
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if isBareRootInvocation(args) {
|
||||
offerRootUpgrade(f, cmd, projector)
|
||||
offerRootUpgrade(f, cmd)
|
||||
}
|
||||
return inner(cmd, args)
|
||||
}
|
||||
|
||||
@@ -14,10 +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/recovery"
|
||||
"github.com/larksuite/cli/internal/surface"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/internal/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -71,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
|
||||
@@ -125,12 +122,23 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
OutIsTerminal: tc.out,
|
||||
StderrIsTerminal: tc.err,
|
||||
}}
|
||||
offerRootUpgrade(f, &cobra.Command{}, nil)
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
|
||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
// The prompt must not name a target version: info.Latest comes from
|
||||
// the on-disk cache and can be stale, while the version actually
|
||||
// installed is resolved live by the update subcommand.
|
||||
if tc.wantPrompt {
|
||||
if strings.Contains(errBuf.String(), tc.latest) {
|
||||
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), build.Version) {
|
||||
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
|
||||
}
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
@@ -138,34 +146,6 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferRootUpgradeDoesNotReadCacheWhenUpdateIsConcealed(t *testing.T) {
|
||||
oldCheck := checkRootCachedUpdate
|
||||
t.Cleanup(func() { checkRootCachedUpdate = oldCheck })
|
||||
|
||||
cacheReads := 0
|
||||
checkRootCachedUpdate = func(string) *update.UpdateInfo {
|
||||
cacheReads++
|
||||
return &update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
|
||||
}
|
||||
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
||||
surface.CommandUpdate: surface.CommandConcealed,
|
||||
})
|
||||
projector := recovery.NewProjector(func() *surface.Plan { return plan })
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader("y\n"),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
IsTerminal: true,
|
||||
OutIsTerminal: true,
|
||||
StderrIsTerminal: true,
|
||||
}}
|
||||
|
||||
offerRootUpgrade(f, &cobra.Command{}, projector)
|
||||
if cacheReads != 0 {
|
||||
t.Fatalf("concealed update read cache %d time(s)", cacheReads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
@@ -178,7 +158,7 @@ func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root, nil)
|
||||
installRootUpgradePrompt(f, root)
|
||||
|
||||
if err := root.RunE(root, []string{}); err != nil {
|
||||
t.Fatalf("bare RunE err = %v", err)
|
||||
@@ -215,7 +195,7 @@ func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root, nil)
|
||||
installRootUpgradePrompt(f, root)
|
||||
if root.RunE != nil {
|
||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user