mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 14:38:53 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc6bb67aa | ||
|
|
a1438586ec | ||
|
|
c9b660ae12 | ||
|
|
567b40778b | ||
|
|
ec23995bce | ||
|
|
1980b999f7 | ||
|
|
1be9a241b7 | ||
|
|
f4afa47de8 | ||
|
|
bb38ecd41a | ||
|
|
9f0758bfef | ||
|
|
d3d92e37c2 | ||
|
|
b064188f20 | ||
|
|
799179fde6 | ||
|
|
8db4528269 | ||
|
|
30dba35c77 | ||
|
|
2efadece34 | ||
|
|
b7613d64bd |
57
.github/workflows/issue-labels.yml
vendored
Normal file
57
.github/workflows/issue-labels.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Issue Labels
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Do not write labels, only print planned changes"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-labels
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync-issue-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# v6+ uses Node 24 runtime for JavaScript actions.
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Sync managed issue labels
|
||||
id: sync_issue_labels
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
run: |
|
||||
args=(
|
||||
"--max-issues" "300"
|
||||
)
|
||||
|
||||
# Schedule runs should write labels by default.
|
||||
# Manual runs default to dry-run unless explicitly disabled.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_DRY_RUN:-true}" = "true" ]; then
|
||||
args+=("--dry-run" "--json")
|
||||
fi
|
||||
|
||||
node scripts/issue-labels/index.js "${args[@]}"
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_issue_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::Issue label sync failed; labels may be stale."
|
||||
echo "⚠️ Issue label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -27,6 +27,7 @@ linters:
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- unconvert # removes unnecessary type conversions
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
- forbidigo # forbids specific function calls
|
||||
|
||||
# To enable later after fixing existing issues:
|
||||
# - errcheck # checks for unchecked errors
|
||||
@@ -44,8 +45,89 @@ linters:
|
||||
linters:
|
||||
- bodyclose
|
||||
- gocritic
|
||||
- forbidigo
|
||||
- path-except: (shortcuts/|internal/)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── Filesystem operations: use internal/vfs instead ──
|
||||
- pattern: os\.Stat\b
|
||||
msg: "use vfs.Stat() from internal/vfs"
|
||||
- pattern: os\.Lstat\b
|
||||
msg: "use vfs.Lstat() from internal/vfs"
|
||||
- pattern: os\.Open\b
|
||||
msg: "use vfs.Open() from internal/vfs"
|
||||
- pattern: os\.OpenFile\b
|
||||
msg: "use vfs.OpenFile() from internal/vfs"
|
||||
- pattern: os\.Create\b
|
||||
msg: "use vfs.OpenFile() from internal/vfs"
|
||||
- pattern: os\.CreateTemp\b
|
||||
msg: >-
|
||||
internal/: use vfs.CreateTemp() from internal/vfs.
|
||||
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
|
||||
- pattern: os\.Mkdir\b
|
||||
msg: "use vfs.MkdirAll() from internal/vfs"
|
||||
- pattern: os\.MkdirAll\b
|
||||
msg: "use vfs.MkdirAll() from internal/vfs"
|
||||
- pattern: os\.Remove\b
|
||||
msg: >-
|
||||
internal/: use vfs.Remove() from internal/vfs.
|
||||
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
|
||||
- pattern: os\.RemoveAll\b
|
||||
msg: >-
|
||||
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
|
||||
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
|
||||
- pattern: os\.Rename\b
|
||||
msg: "use vfs.Rename() from internal/vfs"
|
||||
- pattern: os\.ReadFile\b
|
||||
msg: "use vfs.ReadFile() from internal/vfs"
|
||||
- pattern: os\.WriteFile\b
|
||||
msg: "use vfs.WriteFile() from internal/vfs"
|
||||
- pattern: os\.ReadDir\b
|
||||
msg: "add ReadDir to internal/vfs/fs.go first, then use vfs.ReadDir()"
|
||||
- pattern: os\.Getwd\b
|
||||
msg: "use vfs.Getwd() from internal/vfs"
|
||||
- pattern: os\.Chdir\b
|
||||
msg: "add Chdir to internal/vfs/fs.go first, then use vfs.Chdir()"
|
||||
- pattern: os\.UserHomeDir\b
|
||||
msg: "use vfs.UserHomeDir() from internal/vfs"
|
||||
- pattern: os\.Chmod\b
|
||||
msg: "add Chmod to internal/vfs/fs.go first, then use vfs.Chmod()"
|
||||
- pattern: os\.Chown\b
|
||||
msg: "add Chown to internal/vfs/fs.go first, then use vfs.Chown()"
|
||||
- pattern: os\.Lchown\b
|
||||
msg: "add Lchown to internal/vfs/fs.go first, then use vfs.Lchown()"
|
||||
- pattern: os\.Link\b
|
||||
msg: "add Link to internal/vfs/fs.go first, then use vfs.Link()"
|
||||
- pattern: os\.Symlink\b
|
||||
msg: "add Symlink to internal/vfs/fs.go first, then use vfs.Symlink()"
|
||||
- pattern: os\.Readlink\b
|
||||
msg: "add Readlink to internal/vfs/fs.go first, then use vfs.Readlink()"
|
||||
- pattern: os\.Truncate\b
|
||||
msg: "add Truncate to internal/vfs/fs.go first, then use vfs.Truncate()"
|
||||
- pattern: os\.DirFS\b
|
||||
msg: "add DirFS to internal/vfs/fs.go first, then use vfs.DirFS()"
|
||||
- pattern: os\.SameFile\b
|
||||
msg: "add SameFile to internal/vfs/fs.go first, then use vfs.SameFile()"
|
||||
# ── IO streams: use IOStreams from cmdutil instead ──
|
||||
- pattern: os\.Stdin\b
|
||||
msg: "use IOStreams.In instead of os.Stdin"
|
||||
- pattern: os\.Stdout\b
|
||||
msg: "use IOStreams.Out instead of os.Stdout"
|
||||
- pattern: os\.Stderr\b
|
||||
msg: "use IOStreams.ErrOut instead of os.Stderr"
|
||||
# ── Process-level rules ──
|
||||
- pattern: os\.Exit\b
|
||||
msg: >-
|
||||
Do not use os.Exit in shortcuts/. Return an error instead and let
|
||||
the caller (cmd layer) decide how to terminate.
|
||||
analyze-types: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
|
||||
81
AGENTS.md
81
AGENTS.md
@@ -1,33 +1,78 @@
|
||||
# AGENTS.md
|
||||
Concise maintainer/developer guide for building, testing, and opening high-quality PRs in this repo.
|
||||
|
||||
## Goal (pick one per PR)
|
||||
|
||||
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
|
||||
- Improve reliability: fix bugs, edge cases, and regressions with tests.
|
||||
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
|
||||
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
|
||||
|
||||
## Fast Dev Loop
|
||||
1. `make build` (runs `python3 scripts/fetch_meta.py` first)
|
||||
2. `make unit-test` (required before PR)
|
||||
3. Run changed command(s) manually via `./lark-cli ...`
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
2. `go mod tidy` (must not change `go.mod`/`go.sum`)
|
||||
2. `go mod tidy` — must not change `go.mod`/`go.sum`
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
5. Optional full local suite: `make test` (vet + unit + integration)
|
||||
|
||||
## Test/Check Commands
|
||||
- Unit: `make unit-test`
|
||||
- Integration: `make integration-test`
|
||||
- Full: `make test`
|
||||
- Vet only: `make vet`
|
||||
- Coverage (local): `go test -race -coverprofile=coverage.txt -covermode=atomic ./...`
|
||||
## Commit & PR
|
||||
|
||||
## Commit/PR Rules
|
||||
- Use Conventional Commits in English: `feat: ...`, `fix: ...`, `docs: ...`, `ci: ...`, `test: ...`, `chore: ...`, `refactor: ...`
|
||||
- Keep PR title in the same Conventional Commit format (squash merge keeps it).
|
||||
- Before opening a real PR, draft/fill description from `.github/pull_request_template.md` and ensure Summary/Changes/Test Plan are complete.
|
||||
- Never commit secrets/tokens/internal sensitive data.
|
||||
- Conventional Commits in English: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`, `ci:`
|
||||
- PR title in the same format. Fill `.github/pull_request_template.md` completely.
|
||||
- Never commit secrets, tokens, or internal sensitive data.
|
||||
|
||||
## Source Layout
|
||||
|
||||
| Path | What it does |
|
||||
|------|-------------|
|
||||
| `cmd/root.go` | Entry point, command registration, strict mode pruning |
|
||||
| `cmd/profile/` | Multi-profile management (add/list/use/rename/remove) |
|
||||
| `cmd/config/` | Config init, show, strict-mode |
|
||||
| `cmd/service/` | Auto-registered API commands from embedded metadata |
|
||||
| `shortcuts/common/runner.go` | Shortcut execution pipeline, Flag.Input (@file/stdin) resolution |
|
||||
| `shortcuts/` | Domain-specific shortcut implementations |
|
||||
| `internal/cmdutil/factory.go` | Factory pattern — identity resolution, credential, config |
|
||||
| `internal/cmdutil/factory_default.go` | Production factory wiring |
|
||||
| `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 |
|
||||
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
|
||||
| `internal/validate/path.go` | Path safety validation |
|
||||
|
||||
## 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.
|
||||
|
||||
The one rule to internalize: **every error message you write will be parsed by an AI to decide its next action.** Make errors structured, actionable, and specific.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
|
||||
### Validate paths before reading
|
||||
|
||||
CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInputPath` before any file I/O.
|
||||
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -2,6 +2,38 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.5] - 2026-04-07
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Support multipart upload for files larger than 20MB (#43)
|
||||
- Add darwin file master key fallback for keychain writes (#285)
|
||||
- Add strict mode identity filter, profile management and credential extension (#252)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Restore CID validation and stale PartID lookup lost in revert (#230)
|
||||
- **base**: Clarify table-id `tbl` prefix requirement (#270)
|
||||
- Fix parameter constraints for LarkMessageTrigger (#213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix root calendar example (#299)
|
||||
- Fix README auth scope and api data flag (#298)
|
||||
- Clarify task guid for applinks (#287)
|
||||
- Clarify lark task guid usage (#282)
|
||||
- **lark-base**: Add `has_more` guidance for record-list pagination (#183)
|
||||
|
||||
### Tests
|
||||
|
||||
- Isolate registry package state in tests (#280)
|
||||
|
||||
### CI
|
||||
|
||||
- Add scheduled issue labeler for type/domain triage (#251)
|
||||
- **issue-labels**: Reduce mislabeling and handle missing labels (#288)
|
||||
- Map wiki paths in pr labels (#249)
|
||||
|
||||
## [v1.0.4] - 2026-04-03
|
||||
|
||||
### Features
|
||||
@@ -161,6 +193,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
|
||||
|
||||
@@ -173,7 +173,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# Exact scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent mode: return verification URL immediately, non-blocking
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -216,7 +216,7 @@ Call any Lark Open Platform endpoint directly, covering 2500+ APIs.
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
@@ -174,7 +174,7 @@ lark-cli auth login --domain calendar,task
|
||||
lark-cli auth login --recommend
|
||||
|
||||
# 精确 scope
|
||||
lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
lark-cli auth login --scope "calendar:calendar:read"
|
||||
|
||||
# Agent 模式:立即返回验证 URL,不阻塞
|
||||
lark-cli auth login --domain calendar --no-wait
|
||||
@@ -217,7 +217,7 @@ lark-cli calendar events instance_view --params '{"calendar_id":"primary","start
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
|
||||
```
|
||||
|
||||
## 进阶用法
|
||||
|
||||
@@ -152,7 +152,11 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
|
||||
func apiRun(opts *APIOptions) error {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
@@ -166,7 +170,7 @@ func apiRun(opts *APIOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -70,16 +70,6 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"tenant_access_token": "t-test-token",
|
||||
"expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register API endpoint stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test",
|
||||
@@ -234,13 +224,6 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-bin", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/drive/v1/files/xxx/download",
|
||||
RawBody: []byte("fake-binary-content"),
|
||||
@@ -266,14 +249,6 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa1", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a non-batch API that returns scalar data (no array field)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users/u123",
|
||||
@@ -310,13 +285,6 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-err", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Non-batch API that returns a business error (code != 0)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||
@@ -346,14 +314,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Register tenant_access_token stub (unique app credentials => new token request)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pa2", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Register a batch API that returns an array field
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
@@ -409,13 +369,6 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-raw", "expire": 7200,
|
||||
},
|
||||
})
|
||||
// Return a permission error from the API
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
@@ -456,13 +409,6 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-origmsg", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/origmsg",
|
||||
Body: map[string]interface{}{
|
||||
@@ -505,13 +451,6 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-rawpage", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/rawpage",
|
||||
Body: map[string]interface{}{
|
||||
@@ -599,13 +538,6 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-jq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/jq",
|
||||
Body: map[string]interface{}{
|
||||
@@ -676,13 +608,6 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pjq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
|
||||
@@ -46,8 +46,8 @@ func authListRun(opts *ListOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode)
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -53,6 +59,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
return authLoginRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
@@ -101,8 +108,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Determine UI language from saved config
|
||||
lang := "zh"
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 {
|
||||
lang = multi.Apps[0].Lang
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
}
|
||||
}
|
||||
msg := getLoginMsg(lang)
|
||||
|
||||
@@ -304,18 +313,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
@@ -384,24 +384,49 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
}
|
||||
|
||||
// Update config — overwrite Users to single user, clean old tokens
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi != nil && len(multi.Apps) > 0 {
|
||||
app := &multi.Apps[0]
|
||||
for _, oldUser := range app.Users {
|
||||
if oldUser.UserOpenId != openId {
|
||||
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
app := findProfileByName(multi, profileName)
|
||||
if app == nil {
|
||||
return fmt.Errorf("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 {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
for _, oldUser := range oldUsers {
|
||||
if oldUser.UserOpenId != openID {
|
||||
_ = larkauth.RemoveStoredToken(appID, oldUser.UserOpenId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
return &multi.Apps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectScopesForDomains collects API scopes (from from_meta projects) and
|
||||
// shortcut scopes for the given domain names.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
|
||||
74
cmd/auth/login_config_test.go
Normal file
74
cmd/auth/login_config_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupLoginConfigDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "target",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
|
||||
},
|
||||
{
|
||||
Name: "other",
|
||||
AppId: "app-other",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if err := syncLoginUserToProfile("target", "app-target", "ou_new", "new-user"); err != nil {
|
||||
t.Fatalf("syncLoginUserToProfile() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if got := saved.Apps[0].Users; len(got) != 1 || got[0].UserOpenId != "ou_new" || got[0].UserName != "new-user" {
|
||||
t.Fatalf("target users = %#v, want replaced login user", got)
|
||||
}
|
||||
if got := saved.Apps[1].Users; len(got) != 1 || got[0].UserOpenId != "ou_other" {
|
||||
t.Fatalf("other users = %#v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
err := syncLoginUserToProfile("missing", "app-default", "ou_new", "new-user")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing profile")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `profile "missing" not found`) {
|
||||
t.Fatalf("error = %v, want missing profile", err)
|
||||
}
|
||||
}
|
||||
78
cmd/auth/login_strict_test.go
Normal file
78
cmd/auth/login_strict_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsBot),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if called {
|
||||
t.Error("runF should not be called in bot strict mode")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error in bot strict mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("error should mention strict mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "a", AppSecret: "s",
|
||||
SupportedIdentities: uint8(extcred.SupportsUser),
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called in user strict mode")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
var called bool
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if !called {
|
||||
t.Error("runF should be called when strict mode is off")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
app := &multi.Apps[0]
|
||||
if len(app.Users) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
30
cmd/bootstrap.go
Normal file
30
cmd/bootstrap.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// BootstrapInvocationContext extracts global invocation options before
|
||||
// the real command tree is built, so provider-backed config resolution sees
|
||||
// the correct profile from the start.
|
||||
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
|
||||
var globals GlobalOptions
|
||||
|
||||
fs := pflag.NewFlagSet("bootstrap", pflag.ContinueOnError)
|
||||
fs.ParseErrorsAllowlist.UnknownFlags = true
|
||||
fs.SetInterspersed(true)
|
||||
fs.SetOutput(io.Discard)
|
||||
RegisterGlobalFlags(fs, &globals)
|
||||
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
}
|
||||
72
cmd/bootstrap_test.go
Normal file
72
cmd/bootstrap_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileEquals(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--profile=target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_IgnoresUnknownFlags(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--verify", "--profile", "target"})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_MissingProfileValue(t *testing.T) {
|
||||
if _, err := BootstrapInvocationContext([]string{"auth", "status", "--profile"}); err == nil {
|
||||
t.Fatal("BootstrapInvocationContext() error = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_ShortHelp(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"-h"})
|
||||
if err != nil {
|
||||
t.Fatalf("-h should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Fatalf("profile = %q, want empty", inv.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("--profile + --help should not error, got: %v", err)
|
||||
}
|
||||
if inv.Profile != "target" {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,10 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseBrand(value string) core.LarkBrand {
|
||||
if value == "lark" {
|
||||
return core.BrandLark
|
||||
}
|
||||
return core.BrandFeishu
|
||||
return core.ParseBrand(value)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,22 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
|
||||
func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
@@ -56,6 +65,60 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
|
||||
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -157,3 +220,50 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
|
||||
t.Fatal("expected factory to be preserved in options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "prod",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
||||
Brand: core.BrandFeishu,
|
||||
Lang: "zh",
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when changing app ID without a new secret")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "App Secret") {
|
||||
t.Fatalf("error = %v, want mention of App Secret", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
current := multi.Apps[0].DefaultAs
|
||||
current := app.DefaultAs
|
||||
if current == "" {
|
||||
current = "auto"
|
||||
}
|
||||
@@ -39,9 +44,9 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
multi.Apps[0].DefaultAs = value
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,8 @@ type ConfigInitOptions struct {
|
||||
Brand string
|
||||
New bool
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -59,6 +62,7 @@ verification URL from its output.`,
|
||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -94,6 +98,110 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
|
||||
return core.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 {
|
||||
if profileName != "" {
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
return saveAsOnlyApp(appId, secret, brand, lang)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
multi := existing
|
||||
if multi == nil {
|
||||
multi = &core.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)
|
||||
for _, user := range multi.Apps[idx].Users {
|
||||
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
|
||||
}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
}
|
||||
// Update existing profile
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = lang
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
}
|
||||
|
||||
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].Name == profileName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
if multi == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].AppId == appID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
if existing == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
|
||||
var app *core.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new profile")
|
||||
}
|
||||
} else {
|
||||
app = existing.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
}
|
||||
}
|
||||
|
||||
if app.AppId != appID {
|
||||
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
||||
}
|
||||
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = lang
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -117,6 +225,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
existing = nil // treat as empty
|
||||
}
|
||||
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
@@ -124,8 +239,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, opts.AppID)
|
||||
if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
@@ -136,8 +250,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// For interactive modes, prompt language selection if --lang was not explicitly set
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
savedLang := ""
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
savedLang = existing.Apps[0].Lang
|
||||
if existing != nil {
|
||||
if app := existing.CurrentAppConfig(""); app != nil {
|
||||
savedLang = app.Lang
|
||||
}
|
||||
}
|
||||
lang, err := promptLangSelection(savedLang)
|
||||
if err != nil {
|
||||
@@ -165,8 +281,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
@@ -191,21 +306,17 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, result.AppID)
|
||||
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
existing.Apps[0].AppId = result.AppID
|
||||
existing.Apps[0].Brand = result.Brand
|
||||
existing.Apps[0].Lang = opts.Lang
|
||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
@@ -224,8 +335,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
firstApp := (*core.AppConfig)(nil)
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(f.IOStreams.In)
|
||||
@@ -296,8 +407,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
cleanupOldConfig(existing, f, resolvedAppId)
|
||||
if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
|
||||
@@ -61,8 +61,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
// Load existing config for defaults
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
var firstApp *core.AppConfig
|
||||
if existing != nil && len(existing.Apps) > 0 {
|
||||
firstApp = &existing.Apps[0]
|
||||
if existing != nil {
|
||||
firstApp = existing.CurrentAppConfig("")
|
||||
}
|
||||
|
||||
var appID, appSecret, brand string
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -40,12 +42,19 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath())
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.")
|
||||
return nil
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
||||
}
|
||||
app := config.Apps[0]
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
var userStrs []string
|
||||
@@ -55,6 +64,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
users = strings.Join(userStrs, ", ")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"profile": app.ProfileName(),
|
||||
"appId": app.AppId,
|
||||
"appSecret": "****",
|
||||
"brand": app.Brand,
|
||||
|
||||
146
cmd/config/strict_mode.go
Normal file
146
cmd/config/strict_mode.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigStrictMode creates the "config strict-mode" subcommand.
|
||||
func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
var global bool
|
||||
var reset bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return output.ErrValidation("--reset cannot be used with --global")
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return output.ErrValidation("--reset cannot be used with a value argument")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", 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 {
|
||||
// Runtime effective mode from credential provider chain is the source of truth.
|
||||
runtime := f.ResolveStrictMode(ctx)
|
||||
configMode, configSource := resolveStrictModeStatus(multi, app)
|
||||
|
||||
if runtime != configMode {
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: credential provider)\n", runtime)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: %s)\n", configMode, configSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
|
||||
mode := core.StrictMode(value)
|
||||
switch mode {
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
default:
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
if a.StrictMode != nil && *a.StrictMode != mode {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut,
|
||||
"Warning: profile %q has strict-mode explicitly set to %q, "+
|
||||
"which overrides the global setting. "+
|
||||
"Use --reset in that profile to inherit global.\n",
|
||||
a.ProfileName(), *a.StrictMode)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Strict mode set to %s (%s)\n", mode, scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.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)"
|
||||
}
|
||||
164
cmd/config/strict_mode_test.go
Normal file
164
cmd/config/strict_mode_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func setupStrictModeTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.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"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "off") {
|
||||
t.Errorf("expected 'off' in output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetBot_Profile(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.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()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.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"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"user"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.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"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode == nil || *app.StrictMode != core.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"})
|
||||
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 {
|
||||
t.Error("expected global StrictMode=bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing-profile",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.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()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.StrictMode != core.StrictModeBot {
|
||||
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_Reset(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"bot"})
|
||||
cmd.Execute()
|
||||
cmd = NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"--reset"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
app := multi.CurrentAppConfig("")
|
||||
if app.StrictMode != nil {
|
||||
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_InvalidValue(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid value 'on'")
|
||||
}
|
||||
}
|
||||
17
cmd/global_flags.go
Normal file
17
cmd/global_flags.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/pflag"
|
||||
|
||||
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
|
||||
// actual Cobra command tree.
|
||||
type GlobalOptions struct {
|
||||
Profile string
|
||||
}
|
||||
|
||||
// RegisterGlobalFlags registers the root-level persistent flags.
|
||||
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
|
||||
}
|
||||
137
cmd/profile/add.go
Normal file
137
cmd/profile/add.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileAdd creates the profile add subcommand.
|
||||
func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
appID string
|
||||
appSecretStdin bool
|
||||
brand string
|
||||
lang string
|
||||
use bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileAddRun(f, name, appID, appSecretStdin, brand, lang, use)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&name, "name", "", "profile name (required)")
|
||||
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
|
||||
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
|
||||
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
||||
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("app-id")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
|
||||
if err := core.ValidateProfileName(name); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
// Read secret from stdin
|
||||
if !appSecretStdin {
|
||||
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
||||
}
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
}
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
}
|
||||
appSecret := strings.TrimSpace(scanner.Text())
|
||||
if appSecret == "" {
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
|
||||
}
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
if multi.FindApp(name) != nil {
|
||||
return output.ErrValidation("profile %q already exists", name)
|
||||
}
|
||||
|
||||
// Check app-id uniqueness — keychain stores secrets by appId, so
|
||||
// multiple profiles sharing the same appId would collide on credentials.
|
||||
for _, a := range multi.Apps {
|
||||
if a.AppId == appID {
|
||||
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret securely
|
||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
parsedBrand := core.ParseBrand(brand)
|
||||
|
||||
// Capture current profile before appending (avoid setting PreviousApp to self)
|
||||
var previousName string
|
||||
if useAfter {
|
||||
if currentApp := multi.CurrentAppConfig(""); currentApp != nil {
|
||||
previousName = currentApp.ProfileName()
|
||||
}
|
||||
}
|
||||
|
||||
// Append profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: name,
|
||||
AppId: appID,
|
||||
AppSecret: secret,
|
||||
Brand: parsedBrand,
|
||||
Lang: lang,
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
|
||||
if useAfter {
|
||||
if previousName != "" {
|
||||
multi.PreviousApp = previousName
|
||||
}
|
||||
multi.CurrentApp = name
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
||||
if useAfter {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q", name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
cmd/profile/list.go
Normal file
85
cmd/profile/list.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// NewCmdProfileList creates the profile list subcommand.
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileListRun(f *cmdutil.Factory) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
currentName = currentApp.ProfileName()
|
||||
}
|
||||
|
||||
items := make([]profileListItem, 0, len(multi.Apps))
|
||||
for i := range multi.Apps {
|
||||
app := &multi.Apps[i]
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
item.User = app.Users[0].UserName
|
||||
stored := larkauth.GetStoredToken(app.AppId, app.Users[0].UserOpenId)
|
||||
if stored != nil {
|
||||
item.TokenStatus = larkauth.TokenStatus(stored)
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, items)
|
||||
return nil
|
||||
}
|
||||
29
cmd/profile/profile.go
Normal file
29
cmd/profile/profile.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdProfile creates the profile command with subcommands.
|
||||
func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
|
||||
})
|
||||
|
||||
cmd.AddCommand(NewCmdProfileList(f))
|
||||
cmd.AddCommand(NewCmdProfileUse(f))
|
||||
cmd.AddCommand(NewCmdProfileAdd(f))
|
||||
cmd.AddCommand(NewCmdProfileRemove(f))
|
||||
cmd.AddCommand(NewCmdProfileRename(f))
|
||||
return cmd
|
||||
}
|
||||
371
cmd/profile/profile_test.go
Normal file
371
cmd/profile/profile_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type failRenameFS struct {
|
||||
vfs.OsFs
|
||||
err error
|
||||
}
|
||||
|
||||
func (fs *failRenameFS) Rename(oldpath, newpath string) error {
|
||||
return fs.err
|
||||
}
|
||||
|
||||
func setupProfileConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret\n")
|
||||
|
||||
err := profileAddRun(f, "test", "app-test", true, "feishu", "zh", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid existing config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to load config") {
|
||||
t.Fatalf("error = %v, want failed to load config", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret-new\n")
|
||||
|
||||
if err := profileAddRun(f, "target", "app-target", true, "lark", "en", true); err != nil {
|
||||
t.Fatalf("profileAddRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 2 {
|
||||
t.Fatalf("len(Apps) = %d, want 2", len(saved.Apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.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},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRemoveRun(f, "target"); err != nil {
|
||||
t.Fatalf("profileRemoveRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "default" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "default")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
if len(saved.Apps) != 1 || saved.Apps[0].ProfileName() != "default" {
|
||||
t.Fatalf("remaining apps = %#v, want only default", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "new"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "new" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "new")
|
||||
}
|
||||
if saved.PreviousApp != "new" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "new")
|
||||
}
|
||||
if saved.Apps[0].ProfileName() != "new" {
|
||||
t.Fatalf("ProfileName() = %q, want %q", saved.Apps[0].ProfileName(), "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
PreviousApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileRenameRun(f, "old", "app-old"); err != nil {
|
||||
t.Fatalf("profileRenameRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "app-old" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "app-old")
|
||||
}
|
||||
if saved.PreviousApp != "app-old" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "app-old")
|
||||
}
|
||||
if saved.Apps[0].Name != "app-old" {
|
||||
t.Fatalf("Name = %q, want %q", saved.Apps[0].Name, "app-old")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.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},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileUseRun(f, "-"); err != nil {
|
||||
t.Fatalf("profileUseRun() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved.CurrentApp != "target" {
|
||||
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
|
||||
}
|
||||
if saved.PreviousApp != "default" {
|
||||
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "default" || !got[0].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := profileListRun(f); err != nil {
|
||||
t.Fatalf("profileListRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got []profileListItem
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("len(got) = %d, want 0", len(got))
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.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},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRemoveRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "old",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "old",
|
||||
AppId: "app-old",
|
||||
AppSecret: core.PlainSecret("secret-old"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileRenameRun(f, "old", "new")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
setupProfileConfigDir(t)
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
|
||||
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
restoreFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
|
||||
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := profileUseRun(f, "target")
|
||||
if err == nil {
|
||||
t.Fatal("expected save error")
|
||||
}
|
||||
assertInternalExitError(t, err, "failed to save config")
|
||||
}
|
||||
|
||||
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
||||
t.Helper()
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
||||
}
|
||||
}
|
||||
78
cmd/profile/remove.go
Normal file
78
cmd/profile/remove.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileRemove creates the profile remove subcommand.
|
||||
func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a profile",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRemoveRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return output.ErrValidation("cannot remove the only profile")
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
removedName := app.ProfileName()
|
||||
appId := app.AppId
|
||||
appSecret := app.AppSecret
|
||||
users := app.Users
|
||||
|
||||
// Remove from slice
|
||||
multi.Apps = append(multi.Apps[:idx], multi.Apps[idx+1:]...)
|
||||
|
||||
// Fix currentApp / previousApp references
|
||||
if multi.CurrentApp == removedName {
|
||||
multi.CurrentApp = multi.Apps[0].ProfileName()
|
||||
}
|
||||
if multi.PreviousApp == removedName {
|
||||
multi.PreviousApp = ""
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
core.RemoveSecretStore(appSecret, f.Keychain)
|
||||
for _, user := range users {
|
||||
larkauth.RemoveStoredToken(appId, user.UserOpenId)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q removed", removedName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/rename.go
Normal file
73
cmd/profile/rename.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileRename creates the profile rename subcommand.
|
||||
func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename <old> <new>",
|
||||
Short: "Rename a profile",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileRenameRun(f, args[0], args[1])
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
if err := core.ValidateProfileName(newName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
if idx < 0 {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
// Check new name uniqueness across other profiles, allowing renames to this
|
||||
// profile's own appId or current name.
|
||||
for i := range multi.Apps {
|
||||
if i == idx {
|
||||
continue
|
||||
}
|
||||
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
||||
return output.ErrValidation("profile %q already exists", newName)
|
||||
}
|
||||
}
|
||||
|
||||
oldProfileName := multi.Apps[idx].ProfileName()
|
||||
multi.Apps[idx].Name = newName
|
||||
|
||||
// Update currentApp / previousApp references
|
||||
if multi.CurrentApp == oldProfileName {
|
||||
multi.CurrentApp = newName
|
||||
}
|
||||
if multi.PreviousApp == oldProfileName {
|
||||
multi.PreviousApp = newName
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
||||
return nil
|
||||
}
|
||||
73
cmd/profile/use.go
Normal file
73
cmd/profile/use.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdProfileUse creates the profile use subcommand.
|
||||
func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Short: "Switch to a profile (use '-' to toggle back)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileUseRun(f, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
if name == "-" {
|
||||
if multi.PreviousApp == "" {
|
||||
return output.ErrValidation("no previous profile to switch back to")
|
||||
}
|
||||
name = multi.PreviousApp
|
||||
}
|
||||
|
||||
app := multi.FindApp(name)
|
||||
if app == nil {
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
targetName := app.ProfileName()
|
||||
|
||||
// Short-circuit if already on the target profile
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
if currentApp != nil && currentApp.ProfileName() == targetName {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Already on profile %q\n", targetName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update previous and current
|
||||
if currentApp != nil {
|
||||
multi.PreviousApp = currentApp.ProfileName()
|
||||
}
|
||||
multi.CurrentApp = targetName
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
|
||||
return nil
|
||||
}
|
||||
80
cmd/prune.go
Normal file
80
cmd/prune.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
||||
pruneIncompatible(root, mode)
|
||||
pruneEmpty(root)
|
||||
}
|
||||
|
||||
// pruneIncompatible recursively replaces commands whose annotation declares
|
||||
// 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) {
|
||||
forced := string(mode.ForcedIdentity())
|
||||
var toRemove []*cobra.Command
|
||||
var toAdd []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
ids := cmdutil.GetSupportedIdentities(child)
|
||||
if ids != nil && !slices.Contains(ids, forced) {
|
||||
toRemove = append(toRemove, child)
|
||||
toAdd = append(toAdd, strictModeStubFrom(child, mode))
|
||||
continue
|
||||
}
|
||||
pruneIncompatible(child, mode)
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
parent.AddCommand(toAdd...)
|
||||
}
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: child.Use,
|
||||
Aliases: append([]string(nil), child.Aliases...),
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pruneEmpty recursively removes group commands (no Run/RunE) that have
|
||||
// no remaining subcommands after pruning. If only hidden stubs remain, keep
|
||||
// the group hidden so direct execution still resolves to the stub path.
|
||||
func pruneEmpty(parent *cobra.Command) {
|
||||
var toRemove []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
pruneEmpty(child)
|
||||
if child.Run != nil || child.RunE != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case child.HasAvailableSubCommands():
|
||||
case len(child.Commands()) > 0:
|
||||
child.Hidden = true
|
||||
default:
|
||||
toRemove = append(toRemove, child)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
parent.RemoveCommand(toRemove...)
|
||||
}
|
||||
}
|
||||
200
cmd/prune_test.go
Normal file
200
cmd/prune_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
|
||||
svc := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(svc)
|
||||
|
||||
noop := func(*cobra.Command, []string) error { return nil }
|
||||
|
||||
userOnly := &cobra.Command{Use: "+search", Short: "user only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
botOnly := &cobra.Command{Use: "+subscribe", Short: "bot only", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(botOnly, []string{"bot"})
|
||||
svc.AddCommand(botOnly)
|
||||
|
||||
dual := &cobra.Command{Use: "+send", Short: "dual", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(dual, []string{"user", "bot"})
|
||||
svc.AddCommand(dual)
|
||||
|
||||
noAnnotation := &cobra.Command{Use: "+legacy", Short: "no annotation", RunE: noop}
|
||||
svc.AddCommand(noAnnotation)
|
||||
|
||||
res := &cobra.Command{Use: "messages"}
|
||||
svc.AddCommand(res)
|
||||
userMethod := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmdutil.SetSupportedIdentities(userMethod, []string{"user"})
|
||||
res.AddCommand(userMethod)
|
||||
|
||||
auth := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(auth)
|
||||
login := &cobra.Command{Use: "login", RunE: noop}
|
||||
cmdutil.SetSupportedIdentities(login, []string{"user"})
|
||||
auth.AddCommand(login)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func findCmd(root *cobra.Command, names ...string) *cobra.Command {
|
||||
cmd := root
|
||||
for _, name := range names {
|
||||
found := false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == name {
|
||||
cmd = c
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.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")
|
||||
}
|
||||
if findCmd(root, "im", "+subscribe") == nil {
|
||||
t.Error("+subscribe (bot-only) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in bot mode")
|
||||
}
|
||||
if findCmd(root, "im", "+legacy") == nil {
|
||||
t.Error("+legacy (no annotation) should be kept")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "messages", "search"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("search (user-only method) should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("auth login should be replaced by a hidden stub in bot mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
|
||||
if findCmd(root, "im", "+search") == nil {
|
||||
t.Error("+search (user-only) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "im", "+subscribe"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("+subscribe (bot-only) should be replaced by a hidden stub in user mode")
|
||||
}
|
||||
if findCmd(root, "im", "+send") == nil {
|
||||
t.Error("+send (dual) should be kept in user mode")
|
||||
}
|
||||
if cmd := findCmd(root, "auth", "login"); cmd == nil || cmd.Hidden {
|
||||
t.Error("auth login should be kept in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden {
|
||||
t.Error("resource 'messages' should be kept hidden when only hidden stubs remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmpty_PreservesOriginallyHiddenGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
hidden := &cobra.Command{Use: "hidden", Hidden: true}
|
||||
root.AddCommand(hidden)
|
||||
hidden.AddCommand(&cobra.Command{
|
||||
Use: "visible",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
})
|
||||
|
||||
pruneEmpty(root)
|
||||
|
||||
if !hidden.Hidden {
|
||||
t.Fatal("expected originally hidden group to remain hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "+search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"im", "messages", "search", "--query", "hello"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T) {
|
||||
root := newTestTree()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
pruneForStrictMode(root, core.StrictModeUser)
|
||||
root.SetArgs([]string{"im", "+subscribe", "--topic", "x"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected strict-mode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `strict mode is "user"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
20
cmd/root.go
20
cmd/root.go
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
@@ -43,7 +45,7 @@ EXAMPLES:
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# List calendar events
|
||||
lark-cli calendar events list --params '{"calendar_id":"primary"}'
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
@@ -87,8 +89,14 @@ More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
f := cmdutil.NewDefault()
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
f := cmdutil.NewDefault(inv)
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -97,12 +105,15 @@ func Execute() int {
|
||||
}
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
@@ -110,6 +121,11 @@ func Execute() int {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupUpdateNotice()
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildTestRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildTestRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeE2E runs a command through the full command tree and handleRootError,
|
||||
// returning exit code — matching real CLI behavior.
|
||||
func executeE2E(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// registerTokenStub registers a tenant_access_token stub so bot auth succeeds.
|
||||
func registerTokenStub(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-e2e-token", "expire": 7200,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestE2E_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestE2E_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
func TestE2E_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
490
cmd/root_integration_test.go
Normal file
490
cmd/root_integration_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeRootIntegration runs a command through the full command tree and
|
||||
// handleRootError, returning the exit code matching real CLI behavior.
|
||||
func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.SetOut(f.IOStreams.Out)
|
||||
rootCmd.SetErr(f.IOStreams.ErrOut)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.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, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
targetMode := mode
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
AppSecret: core.PlainSecret("secret-target"),
|
||||
Brand: core.BrandFeishu,
|
||||
StrictMode: &targetMode,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
out := stdout.String()
|
||||
const prefix = "=== Dry Run ===\n"
|
||||
if !strings.HasPrefix(out, prefix) {
|
||||
t.Fatalf("expected dry-run prefix, got:\n%s", out)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("auth --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "login") {
|
||||
t.Fatalf("auth --help should hide login in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
resetBuffers(stdout, stderr)
|
||||
rootCmd = buildStrictModeIntegrationRootCmd(t, f)
|
||||
code = executeRootIntegration(t, f, rootCmd, []string{"im", "--help"})
|
||||
if code != 0 {
|
||||
t.Fatalf("im --help exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "+messages-search") {
|
||||
t.Fatalf("im --help should hide +messages-search in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "+chat-create") {
|
||||
t.Fatalf("im --help should keep +chat-create in bot mode, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if out == "" {
|
||||
t.Fatal("expected non-empty stdout for dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -169,13 +170,20 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
f := opts.Factory
|
||||
opts.As = f.ResolveAs(opts.Cmd, opts.As)
|
||||
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
|
||||
|
||||
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
@@ -191,7 +199,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -200,7 +208,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(config, opts.Method, scopes); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -247,25 +255,30 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
result, err := cred.ResolveToken(ctx, credential.NewTokenSpec(identity, config.AppID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored != nil {
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
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, " ")))
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
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, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -274,16 +287,12 @@ func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, s
|
||||
}
|
||||
|
||||
// Default: ANY one of the declared scopes is sufficient
|
||||
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
grantedScopes := make(map[string]bool)
|
||||
for _, s := range strings.Fields(stored.Scope) {
|
||||
grantedScopes[s] = true
|
||||
grantedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedScopes[str] {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,16 +44,6 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
return m
|
||||
}
|
||||
|
||||
func tokenStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
URL: "tenant_access_token",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test", "expire": 7200,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
|
||||
func TestRegisterService(t *testing.T) {
|
||||
@@ -364,7 +354,6 @@ func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -391,7 +380,6 @@ func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
||||
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
||||
@@ -425,7 +413,6 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -455,7 +442,6 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
@@ -540,7 +526,6 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
@@ -612,7 +597,6 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
|
||||
116
extension/credential/env/env.go
vendored
Normal file
116
extension/credential/env/env.go
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// Provider resolves credentials from environment variables.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "env" }
|
||||
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
acct.DefaultAs = credential.IdentityUser
|
||||
case hasTAT:
|
||||
acct.DefaultAs = credential.IdentityBot
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
envKey = envvars.CliUserAccessToken
|
||||
case credential.TokenTypeTAT:
|
||||
envKey = envvars.CliTenantAccessToken
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
token := os.Getenv(envKey)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &credential.Token{Value: token, Source: "env:" + envKey}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
279
extension/credential/env/env_test.go
vendored
Normal file
279
extension/credential/env/env_test.go
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
if (&Provider{}).Name() != "env" {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_BothSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliBrand, "feishu")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "feishu" {
|
||||
t.Errorf("unexpected: %+v", acct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_NeitherSet(t *testing.T) {
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil || acct != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", acct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected account, got nil")
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Fatalf("AppSecret = %q, want credential.NoAppSecret", acct.AppSecret)
|
||||
}
|
||||
if acct.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultBrand(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
acct, _ := (&Provider{}).ResolveAccount(context.Background())
|
||||
if acct.Brand != "feishu" {
|
||||
t.Errorf("expected 'feishu', got %q", acct.Brand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_UATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_TATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotSet(t *testing.T) {
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil || tok != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", tok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeBot(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeUser(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "user")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOff(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "off")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromUATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only from UAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user from UAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromTATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only from TAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "bot" {
|
||||
t.Errorf("expected default-as bot from TAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferBothTokens(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user when both tokens are present, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("strict mode should override token inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid strict mode")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid default-as")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
}
|
||||
29
extension/credential/registry.go
Normal file
29
extension/credential/registry.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
providers []Provider
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in registration order.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
func Providers() []Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result := make([]Provider, len(providers))
|
||||
copy(result, providers)
|
||||
return result
|
||||
}
|
||||
51
extension/credential/registry_test.go
Normal file
51
extension/credential/registry_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubProvider struct{ name string }
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
return &Account{AppID: s.name}, nil
|
||||
}
|
||||
func (s *stubProvider) ResolveToken(ctx context.Context, req TokenSpec) (*Token, error) {
|
||||
return &Token{Value: "tok-" + s.name, Source: s.name}, nil
|
||||
}
|
||||
|
||||
func TestRegisterAndProviders(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "a"})
|
||||
Register(&stubProvider{name: "b"})
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "a" || got[1].Name() != "b" {
|
||||
t.Errorf("unexpected order: %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "x"})
|
||||
snap := Providers()
|
||||
Register(&stubProvider{name: "y"})
|
||||
|
||||
if len(snap) != 1 {
|
||||
t.Fatalf("snapshot should not be affected, got %d", len(snap))
|
||||
}
|
||||
}
|
||||
100
extension/credential/types.go
Normal file
100
extension/credential/types.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "context"
|
||||
|
||||
// Brand represents the Lark platform brand.
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandLark Brand = "lark"
|
||||
BrandFeishu Brand = "feishu"
|
||||
)
|
||||
|
||||
// NoAppSecret marks that a credential source does not provide a real app secret.
|
||||
// Token-only sources should return this value instead of inventing placeholder text.
|
||||
const NoAppSecret = ""
|
||||
|
||||
// Identity represents the caller identity type.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
IdentityAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IdentitySupport declares which identities a credential source can provide.
|
||||
type IdentitySupport uint8
|
||||
|
||||
const (
|
||||
SupportsUser IdentitySupport = 1 << iota
|
||||
SupportsBot
|
||||
SupportsAll = SupportsUser | SupportsBot
|
||||
)
|
||||
|
||||
// Has reports whether s includes the given flag.
|
||||
func (s IdentitySupport) Has(flag IdentitySupport) bool { return s&flag != 0 }
|
||||
|
||||
// UserOnly returns true if only user identity is supported.
|
||||
func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
type Token struct {
|
||||
Value string
|
||||
Scopes string // space-separated; empty = skip scope pre-check
|
||||
Source string // e.g. "env:LARKSUITE_CLI_USER_ACCESS_TOKEN", "vault:addr"
|
||||
}
|
||||
|
||||
// TokenType represents the kind of access token.
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeUAT TokenType = "uat"
|
||||
TokenTypeTAT TokenType = "tat"
|
||||
)
|
||||
|
||||
// TokenSpec describes what token is needed.
|
||||
type TokenSpec struct {
|
||||
Type TokenType
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
return "blocked by " + e.Provider + ": " + e.Reason
|
||||
}
|
||||
|
||||
// Provider is the unified interface for credential resolution.
|
||||
//
|
||||
// Flow control uses Go's native mechanisms:
|
||||
// - Handle: return &Account{...}, nil or return &Token{...}, nil
|
||||
// - Skip: return nil, nil
|
||||
// - Block: return nil, &BlockError{...}
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*Token, error)
|
||||
}
|
||||
39
extension/credential/types_test.go
Normal file
39
extension/credential/types_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySupport_Has(t *testing.T) {
|
||||
if !SupportsAll.Has(SupportsUser) {
|
||||
t.Error("SupportsAll should have SupportsUser")
|
||||
}
|
||||
if !SupportsAll.Has(SupportsBot) {
|
||||
t.Error("SupportsAll should have SupportsBot")
|
||||
}
|
||||
if SupportsUser.Has(SupportsBot) {
|
||||
t.Error("SupportsUser should not have SupportsBot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_UserOnly(t *testing.T) {
|
||||
if !SupportsUser.UserOnly() {
|
||||
t.Error("SupportsUser.UserOnly() should be true")
|
||||
}
|
||||
if SupportsAll.UserOnly() {
|
||||
t.Error("SupportsAll.UserOnly() should be false")
|
||||
}
|
||||
if IdentitySupport(0).UserOnly() {
|
||||
t.Error("zero value UserOnly() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_BotOnly(t *testing.T) {
|
||||
if !SupportsBot.BotOnly() {
|
||||
t.Error("SupportsBot.BotOnly() should be true")
|
||||
}
|
||||
if SupportsAll.BotOnly() {
|
||||
t.Error("SupportsAll.BotOnly() should be false")
|
||||
}
|
||||
}
|
||||
28
extension/transport/registry.go
Normal file
28
extension/transport/registry.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a transport Provider.
|
||||
// Later registrations override earlier ones.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
77
extension/transport/registry_test.go
Normal file
77
extension/transport/registry_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubInterceptor struct{}
|
||||
|
||||
func (s *stubInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubProvider struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} }
|
||||
|
||||
func TestGetProvider_NilByDefault(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
if got := GetProvider(); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndGet(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "a"}
|
||||
Register(p)
|
||||
|
||||
got := GetProvider()
|
||||
if got != p {
|
||||
t.Fatalf("expected registered provider, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastRegistrationWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
a := &stubProvider{name: "a"}
|
||||
b := &stubProvider{name: "b"}
|
||||
Register(a)
|
||||
Register(b)
|
||||
|
||||
got := GetProvider()
|
||||
if got != b {
|
||||
t.Fatalf("expected provider b, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInterceptor_ReturnsNonNil(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "test"}
|
||||
Register(p)
|
||||
|
||||
ic := GetProvider().ResolveInterceptor(context.Background())
|
||||
if ic == nil {
|
||||
t.Fatal("expected non-nil Interceptor")
|
||||
}
|
||||
}
|
||||
32
extension/transport/types.go
Normal file
32
extension/transport/types.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Provider creates Interceptor instances.
|
||||
// Follows the same API style as extension/credential.Provider and extension/fileio.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
//
|
||||
// PreRoundTrip is called before the built-in chain. Use it to add custom
|
||||
// headers, rewrite the host, or start trace spans. Built-in decorators run
|
||||
// after this and will override any same-named security headers set here.
|
||||
// The extension must not replace req.Context() — the middleware restores
|
||||
// the original context after PreRoundTrip returns.
|
||||
//
|
||||
// The returned function (if non-nil) is called after the built-in chain
|
||||
// completes. Use it for logging, ending trace spans, or recording metrics.
|
||||
type Interceptor interface {
|
||||
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
@@ -54,7 +55,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
@@ -128,7 +129,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
|
||||
configDir := core.GetConfigDir()
|
||||
|
||||
lockDir := filepath.Join(configDir, "locks")
|
||||
if err := os.MkdirAll(lockDir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create lock directory: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,22 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
@@ -32,10 +36,26 @@ type RawApiRequest struct {
|
||||
|
||||
// APIClient wraps lark.Client for all Lark Open API calls.
|
||||
type APIClient struct {
|
||||
Config *core.CliConfig
|
||||
SDK *lark.Client // All Lark API calls go through SDK
|
||||
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
|
||||
ErrOut io.Writer // debug/progress output
|
||||
Config *core.CliConfig
|
||||
SDK *lark.Client // All Lark API calls go through SDK
|
||||
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
|
||||
ErrOut io.Writer // debug/progress output
|
||||
Credential *credential.CredentialProvider
|
||||
}
|
||||
|
||||
func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (string, error) {
|
||||
result, err := c.Credential.ResolveToken(ctx, credential.NewTokenSpec(as, c.Config.AppID))
|
||||
if err != nil {
|
||||
var unavailableErr *credential.TokenUnavailableError
|
||||
if errors.As(err, &unavailableErr) {
|
||||
return "", output.ErrAuth("no access token available for %s", as)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if result.Token == "" {
|
||||
return "", output.ErrAuth("no access token available for %s", as)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// buildApiReq converts a RawApiRequest into SDK types and collects
|
||||
@@ -74,17 +94,15 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if as.IsBot() {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
||||
opts = append(opts, larkcore.WithTenantAccessToken(token))
|
||||
} else {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
|
||||
if c.Config.UserOpenId == "" {
|
||||
return nil, fmt.Errorf("login required: lark-cli auth login (or use --as bot)")
|
||||
}
|
||||
token, err := auth.GetValidAccessToken(c.HTTP, auth.NewUATCallOptions(c.Config, c.ErrOut))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, larkcore.WithUserAccessToken(token))
|
||||
}
|
||||
|
||||
@@ -92,6 +110,146 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
return c.SDK.Do(ctx, req, opts...)
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
|
||||
// a live *http.Response whose Body is an io.Reader for streaming consumption.
|
||||
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
|
||||
// any extra headers from opts are applied automatically.
|
||||
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
|
||||
// closed, and returned as an output.ErrNetwork — callers only receive successful responses.
|
||||
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
|
||||
cfg := buildConfig(opts)
|
||||
|
||||
// Resolve auth
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build URL
|
||||
requestURL, err := buildStreamURL(c.Config.Brand, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build body
|
||||
bodyReader, contentType, err := buildStreamBody(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Timeout — use context deadline only; httpClient.Timeout would cut off
|
||||
// healthy streaming responses because it includes body read time.
|
||||
httpClient := *c.HTTP
|
||||
httpClient.Timeout = 0
|
||||
cancel := func() {}
|
||||
requestCtx := ctx
|
||||
if cfg.timeout > 0 {
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
requestCtx, cancel = context.WithTimeout(ctx, cfg.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request
|
||||
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, output.ErrNetwork("stream request failed: %s", err)
|
||||
}
|
||||
|
||||
// Apply headers from opts
|
||||
for k, vs := range cfg.headers {
|
||||
for _, v := range vs {
|
||||
httpReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
httpReq.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, output.ErrNetwork("stream request failed: %s", err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
// Handle HTTP errors internally
|
||||
if resp.StatusCode >= 400 {
|
||||
defer resp.Body.Close()
|
||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
msg := strings.TrimSpace(string(errBody))
|
||||
if msg != "" {
|
||||
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type cancelOnCloseBody struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (r *cancelOnCloseBody) Close() error {
|
||||
err := r.ReadCloser.Close()
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) {
|
||||
requestURL := req.ApiPath
|
||||
if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") {
|
||||
var pathSegs []string
|
||||
for _, segment := range strings.Split(req.ApiPath, "/") {
|
||||
if !strings.HasPrefix(segment, ":") {
|
||||
pathSegs = append(pathSegs, segment)
|
||||
continue
|
||||
}
|
||||
pathKey := strings.TrimPrefix(segment, ":")
|
||||
pathValue, ok := req.PathParams[pathKey]
|
||||
if !ok {
|
||||
return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath)
|
||||
}
|
||||
if pathValue == "" {
|
||||
return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath)
|
||||
}
|
||||
pathSegs = append(pathSegs, url.PathEscape(pathValue))
|
||||
}
|
||||
endpoints := core.ResolveEndpoints(brand)
|
||||
requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/")
|
||||
}
|
||||
if query := req.QueryParams.Encode(); query != "" {
|
||||
requestURL += "?" + query
|
||||
}
|
||||
return requestURL, nil
|
||||
}
|
||||
|
||||
func buildStreamBody(body interface{}) (io.Reader, string, error) {
|
||||
switch typed := body.(type) {
|
||||
case nil:
|
||||
return nil, "", nil
|
||||
case io.Reader:
|
||||
return typed, "", nil
|
||||
case []byte:
|
||||
return bytes.NewReader(typed), "", nil
|
||||
case string:
|
||||
return strings.NewReader(typed), "text/plain; charset=utf-8", nil
|
||||
default:
|
||||
payload, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err)
|
||||
}
|
||||
return bytes.NewReader(payload), "application/json", nil
|
||||
}
|
||||
}
|
||||
|
||||
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
|
||||
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
|
||||
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and
|
||||
|
||||
@@ -7,13 +7,20 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// roundTripFunc is an adapter to use a function as http.RoundTripper.
|
||||
@@ -31,18 +38,36 @@ func jsonResponse(body interface{}) *http.Response {
|
||||
}
|
||||
}
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
errBuf := &bytes.Buffer{}
|
||||
httpClient := &http.Client{Transport: rt}
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpClient),
|
||||
)
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
return &APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: errBuf,
|
||||
SDK: sdk,
|
||||
ErrOut: errBuf,
|
||||
Credential: testCred,
|
||||
Config: cfg,
|
||||
}, errBuf
|
||||
}
|
||||
|
||||
@@ -87,21 +112,13 @@ func TestMimeToExt(t *testing.T) {
|
||||
|
||||
func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"user_id": "u123",
|
||||
"name": "Test User",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"user_id": "u123",
|
||||
"name": "Test User",
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -138,21 +155,13 @@ func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
|
||||
|
||||
func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -186,23 +195,15 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
|
||||
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
|
||||
apiCalls := 0
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
apiCalls++
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
apiCalls++
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -319,21 +320,13 @@ func TestBuildApiReq_QueryParams(t *testing.T) {
|
||||
|
||||
func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-token", "expire": 7200,
|
||||
}), nil
|
||||
default:
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
@@ -354,3 +347,78 @@ func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: srv.URL,
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("DoStream() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Fatalf("response body = %q, want %q", string(body), "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatal("unexpected HTTP request")
|
||||
return nil, nil
|
||||
}))
|
||||
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/test",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("DoSDKRequest() error = nil, want auth error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "https://example.com/open-apis/test",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("DoStream() error = nil, want auth error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoStream() error = %v, want auth error", err)
|
||||
}
|
||||
}
|
||||
|
||||
46
internal/client/option.go
Normal file
46
internal/client/option.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Option configures API request behavior for DoStream (and future DoSDKRequest).
|
||||
type Option func(*requestConfig)
|
||||
|
||||
type requestConfig struct {
|
||||
timeout time.Duration
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
// WithTimeout sets a request-level timeout that overrides the client default.
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(c *requestConfig) {
|
||||
c.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders adds extra HTTP headers to the request.
|
||||
func WithHeaders(h http.Header) Option {
|
||||
return func(c *requestConfig) {
|
||||
if c.headers == nil {
|
||||
c.headers = make(http.Header)
|
||||
}
|
||||
for k, vs := range h {
|
||||
for _, v := range vs {
|
||||
c.headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfig(opts []Option) requestConfig {
|
||||
var cfg requestConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ── Response routing ──
|
||||
@@ -125,7 +125,7 @@ func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interfa
|
||||
return nil, fmt.Errorf("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, fmt.Errorf("create directory: %s", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,31 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const skipAuthCheckKey = "skipAuthCheck"
|
||||
const annotationSupportedIdentities = "lark:supportedIdentities"
|
||||
|
||||
// SetSupportedIdentities marks which identities a command supports.
|
||||
func SetSupportedIdentities(cmd *cobra.Command, identities []string) {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[annotationSupportedIdentities] = strings.Join(identities, ",")
|
||||
}
|
||||
|
||||
// GetSupportedIdentities returns the declared identities, or nil if not declared.
|
||||
func GetSupportedIdentities(cmd *cobra.Command) []string {
|
||||
v, ok := cmd.Annotations[annotationSupportedIdentities]
|
||||
if !ok || v == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(v, ",")
|
||||
}
|
||||
|
||||
// DisableAuthCheck marks a command (and all its children) as not requiring auth.
|
||||
func DisableAuthCheck(cmd *cobra.Command) {
|
||||
|
||||
@@ -49,3 +49,27 @@ func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) {
|
||||
t.Error("child should have disabled auth check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetSupportedIdentities(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if got := GetSupportedIdentities(cmd); got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
SetSupportedIdentities(cmd, []string{"user", "bot"})
|
||||
got := GetSupportedIdentities(cmd)
|
||||
if len(got) != 2 || got[0] != "user" || got[1] != "bot" {
|
||||
t.Errorf("expected [user bot], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSupportedIdentities_OverwriteExisting(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test", Annotations: map[string]string{"other": "val"}}
|
||||
SetSupportedIdentities(cmd, []string{"bot"})
|
||||
if cmd.Annotations["other"] != "val" {
|
||||
t.Error("existing annotation should be preserved")
|
||||
}
|
||||
got := GetSupportedIdentities(cmd)
|
||||
if len(got) != 1 || got[0] != "bot" {
|
||||
t.Errorf("expected [bot], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,92 +4,102 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ResolveConfig returns Config() for bot identity, or AuthConfig() for user identity.
|
||||
func (f *Factory) ResolveConfig(as core.Identity) (*core.CliConfig, error) {
|
||||
if as.IsBot() {
|
||||
return f.Config()
|
||||
}
|
||||
return f.AuthConfig()
|
||||
}
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
// All function fields are lazily initialized and cached after first call.
|
||||
// In tests, replace any field to stub out external dependencies.
|
||||
type InvocationContext struct {
|
||||
Profile string
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config (credentials, brand, defaultAs)
|
||||
AuthConfig func() (*core.CliConfig, error) // like Config but also requires a logged-in user
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
|
||||
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
||||
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
|
||||
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
|
||||
|
||||
Credential *credential.CredentialProvider
|
||||
}
|
||||
|
||||
// ResolveAs returns the effective identity type.
|
||||
// If the user explicitly passed --as, use that value; otherwise use the configured default.
|
||||
// When the value is "auto" (or unset), auto-detect based on login state.
|
||||
func (f *Factory) ResolveAs(cmd *cobra.Command, flagAs core.Identity) core.Identity {
|
||||
// When the value is "auto" (or unset), auto-detect based on credential hints.
|
||||
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
|
||||
f.IdentityAutoDetected = false
|
||||
|
||||
// Strict mode: force identity regardless of flags or config.
|
||||
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
|
||||
f.ResolvedIdentity = forced
|
||||
return forced
|
||||
}
|
||||
|
||||
if cmd != nil && cmd.Flags().Changed("as") {
|
||||
if flagAs != "auto" {
|
||||
f.ResolvedIdentity = flagAs
|
||||
return flagAs
|
||||
}
|
||||
// --as auto: fall through to auto-detect
|
||||
} else if defaultAs := f.resolveDefaultAs(); defaultAs != "" && defaultAs != "auto" {
|
||||
f.ResolvedIdentity = core.Identity(defaultAs)
|
||||
return f.ResolvedIdentity
|
||||
}
|
||||
// Auto-detect based on login state
|
||||
|
||||
hint := f.resolveIdentityHint(ctx)
|
||||
if cmd == nil || !cmd.Flags().Changed("as") {
|
||||
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {
|
||||
f.ResolvedIdentity = defaultAs
|
||||
return f.ResolvedIdentity
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect based on credential hint
|
||||
f.IdentityAutoDetected = true
|
||||
result := f.autoDetectIdentity()
|
||||
result := autoDetectIdentityFromHint(hint)
|
||||
f.ResolvedIdentity = result
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveDefaultAs returns the configured default identity: env var > config file.
|
||||
func (f *Factory) resolveDefaultAs() string {
|
||||
if v := os.Getenv("LARKSUITE_CLI_DEFAULT_AS"); v != "" {
|
||||
return v
|
||||
}
|
||||
if cfg, err := f.Config(); err == nil {
|
||||
return cfg.DefaultAs
|
||||
func resolveDefaultAsFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
if hint != nil {
|
||||
return hint.DefaultAs
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// autoDetectIdentity checks the login state and returns user if logged in, bot otherwise.
|
||||
func (f *Factory) autoDetectIdentity() core.Identity {
|
||||
cfg, err := f.Config()
|
||||
if err != nil || cfg.UserOpenId == "" {
|
||||
return core.AsBot
|
||||
func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
if hint != nil && hint.AutoAs != "" {
|
||||
return hint.AutoAs
|
||||
}
|
||||
stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
return core.AsBot
|
||||
return core.AsBot
|
||||
}
|
||||
|
||||
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
|
||||
if f.Credential == nil {
|
||||
return nil
|
||||
}
|
||||
if auth.TokenStatus(stored) == "expired" {
|
||||
return core.AsBot
|
||||
hint, err := f.Credential.ResolveIdentityHint(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return core.AsUser
|
||||
return hint
|
||||
}
|
||||
|
||||
// CheckIdentity verifies the resolved identity is in the supported list.
|
||||
@@ -111,6 +121,39 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
|
||||
}
|
||||
|
||||
// ResolveStrictMode returns the effective strict mode by reading
|
||||
// Account.SupportedIdentities from the credential provider chain.
|
||||
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
acct, err := f.Credential.ResolveAccount(ctx)
|
||||
if err != nil || acct == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
||||
switch {
|
||||
case ids.BotOnly():
|
||||
return core.StrictModeBot
|
||||
case ids.UserOnly():
|
||||
return core.StrictModeUser
|
||||
default:
|
||||
return core.StrictModeOff
|
||||
}
|
||||
}
|
||||
|
||||
// CheckStrictMode returns an error if strict mode is active and identity is not allowed.
|
||||
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
||||
mode := f.ResolveStrictMode(ctx)
|
||||
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only).
|
||||
// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead.
|
||||
func (f *Factory) NewAPIClient() (*client.APIClient, error) {
|
||||
@@ -122,8 +165,7 @@ func (f *Factory) NewAPIClient() (*client.APIClient, error) {
|
||||
}
|
||||
|
||||
// NewAPIClientWithConfig creates an APIClient with an explicit config.
|
||||
// Use this when the caller has already resolved the correct user profile
|
||||
// (e.g. via AuthConfig for user-mode commands).
|
||||
// Use this when the caller has already resolved the correct config.
|
||||
func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
@@ -137,5 +179,11 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
|
||||
if f.IOStreams != nil {
|
||||
errOut = f.IOStreams.ErrOut
|
||||
}
|
||||
return &client.APIClient{Config: cfg, SDK: sdk, HTTP: httpClient, ErrOut: errOut}, nil
|
||||
return &client.APIClient{
|
||||
Config: cfg,
|
||||
SDK: sdk,
|
||||
HTTP: httpClient,
|
||||
ErrOut: errOut,
|
||||
Credential: f.Credential,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -14,17 +16,26 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// NewDefault creates a production Factory with cached closures.
|
||||
func NewDefault() *Factory {
|
||||
// Initialization follows a credential-first order:
|
||||
//
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
func NewDefault(inv InvocationContext) *Factory {
|
||||
f := &Factory{
|
||||
Keychain: keychain.Default(),
|
||||
Keychain: keychain.Default(),
|
||||
Invocation: inv,
|
||||
}
|
||||
f.IOStreams = &IOStreams{
|
||||
In: os.Stdin,
|
||||
@@ -32,28 +43,33 @@ func NewDefault() *Factory {
|
||||
ErrOut: os.Stderr,
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
f.Config = cachedConfigFunc(f)
|
||||
f.AuthConfig = cachedAuthConfigFunc(f)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
return f
|
||||
}
|
||||
|
||||
func cachedConfigFunc(f *Factory) func() (*core.CliConfig, error) {
|
||||
return sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
cfg, err := core.RequireConfig(f.Keychain)
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: f.Keychain,
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
return nil, err
|
||||
}
|
||||
cfg := acct.ToCliConfig()
|
||||
registry.InitWithBrand(cfg.Brand)
|
||||
return cfg, nil
|
||||
})
|
||||
}
|
||||
|
||||
func cachedAuthConfigFunc(f *Factory) func() (*core.CliConfig, error) {
|
||||
return sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
return core.RequireAuth(f.Keychain)
|
||||
})
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||
@@ -79,8 +95,8 @@ func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
var transport http.RoundTripper = util.NewBaseTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
transport = wrapWithExtension(transport)
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -92,26 +108,50 @@ func cachedHttpClientFunc() func() (*http.Client, error) {
|
||||
|
||||
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
cfg, err := f.Config()
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := []lark.ClientOptionFunc{
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
// Build SDK transport chain
|
||||
util.WarnIfProxied(os.Stderr)
|
||||
var sdkTransport http.RoundTripper = util.NewBaseTransport()
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: sdkTransport,
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
|
||||
client := lark.NewClient(cfg.AppID, cfg.AppSecret, opts...)
|
||||
return client, nil
|
||||
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.NewBaseTransport()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
providers := extcred.Providers()
|
||||
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
|
||||
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
|
||||
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
|
||||
// happens before the command runs, so any plain-text warning written
|
||||
// to stderr would break the JSON envelope contract that AI agents
|
||||
// depend on. enrichUserInfo failures are already non-fatal (the
|
||||
// provider clears unverified identity fields), so silencing the
|
||||
// warning is safe.
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
}
|
||||
|
||||
366
internal/cmdutil/factory_default_test.go
Normal file
366
internal/cmdutil/factory_default_test.go
Normal file
@@ -0,0 +1,366 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
bot := core.StrictModeBot
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
AppId: "app-target",
|
||||
AppSecret: core.PlainSecret("secret-target"),
|
||||
Brand: core.BrandFeishu,
|
||||
StrictMode: &bot,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "target"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Config() error = %v", err)
|
||||
}
|
||||
if cfg.ProfileName != "target" {
|
||||
t.Fatalf("Config() profile = %q, want %q", cfg.ProfileName, "target")
|
||||
}
|
||||
if cfg.AppID != "app-target" {
|
||||
t.Fatalf("Config() appID = %q, want %q", cfg.AppID, "app-target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := NewDefault(InvocationContext{Profile: "missing"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
|
||||
}
|
||||
_, err := f.Config()
|
||||
if err == nil {
|
||||
t.Fatal("Config() error = nil, want non-nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("Config() error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != `profile "missing" not found` {
|
||||
t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsUser {
|
||||
t.Fatalf("ResolveAs() = %q, want %q", got, core.AsUser)
|
||||
}
|
||||
if f.IdentityAutoDetected {
|
||||
t.Fatal("IdentityAutoDetected = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat-token")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Config() error = %v", err)
|
||||
}
|
||||
|
||||
cfg.AppID = "mutated-cli-config"
|
||||
if acct.AppID != "env-app" {
|
||||
t.Fatalf("credential account mutated via Config(): got %q, want %q", acct.AppID, "env-app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat-token")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct.AppSecret != "" {
|
||||
t.Fatalf("credential account AppSecret = %q, want empty string", acct.AppSecret)
|
||||
}
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
t.Fatalf("Config() error = %v", err)
|
||||
}
|
||||
if cfg.AppSecret != "" {
|
||||
t.Fatalf("Config().AppSecret = %q, want empty string for token-only account", cfg.AppSecret)
|
||||
}
|
||||
if credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
t.Fatalf("Config().AppSecret = %q, want token-only no-secret marker", cfg.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
type stubTransportProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (s *stubTransportProvider) Name() string { return "stub" }
|
||||
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if s.interceptor != nil {
|
||||
return s.interceptor
|
||||
}
|
||||
return &stubTransportImpl{}
|
||||
}
|
||||
|
||||
type stubTransportImpl struct{}
|
||||
|
||||
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
|
||||
// whether PostRoundTrip was called, to verify execution order.
|
||||
type headerCapturingInterceptor struct {
|
||||
preCalled bool
|
||||
postCalled bool
|
||||
}
|
||||
|
||||
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
h.preCalled = true
|
||||
// Set a custom header that should survive (no built-in override)
|
||||
req.Header.Set("X-Custom-Trace", "ext-trace-123")
|
||||
// Try to override a security header — should be overwritten by SecurityHeaderTransport
|
||||
req.Header.Set(HeaderSource, "ext-tampered")
|
||||
return func(resp *http.Response, err error) {
|
||||
h.postCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var receivedHeaders http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ic := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&stubTransportProvider{interceptor: ic})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Use HTTP transport chain (has SecurityHeaderTransport)
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// PreRoundTrip was called
|
||||
if !ic.preCalled {
|
||||
t.Fatal("PreRoundTrip was not called")
|
||||
}
|
||||
// PostRoundTrip (closure) was called
|
||||
if !ic.postCalled {
|
||||
t.Fatal("PostRoundTrip closure was not called")
|
||||
}
|
||||
// Custom header set by extension survives (no built-in override)
|
||||
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
|
||||
}
|
||||
// Security header overridden by extension is restored by SecurityHeaderTransport
|
||||
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
type ctxKeyType string
|
||||
const testKey ctxKeyType = "original"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var ctxValue any
|
||||
|
||||
// Use a custom transport that captures the context value seen by the built-in chain
|
||||
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
ctxValue = req.Context().Value(testKey)
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
})
|
||||
|
||||
// Interceptor that tries to tamper with context
|
||||
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
|
||||
// Try to replace context with a new one
|
||||
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Built-in chain should see original context, not tampered
|
||||
if ctxValue != "original" {
|
||||
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
|
||||
}
|
||||
}
|
||||
|
||||
// interceptorFunc adapts a function to exttransport.Interceptor.
|
||||
type interceptorFunc func(*http.Request) func(*http.Response, error)
|
||||
|
||||
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
}
|
||||
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
transport := buildSDKTransport()
|
||||
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
ua, ok := sec.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
|
||||
@@ -29,7 +30,7 @@ func TestResolveAs_ExplicitAs(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("bot", true)
|
||||
|
||||
got := f.ResolveAs(cmd, core.AsBot)
|
||||
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot, got %s", got)
|
||||
}
|
||||
@@ -45,7 +46,7 @@ func TestResolveAs_ExplicitAsUser(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("user", true)
|
||||
|
||||
got := f.ResolveAs(cmd, core.AsUser)
|
||||
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
|
||||
if got != core.AsUser {
|
||||
t.Errorf("want user, got %s", got)
|
||||
}
|
||||
@@ -60,7 +61,7 @@ func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("auto", true)
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (auto-detect, no login), got %s", got)
|
||||
}
|
||||
@@ -76,7 +77,7 @@ func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
|
||||
})
|
||||
cmd := newCmdWithAsFlag("auto", false) // --as not changed
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (from default-as config), got %s", got)
|
||||
}
|
||||
@@ -85,16 +86,18 @@ func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAs_DefaultAs_FromEnv(t *testing.T) {
|
||||
os.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
|
||||
defer os.Unsetenv("LARKSUITE_CLI_DEFAULT_AS")
|
||||
func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) {
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
if got != core.AsUser {
|
||||
t.Errorf("want user (from env), got %s", got)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (env default-as should not bypass config source), got %s", got)
|
||||
}
|
||||
if !f.IdentityAutoDetected {
|
||||
t.Error("IdentityAutoDetected should be true when no account default-as is set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
|
||||
})
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
|
||||
got := f.ResolveAs(cmd, "auto")
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
// No UserOpenId → auto-detect returns bot
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (auto-detect), got %s", got)
|
||||
@@ -119,7 +122,7 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
|
||||
func TestResolveAs_NilCmd_AutoDetect(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
|
||||
got := f.ResolveAs(nil, "auto")
|
||||
got := f.ResolveAs(context.Background(), nil, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot, got %s", got)
|
||||
}
|
||||
@@ -183,56 +186,6 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolveConfig tests ---
|
||||
|
||||
func TestResolveConfig_Bot(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
|
||||
got, err := f.ResolveConfig(core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.AppID != "a" {
|
||||
t.Errorf("want AppID a, got %s", got.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfig_User(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
|
||||
got, err := f.ResolveConfig(core.AsUser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.AppID != "a" {
|
||||
t.Errorf("want AppID a, got %s", got.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- autoDetectIdentity tests ---
|
||||
|
||||
func TestAutoDetectIdentity_NoUserOpenId(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
got := f.autoDetectIdentity()
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (no UserOpenId), got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoDetectIdentity_ConfigError(t *testing.T) {
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return nil, os.ErrNotExist
|
||||
},
|
||||
}
|
||||
got := f.autoDetectIdentity()
|
||||
if got != core.AsBot {
|
||||
t.Errorf("want bot (config error), got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NewAPIClient / NewAPIClientWithConfig tests ---
|
||||
|
||||
func TestNewAPIClient(t *testing.T) {
|
||||
@@ -280,3 +233,125 @@ func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) {
|
||||
t.Fatal("expected non-nil APIClient")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolveStrictMode tests ---
|
||||
|
||||
func TestResolveStrictMode_Off(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Errorf("expected off, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_BotFromAccount(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
|
||||
t.Errorf("expected bot, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_UserFromAccount(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeUser {
|
||||
t.Errorf("expected user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_BothIdentities(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Errorf("expected off when both supported, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrictMode_NilCredential(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
f.Credential = nil
|
||||
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
|
||||
t.Errorf("expected off with nil credential, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CheckStrictMode tests ---
|
||||
|
||||
func TestCheckStrictMode_BotMode_BotAllowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
|
||||
t.Errorf("bot should be allowed in bot mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
err := f.CheckStrictMode(context.Background(), core.AsUser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for user in bot mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "strict mode") {
|
||||
t.Errorf("error should mention strict mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_UserMode_UserAllowed(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
|
||||
t.Errorf("user should be allowed in user mode, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_UserMode_BotBlocked(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
err := f.CheckStrictMode(context.Background(), core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bot in user mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStrictMode_Off_BothAllowed(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
|
||||
t.Errorf("user should be allowed when off: %v", err)
|
||||
}
|
||||
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
|
||||
t.Errorf("bot should be allowed when off: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolveAs strict mode tests ---
|
||||
|
||||
func TestResolveAs_StrictModeBot_ForceBot(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("bot mode should force bot, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsUser {
|
||||
t.Errorf("user mode should force user, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
|
||||
f, _, _, _ := TestFactory(t, cfg)
|
||||
cmd := newCmdWithAsFlag("auto", false)
|
||||
got := f.ResolveAs(context.Background(), cmd, "auto")
|
||||
if got != core.AsBot {
|
||||
t.Errorf("bot mode should override default-as user, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,16 @@ func ExecutionIdFromContext(ctx context.Context) (string, bool) {
|
||||
// RequestOptionFunc that injects the corresponding headers into SDK requests.
|
||||
// Returns nil if the context has no Shortcut info.
|
||||
func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
|
||||
h := ShortcutHeaders(ctx)
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return larkcore.WithHeaders(h)
|
||||
}
|
||||
|
||||
// ShortcutHeaders extracts Shortcut info from the context and returns
|
||||
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
|
||||
func ShortcutHeaders(ctx context.Context) http.Header {
|
||||
name, ok := ShortcutNameFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -77,5 +87,5 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
|
||||
if eid, ok := ExecutionIdFromContext(ctx); ok {
|
||||
h.Set(HeaderExecutionId, eid)
|
||||
}
|
||||
return larkcore.WithHeaders(h)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
@@ -34,16 +36,14 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
stderrBuf := &bytes.Buffer{}
|
||||
|
||||
mockClient := httpmock.NewClient(reg)
|
||||
// SDK mock client wraps the mock transport with UserAgentTransport
|
||||
// so that User-Agent overrides the SDK default (oapi-sdk-go/v3.x.x).
|
||||
sdkMockClient := &http.Client{
|
||||
Transport: &UserAgentTransport{Base: reg},
|
||||
}
|
||||
|
||||
// Build a test LarkClient using the config
|
||||
var testLarkClient *lark.Client
|
||||
if config != nil && config.AppID != "" {
|
||||
opts := []lark.ClientOptionFunc{
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(sdkMockClient),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
@@ -51,16 +51,40 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
if config.Brand != "" {
|
||||
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand)))
|
||||
}
|
||||
testLarkClient = lark.NewClient(config.AppID, config.AppSecret, opts...)
|
||||
testLarkClient = lark.NewClient(config.AppID, credential.RuntimeAppSecret(config.AppSecret), opts...)
|
||||
}
|
||||
|
||||
testCred := credential.NewCredentialProvider(
|
||||
nil,
|
||||
&testDefaultAcct{config: config},
|
||||
&testDefaultToken{},
|
||||
func() (*http.Client, error) { return mockClient, nil },
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
AuthConfig: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
}
|
||||
return f, stdoutBuf, stderrBuf, reg
|
||||
}
|
||||
|
||||
type testDefaultAcct struct {
|
||||
config *core.CliConfig
|
||||
}
|
||||
|
||||
func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
if a.config == nil {
|
||||
return &credential.Account{}, nil
|
||||
}
|
||||
return credential.AccountFromCliConfig(a.config), nil
|
||||
}
|
||||
|
||||
type testDefaultToken struct{}
|
||||
|
||||
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -100,3 +102,40 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
}
|
||||
return t.base().RoundTrip(req)
|
||||
}
|
||||
|
||||
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
|
||||
// The built-in chain always executes and cannot be skipped or overridden.
|
||||
// The original request context is restored after PreRoundTrip to prevent
|
||||
// extensions from tampering with cancellation, deadlines, or built-in values.
|
||||
type extensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
}
|
||||
|
||||
// RoundTrip calls PreRoundTrip, restores the original context, executes
|
||||
// the built-in chain, then calls the post hook if non-nil.
|
||||
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx) // isolate caller's request before extension mutations
|
||||
post := m.Ext.PreRoundTrip(req)
|
||||
req = req.WithContext(origCtx) // restore original context
|
||||
resp, err := m.Base.RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// wrapWithExtension wraps transport with the registered extension middleware.
|
||||
// If no extension is registered, returns transport unchanged.
|
||||
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return transport
|
||||
}
|
||||
tr := p.ResolveInterceptor(context.Background())
|
||||
if tr == nil {
|
||||
return transport
|
||||
}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Identity represents the caller identity for API requests.
|
||||
@@ -21,6 +24,7 @@ type Identity string
|
||||
const (
|
||||
AsUser Identity = "user"
|
||||
AsBot Identity = "bot"
|
||||
AsAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IsBot returns true if the identity is bot.
|
||||
@@ -34,27 +38,129 @@ type AppUser struct {
|
||||
|
||||
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
|
||||
type AppConfig struct {
|
||||
AppId string `json:"appId"`
|
||||
AppSecret SecretInput `json:"appSecret"`
|
||||
Brand LarkBrand `json:"brand"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
DefaultAs string `json:"defaultAs,omitempty"` // "user" | "bot" | "auto"
|
||||
Users []AppUser `json:"users"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AppId string `json:"appId"`
|
||||
AppSecret SecretInput `json:"appSecret"`
|
||||
Brand LarkBrand `json:"brand"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
|
||||
StrictMode *StrictMode `json:"strictMode,omitempty"`
|
||||
Users []AppUser `json:"users"`
|
||||
}
|
||||
|
||||
// ProfileName returns the display name for this app config.
|
||||
// If Name is set, returns Name; otherwise falls back to AppId.
|
||||
func (a *AppConfig) ProfileName() string {
|
||||
if a.Name != "" {
|
||||
return a.Name
|
||||
}
|
||||
return a.AppId
|
||||
}
|
||||
|
||||
// MultiAppConfig is the multi-app config file format.
|
||||
type MultiAppConfig struct {
|
||||
Apps []AppConfig `json:"apps"`
|
||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||
CurrentApp string `json:"currentApp,omitempty"`
|
||||
PreviousApp string `json:"previousApp,omitempty"`
|
||||
Apps []AppConfig `json:"apps"`
|
||||
}
|
||||
|
||||
// CurrentAppConfig returns the currently active app config.
|
||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||
if profileOverride != "" {
|
||||
if app := m.FindApp(profileOverride); app != nil {
|
||||
return app
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if m.CurrentApp != "" {
|
||||
if app := m.FindApp(m.CurrentApp); app != nil {
|
||||
return app
|
||||
}
|
||||
return nil // explicit currentApp not found; don't silently fallback
|
||||
}
|
||||
if len(m.Apps) > 0 {
|
||||
return &m.Apps[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindApp looks up an app by name, then by appId. Returns nil if not found.
|
||||
// Name match takes priority: if profile A has Name "X" and profile B has AppId "X",
|
||||
// FindApp("X") returns profile A.
|
||||
func (m *MultiAppConfig) FindApp(name string) *AppConfig {
|
||||
// First pass: match by Name
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
||||
return &m.Apps[i]
|
||||
}
|
||||
}
|
||||
// Second pass: match by AppId
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].AppId == name {
|
||||
return &m.Apps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAppIndex looks up an app index by name, then by appId. Returns -1 if not found.
|
||||
func (m *MultiAppConfig) FindAppIndex(name string) int {
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].AppId == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ProfileNames returns all profile names (Name if set, otherwise AppId).
|
||||
func (m *MultiAppConfig) ProfileNames() []string {
|
||||
names := make([]string, len(m.Apps))
|
||||
for i := range m.Apps {
|
||||
names[i] = m.Apps[i].ProfileName()
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ValidateProfileName checks that a profile name is valid.
|
||||
// Rejects empty names, whitespace, control characters, and shell-problematic characters,
|
||||
// but allows Unicode letters (e.g. Chinese, Japanese) for localized profile names.
|
||||
func ValidateProfileName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("profile name cannot be empty")
|
||||
}
|
||||
if utf8.RuneCountInString(name) > 64 {
|
||||
return fmt.Errorf("profile name %q is too long (max 64 characters)", name)
|
||||
}
|
||||
for _, r := range name {
|
||||
if r <= 0x1F || r == 0x7F { // control characters
|
||||
return fmt.Errorf("invalid profile name %q: contains control characters", name)
|
||||
}
|
||||
switch r {
|
||||
case ' ', '\t', '/', '\\', '"', '\'', '`', '$', '#', '!', '&', '|', ';', '(', ')', '{', '}', '[', ']', '<', '>', '?', '*', '~':
|
||||
return fmt.Errorf("invalid profile name %q: contains invalid character %q", name, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CliConfig is the resolved single-app config used by downstream code.
|
||||
type CliConfig struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand LarkBrand
|
||||
DefaultAs string // "user" | "bot" | "auto" | "" (from config file)
|
||||
UserOpenId string
|
||||
UserName string
|
||||
ProfileName string
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand LarkBrand
|
||||
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
|
||||
UserOpenId string
|
||||
UserName string
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
}
|
||||
|
||||
// GetConfigDir returns the config directory path.
|
||||
@@ -64,7 +170,7 @@ func GetConfigDir() string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
@@ -78,7 +184,7 @@ func GetConfigPath() string {
|
||||
|
||||
// LoadMultiAppConfig loads multi-app config from disk.
|
||||
func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
data, err := os.ReadFile(GetConfigPath())
|
||||
data, err := vfs.ReadFile(GetConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,7 +202,7 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
// SaveMultiAppConfig saves config to disk.
|
||||
func SaveMultiAppConfig(config *MultiAppConfig) error {
|
||||
dir := GetConfigDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
@@ -106,13 +212,34 @@ func SaveMultiAppConfig(config *MultiAppConfig) error {
|
||||
return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600)
|
||||
}
|
||||
|
||||
// RequireConfig loads the single-app config. Takes Apps[0] directly.
|
||||
// RequireConfig loads the single-app config using the default profile resolution.
|
||||
func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
return RequireConfigForProfile(kc, "")
|
||||
}
|
||||
|
||||
// RequireConfigForProfile loads the single-app config for a specific profile.
|
||||
// Resolution priority: profileOverride > config.CurrentApp > Apps[0].
|
||||
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
raw, err := LoadMultiAppConfig()
|
||||
if err != nil || raw == nil || len(raw.Apps) == 0 {
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
}
|
||||
app := raw.Apps[0]
|
||||
return ResolveConfigFromMulti(raw, kc, profileOverride)
|
||||
}
|
||||
|
||||
// ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig.
|
||||
// This avoids re-reading the config file when the caller has already loaded it.
|
||||
func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
app := raw.CurrentAppConfig(profileOverride)
|
||||
if app == nil {
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("profile %q not found", profileOverride),
|
||||
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
|
||||
}
|
||||
}
|
||||
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
// If the error comes from the keychain, it will already be wrapped as an ExitError.
|
||||
@@ -124,10 +251,11 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
DefaultAs: app.DefaultAs,
|
||||
ProfileName: app.ProfileName(),
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
if len(app.Users) > 0 {
|
||||
cfg.UserOpenId = app.Users[0].UserOpenId
|
||||
@@ -138,7 +266,12 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
|
||||
// RequireAuth loads config and ensures a user is logged in.
|
||||
func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
cfg, err := RequireConfig(kc)
|
||||
return RequireAuthForProfile(kc, "")
|
||||
}
|
||||
|
||||
// RequireAuthForProfile loads config for a profile and ensures a user is logged in.
|
||||
func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
cfg, err := RequireConfigForProfile(kc, profileOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,3 +280,11 @@ func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// formatProfileNames joins profile names for display.
|
||||
func formatProfileNames(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
58
internal/core/config_strict_mode_test.go
Normal file
58
internal/core/config_strict_mode_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMultiAppConfig_StrictMode_JSON(t *testing.T) {
|
||||
// StrictMode="" should be omitted (omitempty)
|
||||
m := &MultiAppConfig{
|
||||
Apps: []AppConfig{{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}},
|
||||
}
|
||||
data, _ := json.Marshal(m)
|
||||
if string(data) != `{"apps":[{"appId":"a","appSecret":"s","brand":"feishu","users":[]}]}` {
|
||||
t.Errorf("StrictMode empty should be omitted, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode="bot" should be present
|
||||
m.StrictMode = StrictModeBot
|
||||
data, _ = json.Marshal(m)
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
if parsed["strictMode"] != "bot" {
|
||||
t.Errorf("StrictMode=bot should be present, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfig_StrictMode_JSON(t *testing.T) {
|
||||
// StrictMode nil should be omitted
|
||||
app := &AppConfig{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}
|
||||
data, _ := json.Marshal(app)
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
if _, ok := parsed["strictMode"]; ok {
|
||||
t.Errorf("nil StrictMode should be omitted, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode = pointer to "user"
|
||||
v := StrictModeUser
|
||||
app.StrictMode = &v
|
||||
data, _ = json.Marshal(app)
|
||||
json.Unmarshal(data, &parsed)
|
||||
if parsed["strictMode"] != "user" {
|
||||
t.Errorf("StrictMode=user should be present, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode = pointer to "off" (explicit off — should be present, not omitted)
|
||||
voff := StrictModeOff
|
||||
app.StrictMode = &voff
|
||||
data, _ = json.Marshal(app)
|
||||
json.Unmarshal(data, &parsed)
|
||||
if val, ok := parsed["strictMode"]; !ok || val != "off" {
|
||||
t.Errorf("StrictMode=off (explicit) should be present, got: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,27 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_PROFILE", "missing")
|
||||
|
||||
raw := &MultiAppConfig{
|
||||
CurrentApp: "active",
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
Name: "active",
|
||||
AppId: "cli_active",
|
||||
AppSecret: PlainSecret("secret"),
|
||||
Brand: BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfigFromMulti() error = %v", err)
|
||||
}
|
||||
if cfg.ProfileName != "active" {
|
||||
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const secretKeyPrefix = "appsecret:"
|
||||
@@ -25,7 +25,7 @@ func ResolveSecretInput(s SecretInput, kc keychain.KeychainAccess) (string, erro
|
||||
}
|
||||
switch s.Ref.Source {
|
||||
case "file":
|
||||
data, err := os.ReadFile(s.Ref.ID)
|
||||
data, err := vfs.ReadFile(s.Ref.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read secret file %s: %w", s.Ref.ID, err)
|
||||
}
|
||||
|
||||
42
internal/core/strict_mode.go
Normal file
42
internal/core/strict_mode.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
// StrictMode represents the identity restriction policy.
|
||||
type StrictMode string
|
||||
|
||||
const (
|
||||
StrictModeOff StrictMode = "off"
|
||||
StrictModeBot StrictMode = "bot"
|
||||
StrictModeUser StrictMode = "user"
|
||||
)
|
||||
|
||||
// IsActive returns true if strict mode restricts identity.
|
||||
func (m StrictMode) IsActive() bool {
|
||||
return m == StrictModeBot || m == StrictModeUser
|
||||
}
|
||||
|
||||
// AllowsIdentity reports whether the given identity is permitted under this mode.
|
||||
func (m StrictMode) AllowsIdentity(id Identity) bool {
|
||||
switch m {
|
||||
case StrictModeBot:
|
||||
return id.IsBot()
|
||||
case StrictModeUser:
|
||||
return id == AsUser
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ForcedIdentity returns the identity forced by this mode, or "" if not active.
|
||||
func (m StrictMode) ForcedIdentity() Identity {
|
||||
switch m {
|
||||
case StrictModeBot:
|
||||
return AsBot
|
||||
case StrictModeUser:
|
||||
return AsUser
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
62
internal/core/strict_mode_test.go
Normal file
62
internal/core/strict_mode_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStrictMode_IsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
active bool
|
||||
}{
|
||||
{StrictModeOff, false},
|
||||
{"", false},
|
||||
{StrictModeBot, true},
|
||||
{StrictModeUser, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.IsActive(); got != tt.active {
|
||||
t.Errorf("StrictMode(%q).IsActive() = %v, want %v", tt.mode, got, tt.active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_AllowsIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
id Identity
|
||||
ok bool
|
||||
}{
|
||||
{StrictModeOff, AsUser, true},
|
||||
{StrictModeOff, AsBot, true},
|
||||
{StrictModeBot, AsBot, true},
|
||||
{StrictModeBot, AsUser, false},
|
||||
{StrictModeUser, AsUser, true},
|
||||
{StrictModeUser, AsBot, false},
|
||||
{"", AsUser, true},
|
||||
{"", AsBot, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.AllowsIdentity(tt.id); got != tt.ok {
|
||||
t.Errorf("StrictMode(%q).AllowsIdentity(%q) = %v, want %v", tt.mode, tt.id, got, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_ForcedIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
want Identity
|
||||
}{
|
||||
{StrictModeOff, ""},
|
||||
{StrictModeBot, AsBot},
|
||||
{StrictModeUser, AsUser},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.ForcedIdentity(); got != tt.want {
|
||||
t.Errorf("StrictMode(%q).ForcedIdentity() = %q, want %q", tt.mode, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,15 @@ const (
|
||||
BrandLark LarkBrand = "lark"
|
||||
)
|
||||
|
||||
// ParseBrand normalizes a brand string to a LarkBrand constant.
|
||||
// Unrecognized values default to BrandFeishu.
|
||||
func ParseBrand(value string) LarkBrand {
|
||||
if value == "lark" {
|
||||
return BrandLark
|
||||
}
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
|
||||
344
internal/credential/credential_provider.go
Normal file
344
internal/credential/credential_provider.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// DefaultAccountResolver is implemented by the default account provider.
|
||||
type DefaultAccountResolver interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
// DefaultTokenResolver is implemented by the default token provider.
|
||||
type DefaultTokenResolver interface {
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||
}
|
||||
|
||||
var (
|
||||
getStoredToken = auth.GetStoredToken
|
||||
getStoredTokenStatus = auth.TokenStatus
|
||||
)
|
||||
|
||||
type credentialSource interface {
|
||||
Name() string
|
||||
TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error)
|
||||
ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error)
|
||||
}
|
||||
|
||||
type extensionTokenSource struct {
|
||||
provider extcred.Provider
|
||||
}
|
||||
|
||||
func (s extensionTokenSource) Name() string { return s.provider.Name() }
|
||||
|
||||
func (s extensionTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
||||
tok, err := s.provider.ResolveToken(ctx, extcred.TokenSpec{
|
||||
Type: extcred.TokenType(req.Type.String()),
|
||||
AppID: req.AppID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if tok == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if tok.Value == "" {
|
||||
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
||||
}
|
||||
return &TokenResult{Token: tok.Value, Scopes: tok.Scopes}, true, nil
|
||||
}
|
||||
|
||||
func (s extensionTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
||||
hint := &IdentityHint{}
|
||||
if acct == nil {
|
||||
return hint, nil
|
||||
}
|
||||
hint.DefaultAs = acct.DefaultAs
|
||||
// Extension sources verify user identity via enrichUserInfo, so a resolved
|
||||
// UserOpenId is sufficient here; no keychain-backed token status lookup is needed.
|
||||
if acct.UserOpenId != "" {
|
||||
hint.AutoAs = core.AsUser
|
||||
return hint, nil
|
||||
}
|
||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
||||
switch {
|
||||
case ids.UserOnly():
|
||||
hint.AutoAs = core.AsUser
|
||||
case ids.BotOnly():
|
||||
hint.AutoAs = core.AsBot
|
||||
}
|
||||
return hint, nil
|
||||
}
|
||||
|
||||
type defaultTokenSource struct {
|
||||
resolver DefaultTokenResolver
|
||||
}
|
||||
|
||||
func (s defaultTokenSource) Name() string { return "default" }
|
||||
|
||||
func (s defaultTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
||||
if s.resolver == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
result, err := s.resolver.ResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "nil token result"}
|
||||
}
|
||||
if result.Token == "" {
|
||||
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func (s defaultTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
||||
hint := &IdentityHint{}
|
||||
if acct == nil {
|
||||
return hint, nil
|
||||
}
|
||||
hint.DefaultAs = acct.DefaultAs
|
||||
if acct.UserOpenId == "" {
|
||||
hint.AutoAs = core.AsBot
|
||||
return hint, nil
|
||||
}
|
||||
stored := getStoredToken(acct.AppID, acct.UserOpenId)
|
||||
if stored == nil {
|
||||
hint.AutoAs = core.AsBot
|
||||
return hint, nil
|
||||
}
|
||||
if getStoredTokenStatus(stored) == "expired" {
|
||||
hint.AutoAs = core.AsBot
|
||||
return hint, nil
|
||||
}
|
||||
hint.AutoAs = core.AsUser
|
||||
return hint, nil
|
||||
}
|
||||
|
||||
// CredentialProvider is the unified entry point for all credential resolution.
|
||||
type CredentialProvider struct {
|
||||
providers []extcred.Provider
|
||||
defaultAcct DefaultAccountResolver
|
||||
defaultToken DefaultTokenResolver
|
||||
httpClient func() (*http.Client, error)
|
||||
warnOut io.Writer
|
||||
|
||||
accountOnce sync.Once
|
||||
account *Account
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
hintErr error
|
||||
}
|
||||
|
||||
// NewCredentialProvider creates a CredentialProvider.
|
||||
func NewCredentialProvider(providers []extcred.Provider, defaultAcct DefaultAccountResolver, defaultToken DefaultTokenResolver, httpClient func() (*http.Client, error)) *CredentialProvider {
|
||||
return &CredentialProvider{
|
||||
providers: providers,
|
||||
defaultAcct: defaultAcct,
|
||||
defaultToken: defaultToken,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
||||
p.warnOut = warnOut
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolveAccount resolves app credentials. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// Subsequent calls return the cached result regardless of their context.
|
||||
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
|
||||
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
p.accountOnce.Do(func() {
|
||||
p.account, p.accountErr = p.doResolveAccount(ctx)
|
||||
})
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
internal.UserOpenId = ""
|
||||
internal.UserName = ""
|
||||
}
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != nil {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
// If UAT is available, user_info API call is mandatory (security: verify token validity).
|
||||
// If no UAT from extension, falls back to provider-supplied OpenID.
|
||||
func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account, source credentialSource) error {
|
||||
if p.httpClient == nil || source == nil {
|
||||
return nil
|
||||
}
|
||||
tok, found, err := source.TryResolveToken(ctx, TokenSpec{Type: TokenTypeUAT, AppID: acct.AppID})
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
return nil // provider explicitly blocks UAT; skip enrichment
|
||||
}
|
||||
return fmt.Errorf("failed to resolve UAT for user identity verification: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
// Have UAT — must verify and resolve identity
|
||||
hc, err := p.httpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get HTTP client for user_info: %w", err)
|
||||
}
|
||||
info, err := fetchUserInfo(ctx, hc, acct.Brand, tok.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify user identity: %w", err)
|
||||
}
|
||||
acct.UserOpenId = info.OpenID
|
||||
acct.UserName = info.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
||||
if p.selectedSource != nil {
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.selectedSource == nil {
|
||||
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
||||
}
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
|
||||
func resolveTokenFromSource(ctx context.Context, source credentialSource, req TokenSpec) (*TokenResult, error) {
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, &TokenUnavailableError{Source: source.Name(), Type: req.Type}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveIdentityHint resolves default/auto identity guidance from the selected source.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// This matches ResolveAccount and keeps identity decisions stable within one CLI invocation.
|
||||
func (p *CredentialProvider) ResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
||||
p.hintOnce.Do(func() {
|
||||
p.hint, p.hintErr = p.doResolveIdentityHint(ctx)
|
||||
})
|
||||
return p.hint, p.hintErr
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
||||
acct, err := p.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct == nil {
|
||||
return &IdentityHint{}, nil
|
||||
}
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source == nil {
|
||||
return &IdentityHint{}, nil
|
||||
}
|
||||
hint, err := source.ResolveIdentityHint(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hint == nil {
|
||||
return &IdentityHint{}, nil
|
||||
}
|
||||
return hint, nil
|
||||
}
|
||||
|
||||
// ResolveToken resolves an access token.
|
||||
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source != nil {
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
}
|
||||
|
||||
for _, prov := range p.providers {
|
||||
source := extensionTokenSource{provider: prov}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
source = defaultTokenSource{resolver: p.defaultToken}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
return nil, &TokenUnavailableError{Type: req.Type}
|
||||
}
|
||||
|
||||
func convertAccount(ext *extcred.Account) *Account {
|
||||
return &Account{
|
||||
AppID: ext.AppID,
|
||||
AppSecret: ext.AppSecret,
|
||||
Brand: core.LarkBrand(ext.Brand),
|
||||
DefaultAs: core.Identity(ext.DefaultAs),
|
||||
ProfileName: ext.ProfileName,
|
||||
UserOpenId: ext.OpenID,
|
||||
SupportedIdentities: uint8(ext.SupportedIdentities),
|
||||
}
|
||||
}
|
||||
421
internal/credential/credential_provider_test.go
Normal file
421
internal/credential/credential_provider_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type mockExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
token *extcred.Token
|
||||
err error
|
||||
accountErr error
|
||||
tokenErr error
|
||||
}
|
||||
|
||||
func (m *mockExtProvider) Name() string { return m.name }
|
||||
func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
||||
if m.accountErr != nil {
|
||||
return nil, m.accountErr
|
||||
}
|
||||
return m.account, m.err
|
||||
}
|
||||
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
if m.tokenErr != nil {
|
||||
return nil, m.tokenErr
|
||||
}
|
||||
return m.token, m.err
|
||||
}
|
||||
|
||||
type mockDefaultAcct struct {
|
||||
account *Account
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
return m.account, m.err
|
||||
}
|
||||
|
||||
type mockDefaultToken struct {
|
||||
result *TokenResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountFromExtension(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "ext_app", Brand: "lark"}}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{}, nil,
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "ext_app" {
|
||||
t.Errorf("expected ext_app, got %s", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountFallsToDefault(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: "feishu"}},
|
||||
&mockDefaultToken{}, nil,
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "default_app" {
|
||||
t.Errorf("expected default_app, got %s", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountBlockStopsChain(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "blocker", err: &extcred.BlockError{Provider: "blocker", Reason: "denied"}}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{}, nil,
|
||||
)
|
||||
_, err := cp.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var blockErr *extcred.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AccountCached(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "cached"}}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
a1, _ := cp.ResolveAccount(context.Background())
|
||||
a2, _ := cp.ResolveAccount(context.Background())
|
||||
if a1 != a2 {
|
||||
t.Error("expected same pointer (cached)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "default_tok" {
|
||||
t.Errorf("expected default_tok, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
if _, err := cp.ResolveAccount(context.Background()); err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
if result.Token != "default_tok" {
|
||||
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "default_tok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
if _, err := cp.ResolveAccount(context.Background()); err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want unavailable error")
|
||||
}
|
||||
var unavailableErr *TokenUnavailableError
|
||||
if !errors.As(err, &unavailableErr) {
|
||||
t.Fatalf("ResolveToken() error type = %T, want *TokenUnavailableError", err)
|
||||
}
|
||||
if unavailableErr.Source != "env" || unavailableErr.Type != TokenTypeUAT {
|
||||
t.Fatalf("ResolveToken() unavailable error = %+v, want source env and type uat", unavailableErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", err: errors.New("provider exploded")}},
|
||||
nil,
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "provider exploded" {
|
||||
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveIdentityHint_FromExtensionAccount(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
||||
AppID: "ext_app",
|
||||
Brand: "feishu",
|
||||
DefaultAs: extcred.IdentityUser,
|
||||
SupportedIdentities: extcred.SupportsUser,
|
||||
}}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
hint, err := cp.ResolveIdentityHint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
||||
}
|
||||
if hint.DefaultAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() defaultAs = %q, want %q", hint.DefaultAs, core.AsUser)
|
||||
}
|
||||
if hint.AutoAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveIdentityHint_DefaultSourceUsesStoredTokenState(t *testing.T) {
|
||||
origGetStoredToken := getStoredToken
|
||||
origTokenStatus := getStoredTokenStatus
|
||||
t.Cleanup(func() {
|
||||
getStoredToken = origGetStoredToken
|
||||
getStoredTokenStatus = origTokenStatus
|
||||
})
|
||||
|
||||
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
|
||||
if appID != "default_app" || userOpenID != "ou_default" {
|
||||
t.Fatalf("GetStoredToken() args = (%q, %q), want (%q, %q)", appID, userOpenID, "default_app", "ou_default")
|
||||
}
|
||||
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
|
||||
}
|
||||
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
|
||||
return "valid"
|
||||
}
|
||||
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
hint, err := cp.ResolveIdentityHint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
||||
}
|
||||
if hint.AutoAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
|
||||
origGetStoredToken := getStoredToken
|
||||
origTokenStatus := getStoredTokenStatus
|
||||
t.Cleanup(func() {
|
||||
getStoredToken = origGetStoredToken
|
||||
getStoredTokenStatus = origTokenStatus
|
||||
})
|
||||
|
||||
storedCalls := 0
|
||||
statusCalls := 0
|
||||
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
|
||||
storedCalls++
|
||||
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
|
||||
}
|
||||
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
|
||||
statusCalls++
|
||||
return "valid"
|
||||
}
|
||||
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
hint, err := cp.ResolveIdentityHint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
||||
}
|
||||
if hint.AutoAs != core.AsUser {
|
||||
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
||||
}
|
||||
}
|
||||
|
||||
if storedCalls != 1 {
|
||||
t.Fatalf("GetStoredToken() calls = %d, want 1", storedCalls)
|
||||
}
|
||||
if statusCalls != 1 {
|
||||
t.Fatalf("TokenStatus() calls = %d, want 1", statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
nil,
|
||||
&mockDefaultToken{result: &TokenResult{Token: ""}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveAccountDoesNotEnrichWithTokenFromDifferentProvider(t *testing.T) {
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
|
||||
&mockDefaultAcct{account: &Account{
|
||||
AppID: "default_app",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_default",
|
||||
UserName: "Default User",
|
||||
}},
|
||||
&mockDefaultToken{},
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected enrich call")
|
||||
},
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() called %d times, want 0", httpClientCalls)
|
||||
}
|
||||
if acct.UserOpenId != "ou_default" || acct.UserName != "Default User" {
|
||||
t.Fatalf("resolved user = (%q, %q), want (%q, %q)", acct.UserOpenId, acct.UserName, "ou_default", "Default User")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveAccountClearsUnverifiedExtensionIdentityOnTokenError(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
||||
AppID: "ext_app",
|
||||
Brand: "feishu",
|
||||
OpenID: "ou_ext",
|
||||
}, tokenErr: errors.New("token lookup failed")}},
|
||||
nil,
|
||||
nil,
|
||||
func() (*http.Client, error) {
|
||||
t.Fatal("httpClient() should not be called when token lookup fails")
|
||||
return nil, nil
|
||||
},
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct.UserOpenId != "" || acct.UserName != "" {
|
||||
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerificationFails(t *testing.T) {
|
||||
var warnBuf bytes.Buffer
|
||||
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
||||
AppID: "ext_app",
|
||||
Brand: "feishu",
|
||||
OpenID: "ou_ext",
|
||||
}, tokenErr: errors.New("token lookup failed")}},
|
||||
nil,
|
||||
nil,
|
||||
func() (*http.Client, error) {
|
||||
t.Fatal("httpClient() should not be called when token lookup fails")
|
||||
return nil, nil
|
||||
},
|
||||
)
|
||||
cp.SetWarnOut(&warnBuf)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct.UserOpenId != "" || acct.UserName != "" {
|
||||
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
|
||||
}
|
||||
if !strings.Contains(warnBuf.String(), "unable to verify user identity from credential source \"env\"") {
|
||||
t.Fatalf("warning output = %q, want source-specific verification warning", warnBuf.String())
|
||||
}
|
||||
if !strings.Contains(warnBuf.String(), "token lookup failed") {
|
||||
t.Fatalf("warning output = %q, want underlying error", warnBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{err: errors.New("config unavailable")},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "config unavailable" {
|
||||
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
|
||||
}
|
||||
}
|
||||
173
internal/credential/default_provider.go
Normal file
173
internal/credential/default_provider.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
type DefaultAccountProvider struct {
|
||||
keychain keychain.KeychainAccess
|
||||
profile string
|
||||
}
|
||||
|
||||
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
||||
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
||||
}
|
||||
|
||||
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.SupportedIdentities = strictModeToIdentitySupport(multi, p.profile)
|
||||
return AccountFromCliConfig(cfg), nil
|
||||
}
|
||||
|
||||
// strictModeToIdentitySupport maps the config-level strict mode to
|
||||
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
||||
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
||||
app := multi.CurrentAppConfig(profileOverride)
|
||||
var mode core.StrictMode
|
||||
if app != nil && app.StrictMode != nil {
|
||||
mode = *app.StrictMode
|
||||
} else {
|
||||
mode = multi.StrictMode
|
||||
}
|
||||
switch mode {
|
||||
case core.StrictModeBot:
|
||||
return uint8(extcred.SupportsBot)
|
||||
case core.StrictModeUser:
|
||||
return uint8(extcred.SupportsUser)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultTokenProvider resolves UAT/TAT using keychain + direct HTTP calls.
|
||||
// No SDK/LarkClient dependency — eliminates circular dependency with Factory.
|
||||
type DefaultTokenProvider struct {
|
||||
defaultAcct *DefaultAccountProvider
|
||||
httpClient func() (*http.Client, error)
|
||||
errOut io.Writer
|
||||
|
||||
tatOnce sync.Once
|
||||
tatResult *TokenResult
|
||||
tatErr error
|
||||
}
|
||||
|
||||
func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient func() (*http.Client, error), errOut io.Writer) *DefaultTokenProvider {
|
||||
return &DefaultTokenProvider{defaultAcct: defaultAcct, httpClient: httpClient, errOut: errOut}
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
switch req.Type {
|
||||
case TokenTypeUAT:
|
||||
return p.resolveUAT(ctx)
|
||||
case TokenTypeTAT:
|
||||
return p.resolveTAT(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
|
||||
// may be refreshed between calls and GetValidAccessToken handles its own caching.
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := auth.GetValidAccessToken(httpClient, auth.NewUATCallOptions(acct.ToCliConfig(), p.errOut))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := auth.GetStoredToken(acct.AppID, acct.UserOpenId)
|
||||
scopes := ""
|
||||
if stored != nil {
|
||||
scopes = stored.Scope
|
||||
}
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
})
|
||||
return p.tatResult, p.tatErr
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": acct.AppID,
|
||||
"app_secret": acct.AppSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg)
|
||||
}
|
||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
||||
}
|
||||
14
internal/credential/default_provider_test.go
Normal file
14
internal/credential/default_provider_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||
// Just verify the type implements DefaultTokenResolver
|
||||
var _ DefaultTokenResolver = &DefaultTokenProvider{}
|
||||
}
|
||||
|
||||
func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
113
internal/credential/integration_test.go
Normal file
113
internal/credential/integration_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
type noopKC struct{}
|
||||
|
||||
func (n *noopKC) Get(service, account string) (string, error) { return "", nil }
|
||||
func (n *noopKC) Set(service, account, value string) error { return nil }
|
||||
func (n *noopKC) Remove(service, account string) error { return nil }
|
||||
|
||||
func TestFullChain_EnvWins(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env_app")
|
||||
t.Setenv(envvars.CliAppSecret, "env_secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "env_uat")
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "env_app" {
|
||||
t.Errorf("expected env_app, got %s", acct.AppID)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "env_app",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "env_uat" {
|
||||
t.Errorf("expected env_uat, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_Fallthrough(t *testing.T) {
|
||||
// env provider returns nil (no env vars set), falls through to default token
|
||||
ep := &envprovider.Provider{}
|
||||
mock := &mockDefaultTokenProvider{token: "mock_tok", scopes: "drive:read"}
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
nil, mock, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "app1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "mock_tok" || result.Scopes != "drive:read" {
|
||||
t.Errorf("unexpected: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type mockDefaultTokenProvider struct {
|
||||
token string
|
||||
scopes string
|
||||
}
|
||||
|
||||
func (m *mockDefaultTokenProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: m.token, Scopes: m.scopes}, nil
|
||||
}
|
||||
|
||||
func TestFullChain_ConfigStrictMode(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
botMode := core.StrictModeBot
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "cfg_app",
|
||||
AppSecret: core.PlainSecret("cfg_secret"),
|
||||
Brand: core.BrandLark,
|
||||
StrictMode: &botMode,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ep := &envprovider.Provider{}
|
||||
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
defaultAcct, nil, nil,
|
||||
)
|
||||
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != uint8(extcred.SupportsBot) {
|
||||
t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
172
internal/credential/types.go
Normal file
172
internal/credential/types.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Account is the credential-layer view of the active runtime account.
|
||||
// It intentionally mirrors only the resolved fields needed by runtime auth
|
||||
// and identity selection, without exposing core.CliConfig as a dependency.
|
||||
type Account struct {
|
||||
ProfileName string
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand core.LarkBrand
|
||||
DefaultAs core.Identity
|
||||
UserOpenId string
|
||||
UserName string
|
||||
SupportedIdentities uint8
|
||||
}
|
||||
|
||||
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
|
||||
|
||||
// HasRealAppSecret reports whether secret is an actual app secret rather than
|
||||
// an empty/token-only marker or the internal runtime placeholder.
|
||||
func HasRealAppSecret(secret string) bool {
|
||||
return secret != "" && secret != runtimePlaceholderAppSecret
|
||||
}
|
||||
|
||||
// RuntimeAppSecret returns the SDK-compatible app secret used at runtime.
|
||||
// Token-only sources intentionally have no real secret; this helper injects a
|
||||
// private placeholder so downstream SDK validation can proceed while callers
|
||||
// still distinguish real secrets with HasRealAppSecret.
|
||||
func RuntimeAppSecret(secret string) string {
|
||||
if HasRealAppSecret(secret) {
|
||||
return secret
|
||||
}
|
||||
return runtimePlaceholderAppSecret
|
||||
}
|
||||
|
||||
func normalizeAccountAppSecret(secret string) string {
|
||||
if HasRealAppSecret(secret) {
|
||||
return secret
|
||||
}
|
||||
return extcred.NoAppSecret
|
||||
}
|
||||
|
||||
// AccountFromCliConfig copies the resolved config view into a credential.Account.
|
||||
func AccountFromCliConfig(cfg *core.CliConfig) *Account {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return &Account{
|
||||
ProfileName: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
AppSecret: normalizeAccountAppSecret(cfg.AppSecret),
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: cfg.DefaultAs,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
UserName: cfg.UserName,
|
||||
SupportedIdentities: cfg.SupportedIdentities,
|
||||
}
|
||||
}
|
||||
|
||||
// ToCliConfig copies the credential-layer account into the downstream config shape.
|
||||
func (a *Account) ToCliConfig() *core.CliConfig {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return &core.CliConfig{
|
||||
ProfileName: a.ProfileName,
|
||||
AppID: a.AppID,
|
||||
AppSecret: normalizeAccountAppSecret(a.AppSecret),
|
||||
Brand: a.Brand,
|
||||
DefaultAs: a.DefaultAs,
|
||||
UserOpenId: a.UserOpenId,
|
||||
UserName: a.UserName,
|
||||
SupportedIdentities: a.SupportedIdentities,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountProvider resolves app credentials.
|
||||
// Returns nil, nil to indicate "I don't handle this, try next provider".
|
||||
type AccountProvider interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
// TokenType distinguishes UAT from TAT.
|
||||
// Uses string constants matching extension/credential.TokenType for zero-cost conversion.
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeUAT TokenType = "uat" // User Access Token
|
||||
TokenTypeTAT TokenType = "tat" // Tenant Access Token
|
||||
)
|
||||
|
||||
func (t TokenType) String() string { return string(t) }
|
||||
|
||||
// ParseTokenType converts a string to TokenType.
|
||||
func ParseTokenType(s string) (TokenType, bool) {
|
||||
switch strings.ToLower(s) {
|
||||
case "uat":
|
||||
return TokenTypeUAT, true
|
||||
case "tat":
|
||||
return TokenTypeTAT, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// TokenSpec is the input to TokenProvider.ResolveToken.
|
||||
type TokenSpec struct {
|
||||
Type TokenType
|
||||
AppID string // identifies which app (multi-account); not sensitive
|
||||
}
|
||||
|
||||
// TokenResult is the output of TokenProvider.ResolveToken.
|
||||
type TokenResult struct {
|
||||
Token string
|
||||
Scopes string // optional, space-separated; empty = skip scope pre-check
|
||||
}
|
||||
|
||||
// IdentityHint is credential-layer guidance for resolving the effective identity.
|
||||
type IdentityHint struct {
|
||||
DefaultAs core.Identity
|
||||
AutoAs core.Identity
|
||||
}
|
||||
|
||||
// TokenUnavailableError reports that no usable token was available.
|
||||
type TokenUnavailableError struct {
|
||||
Source string
|
||||
Type TokenType
|
||||
}
|
||||
|
||||
func (e *TokenUnavailableError) Error() string {
|
||||
if e.Source != "" {
|
||||
return fmt.Sprintf("no %s available from credential source %q", e.Type, e.Source)
|
||||
}
|
||||
return fmt.Sprintf("no credential provider returned a token for %s", e.Type)
|
||||
}
|
||||
|
||||
// MalformedTokenResultError reports that a source returned an invalid token payload.
|
||||
type MalformedTokenResultError struct {
|
||||
Source string
|
||||
Type TokenType
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *MalformedTokenResultError) Error() string {
|
||||
return fmt.Sprintf("credential source %q returned malformed %s token: %s", e.Source, e.Type, e.Reason)
|
||||
}
|
||||
|
||||
// TokenProvider resolves a runtime access token.
|
||||
// Top-level resolvers should return a non-nil token or an error.
|
||||
// Chain participants may use nil, nil internally to indicate "try next source".
|
||||
type TokenProvider interface {
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||
}
|
||||
|
||||
// NewTokenSpec returns a TokenSpec with the token type automatically
|
||||
// selected based on identity: TAT for bot, UAT for user.
|
||||
func NewTokenSpec(identity core.Identity, appID string) TokenSpec {
|
||||
t := TokenTypeUAT
|
||||
if identity.IsBot() {
|
||||
t = TokenTypeTAT
|
||||
}
|
||||
return TokenSpec{Type: t, AppID: appID}
|
||||
}
|
||||
121
internal/credential/types_test.go
Normal file
121
internal/credential/types_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestTokenTypeString(t *testing.T) {
|
||||
tests := []struct {
|
||||
tt TokenType
|
||||
want string
|
||||
}{
|
||||
{TokenTypeUAT, "uat"},
|
||||
{TokenTypeTAT, "tat"},
|
||||
{TokenType("custom"), "custom"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := tc.tt.String(); got != tc.want {
|
||||
t.Errorf("TokenType(%q).String() = %q, want %q", tc.tt, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenType(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
want TokenType
|
||||
ok bool
|
||||
}{
|
||||
{"uat", TokenTypeUAT, true},
|
||||
{"tat", TokenTypeTAT, true},
|
||||
{"UAT", TokenTypeUAT, true},
|
||||
{"bad", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := ParseTokenType(tc.s)
|
||||
if ok != tc.ok || (ok && got != tc.want) {
|
||||
t.Errorf("ParseTokenType(%q) = (%v, %v), want (%v, %v)", tc.s, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "target",
|
||||
AppID: "app-1",
|
||||
AppSecret: "secret-1",
|
||||
Brand: core.BrandLark,
|
||||
DefaultAs: "user",
|
||||
UserOpenId: "ou_123",
|
||||
UserName: "alice",
|
||||
SupportedIdentities: 3,
|
||||
}
|
||||
|
||||
acct := AccountFromCliConfig(cfg)
|
||||
if acct == nil {
|
||||
t.Fatal("AccountFromCliConfig() = nil")
|
||||
}
|
||||
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName {
|
||||
t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg)
|
||||
}
|
||||
|
||||
roundtrip := acct.ToCliConfig()
|
||||
if roundtrip == nil {
|
||||
t.Fatal("ToCliConfig() = nil")
|
||||
}
|
||||
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName {
|
||||
t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg)
|
||||
}
|
||||
|
||||
roundtrip.AppID = "mutated-cli"
|
||||
acct.AppID = "mutated-account"
|
||||
|
||||
if cfg.AppID != "app-1" {
|
||||
t.Fatalf("cfg.AppID = %q, want original value", cfg.AppID)
|
||||
}
|
||||
if roundtrip.AppID != "mutated-cli" {
|
||||
t.Fatalf("roundtrip.AppID = %q, want mutated value", roundtrip.AppID)
|
||||
}
|
||||
if acct.AppID != "mutated-account" {
|
||||
t.Fatalf("acct.AppID = %q, want mutated value", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountToCliConfig_TokenOnlySecretPreservesNoAppSecret(t *testing.T) {
|
||||
acct := &Account{
|
||||
ProfileName: "env",
|
||||
AppID: "app-1",
|
||||
AppSecret: "",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
|
||||
cfg := acct.ToCliConfig()
|
||||
if cfg == nil {
|
||||
t.Fatal("ToCliConfig() = nil")
|
||||
}
|
||||
if cfg.AppSecret != "" {
|
||||
t.Fatalf("AppSecret = %q, want empty string", cfg.AppSecret)
|
||||
}
|
||||
|
||||
roundtrip := AccountFromCliConfig(cfg)
|
||||
if roundtrip == nil {
|
||||
t.Fatal("AccountFromCliConfig() = nil")
|
||||
}
|
||||
if roundtrip.AppSecret != "" {
|
||||
t.Fatalf("roundtrip.AppSecret = %q, want empty string", roundtrip.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAppSecret_TokenOnlyUsesPlaceholder(t *testing.T) {
|
||||
if got := RuntimeAppSecret(""); got == "" {
|
||||
t.Fatal("RuntimeAppSecret(\"\") = empty, want non-empty placeholder")
|
||||
}
|
||||
if HasRealAppSecret(RuntimeAppSecret("")) {
|
||||
t.Fatalf("HasRealAppSecret(RuntimeAppSecret(\"\")) = true, want false")
|
||||
}
|
||||
if got := RuntimeAppSecret("secret-1"); got != "secret-1" {
|
||||
t.Fatalf("RuntimeAppSecret(real) = %q, want %q", got, "secret-1")
|
||||
}
|
||||
}
|
||||
56
internal/credential/user_info.go
Normal file
56
internal/credential/user_info.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type userInfo struct {
|
||||
OpenID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// fetchUserInfo calls /open-apis/authen/v1/user_info with a UAT to get the user's identity.
|
||||
func fetchUserInfo(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, uat string) (*userInfo, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/authen/v1/user_info"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+uat)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("user_info API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("user_info API error: [%d] %s", result.Code, result.Msg)
|
||||
}
|
||||
return &userInfo{OpenID: result.Data.OpenID, Name: result.Data.Name}, nil
|
||||
}
|
||||
14
internal/envvars/envvars.go
Normal file
14
internal/envvars/envvars.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
const (
|
||||
CliAppID = "LARKSUITE_CLI_APP_ID"
|
||||
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
||||
CliBrand = "LARKSUITE_CLI_BRAND"
|
||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
)
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -23,7 +25,7 @@ func authLogDir() string {
|
||||
return filepath.Join(dir, "logs")
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
@@ -39,13 +41,13 @@ func initAuthLogger() {
|
||||
|
||||
dir := authLogDir()
|
||||
now := authResponseLogNow()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
logName := fmt.Sprintf("auth-%s.log", now.Format("2006-01-02"))
|
||||
logPath := filepath.Join(dir, logName)
|
||||
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil {
|
||||
if f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil {
|
||||
authResponseLogger = log.New(f, "", 0)
|
||||
cleanupOldLogs(dir, now)
|
||||
}
|
||||
@@ -131,7 +133,7 @@ func cleanupOldLogs(dir string, now time.Time) {
|
||||
}
|
||||
}()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
entries, err := vfs.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -153,7 +155,7 @@ func cleanupOldLogs(dir string, now time.Time) {
|
||||
|
||||
logDate = time.Date(logDate.Year(), logDate.Month(), logDate.Day(), 0, 0, 0, 0, now.Location())
|
||||
if logDate.Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, entry.Name()))
|
||||
_ = vfs.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,34 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// keychainTimeout bounds system keychain access to avoid hanging on blocked prompts.
|
||||
const keychainTimeout = 5 * time.Second
|
||||
|
||||
// masterKeyBytes is the AES-256 key size used to encrypt stored secrets.
|
||||
const masterKeyBytes = 32
|
||||
|
||||
// ivBytes is the nonce size used by AES-GCM.
|
||||
const ivBytes = 12
|
||||
|
||||
// tagBytes is the authentication tag size produced by AES-GCM.
|
||||
const tagBytes = 16
|
||||
|
||||
// fileMasterKeyName is the local fallback master key file name.
|
||||
const fileMasterKeyName = "master.key.file"
|
||||
|
||||
// keyringGet is overridden in tests to simulate system keychain reads.
|
||||
var keyringGet = keyring.Get
|
||||
|
||||
// keyringSet is overridden in tests to simulate system keychain writes.
|
||||
var keyringSet = keyring.Set
|
||||
|
||||
// StorageDir returns the storage directory for a given service name on macOS.
|
||||
func StorageDir(service string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return filepath.Join(".lark-cli", "keychain", service)
|
||||
}
|
||||
@@ -56,7 +73,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
go func() {
|
||||
defer func() { recover() }()
|
||||
|
||||
encodedKey, err := keyring.Get(service, "master.key")
|
||||
encodedKey, err := keyringGet(service, "master.key")
|
||||
if err == nil {
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if decodeErr == nil && len(key) == masterKeyBytes {
|
||||
@@ -87,7 +104,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
encodedKeyStr := base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "master.key", encodedKeyStr)
|
||||
setErr := keyringSet(service, "master.key", encodedKeyStr)
|
||||
if setErr != nil {
|
||||
resCh <- result{key: nil, err: setErr}
|
||||
return
|
||||
@@ -104,6 +121,85 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// getFileMasterKey retrieves the fallback master key from local storage.
|
||||
// If allowCreate is true, it generates and stores a new fallback master key when missing.
|
||||
func getFileMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, fileMasterKeyName)
|
||||
|
||||
key, err := vfs.ReadFile(keyPath)
|
||||
if err == nil && len(key) == masterKeyBytes {
|
||||
return key, nil
|
||||
}
|
||||
if err == nil && len(key) != masterKeyBytes {
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if !allowCreate {
|
||||
return nil, errNotInitialized
|
||||
}
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key = make([]byte, masterKeyBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
for i := 0; i < 3; i++ {
|
||||
existingKey, readErr := vfs.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == masterKeyBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if i < 2 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
writeFailed := true
|
||||
defer func() {
|
||||
if writeFailed {
|
||||
_ = vfs.Remove(keyPath)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(key); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeFailed = false
|
||||
|
||||
canonicalKey, err := vfs.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
existingKey, readErr := vfs.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == masterKeyBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
if readErr == nil && len(existingKey) != masterKeyBytes {
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(canonicalKey) != masterKeyBytes {
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
return canonicalKey, nil
|
||||
}
|
||||
|
||||
// encryptData encrypts data using AES-GCM.
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
@@ -153,13 +249,18 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
// platformGet retrieves a value from the macOS keychain.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
path := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := vfs.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key, ferr := getFileMasterKey(service, false); ferr == nil {
|
||||
if plaintext, derr := decryptData(data, key); derr == nil {
|
||||
return plaintext, nil
|
||||
}
|
||||
}
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -173,12 +274,18 @@ func platformGet(service, account string) (string, error) {
|
||||
|
||||
// platformSet stores a value in the macOS keychain.
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getMasterKey(service, true)
|
||||
key, err := getFileMasterKey(service, false)
|
||||
if err != nil {
|
||||
return err
|
||||
key, err = getMasterKey(service, true)
|
||||
if err != nil {
|
||||
key, err = getFileMasterKey(service, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
encrypted, err := encryptData(data, key)
|
||||
@@ -188,14 +295,14 @@ func platformSet(service, account, data string) error {
|
||||
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpPath)
|
||||
defer vfs.Remove(tmpPath)
|
||||
|
||||
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
||||
if err := vfs.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic rename to prevent file corruption during multi-process writes
|
||||
if err := os.Rename(tmpPath, targetPath); err != nil {
|
||||
if err := vfs.Rename(tmpPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -203,7 +310,7 @@ func platformSet(service, account, data string) error {
|
||||
|
||||
// platformRemove deletes a value from the macOS keychain.
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
err := vfs.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
160
internal/keychain/keychain_darwin_test.go
Normal file
160
internal/keychain/keychain_darwin_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
//go:build darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// TestPlatformSetFallsBackToFileMasterKey verifies writes fall back to a file master key
|
||||
// when the system keychain cannot create the master key.
|
||||
func TestPlatformSetFallsBackToFileMasterKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return "", keyring.ErrNotFound
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
return errors.New("blocked")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
secret := "secret-value"
|
||||
|
||||
if err := platformSet(service, account, secret); err != nil {
|
||||
t.Fatalf("platformSet() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(StorageDir(service), fileMasterKeyName)); err != nil {
|
||||
t.Fatalf("file master key not created: %v", err)
|
||||
}
|
||||
|
||||
got, err := platformGet(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("platformGet() error = %v", err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("platformGet() = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformGetPrefersFileMasterKey verifies reads prefer the file-based master key
|
||||
// before trying the system keychain master key.
|
||||
func TestPlatformGetPrefersFileMasterKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
fileKey := make([]byte, masterKeyBytes)
|
||||
for i := range fileKey {
|
||||
fileKey[i] = byte(i + 1)
|
||||
}
|
||||
keychainKey := make([]byte, masterKeyBytes)
|
||||
for i := range keychainKey {
|
||||
keychainKey[i] = byte(i + 33)
|
||||
}
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(keychainKey), nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
secret := "secret-value"
|
||||
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), fileKey, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(master key) error = %v", err)
|
||||
}
|
||||
encrypted, err := encryptData(secret, fileKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(secret) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := platformGet(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("platformGet() error = %v", err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("platformGet() = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based
|
||||
// master key path once the fallback master key already exists.
|
||||
func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
t.Fatalf("keyringGet should not be called when file master key exists")
|
||||
return "", nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet should not be called when file master key exists")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
secret := "secret-value"
|
||||
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
fileKey := make([]byte, masterKeyBytes)
|
||||
for i := range fileKey {
|
||||
fileKey[i] = byte(i + 1)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), fileKey, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(master key) error = %v", err)
|
||||
}
|
||||
|
||||
if err := platformSet(service, account, secret); err != nil {
|
||||
t.Fatalf("platformSet() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := platformGet(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("platformGet() error = %v", err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("platformGet() = %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const masterKeyBytes = 32
|
||||
@@ -24,7 +25,7 @@ const tagBytes = 16
|
||||
|
||||
// StorageDir returns the directory where encrypted files are stored.
|
||||
func StorageDir(service string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
// If home is missing, fallback to relative path and print warning.
|
||||
// This matches the behavior in internal/core/config.go.
|
||||
@@ -47,7 +48,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, "master.key")
|
||||
|
||||
key, err := os.ReadFile(keyPath)
|
||||
key, err := vfs.ReadFile(keyPath)
|
||||
if err == nil && len(key) == masterKeyBytes {
|
||||
return key, nil
|
||||
}
|
||||
@@ -64,7 +65,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
return nil, errNotInitialized
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -74,16 +75,16 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
tmpKeyPath := filepath.Join(dir, "master.key."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpKeyPath)
|
||||
defer vfs.Remove(tmpKeyPath)
|
||||
|
||||
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
|
||||
if err := vfs.WriteFile(tmpKeyPath, key, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Atomic rename to prevent multi-process master key initialization collision
|
||||
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
|
||||
if err := vfs.Rename(tmpKeyPath, keyPath); err != nil {
|
||||
// If rename fails, another process might have created it. Try reading again.
|
||||
existingKey, readErr := os.ReadFile(keyPath)
|
||||
existingKey, readErr := vfs.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == masterKeyBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
@@ -142,7 +143,7 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
// platformGet retrieves a value from the file system.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
path := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := vfs.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -167,7 +168,7 @@ func platformSet(service, account, data string) error {
|
||||
return err
|
||||
}
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
encrypted, err := encryptData(data, key)
|
||||
@@ -177,14 +178,14 @@ func platformSet(service, account, data string) error {
|
||||
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpPath)
|
||||
defer vfs.Remove(tmpPath)
|
||||
|
||||
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
||||
if err := vfs.WriteFile(tmpPath, encrypted, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic rename to prevent file corruption during multi-process writes
|
||||
if err := os.Rename(tmpPath, targetPath); err != nil {
|
||||
if err := vfs.Rename(tmpPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -192,7 +193,7 @@ func platformSet(service, account, data string) error {
|
||||
|
||||
// platformRemove deletes a value from the file system.
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
err := vfs.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// safeIDChars strips everything except alphanumerics, underscores, hyphens, and dots
|
||||
@@ -39,7 +40,7 @@ func ForSubscribe(appID string) (*LockFile, error) {
|
||||
return nil, fmt.Errorf("app ID must not be empty")
|
||||
}
|
||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create lock dir: %w", err)
|
||||
}
|
||||
safe := safeIDChars.ReplaceAllString(appID, "_")
|
||||
@@ -56,7 +57,7 @@ func (l *LockFile) TryLock() error {
|
||||
if l.file != nil {
|
||||
return fmt.Errorf("lock already held: %s", l.path)
|
||||
}
|
||||
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0600)
|
||||
f, err := vfs.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open lock file: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ensureFreshRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
resetInit()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
Init()
|
||||
}
|
||||
|
||||
func TestLoadScopePriorities(t *testing.T) {
|
||||
priorities := LoadScopePriorities()
|
||||
if len(priorities) == 0 {
|
||||
@@ -123,6 +131,7 @@ func TestComputeMinimumScopeSet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeMinimumScopeSet_Tenant(t *testing.T) {
|
||||
ensureFreshRegistry(t)
|
||||
minSet := ComputeMinimumScopeSet("tenant")
|
||||
if len(minSet) == 0 {
|
||||
if len(ListFromMetaProjects()) == 0 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -109,14 +110,14 @@ func cacheMetaPath() string {
|
||||
// Returns false if the directory cannot be created or written to.
|
||||
func cacheWritable() bool {
|
||||
dir := cacheDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return false
|
||||
}
|
||||
probe := filepath.Join(dir, ".probe")
|
||||
if err := os.WriteFile(probe, []byte{}, 0644); err != nil {
|
||||
if err := vfs.WriteFile(probe, []byte{}, 0644); err != nil {
|
||||
return false
|
||||
}
|
||||
os.Remove(probe)
|
||||
vfs.Remove(probe)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ func cacheWritable() bool {
|
||||
|
||||
func loadCacheMeta() (CacheMeta, error) {
|
||||
var meta CacheMeta
|
||||
data, err := os.ReadFile(cacheMetaPath())
|
||||
data, err := vfs.ReadFile(cacheMetaPath())
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
@@ -135,7 +136,7 @@ func loadCacheMeta() (CacheMeta, error) {
|
||||
}
|
||||
|
||||
func saveCacheMeta(meta CacheMeta) error {
|
||||
if err := os.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(meta)
|
||||
@@ -147,22 +148,22 @@ func saveCacheMeta(meta CacheMeta) error {
|
||||
|
||||
func loadCachedMerged() (*MergedRegistry, error) {
|
||||
path := cachePath()
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// Cache corrupted — remove it so next run triggers a fresh fetch
|
||||
os.Remove(path)
|
||||
os.Remove(cacheMetaPath())
|
||||
vfs.Remove(path)
|
||||
vfs.Remove(cacheMetaPath())
|
||||
return nil, err
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||
if err := os.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil {
|
||||
|
||||
@@ -22,16 +22,39 @@ func resetInit() {
|
||||
initOnce = sync.Once{}
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedProjectList = nil
|
||||
embeddedVersion = ""
|
||||
cachedAllScopes = nil
|
||||
cachedScopePriorities = nil
|
||||
cachedAutoApproveSet = nil
|
||||
cachedPlatformAutoApprove = nil
|
||||
cachedOverrideAutoAllow = nil
|
||||
cachedOverrideAutoDeny = nil
|
||||
refreshOnce = sync.Once{}
|
||||
configuredBrand = ""
|
||||
enableRemoteMeta = true // tests exercise remote logic
|
||||
testMetaURL = ""
|
||||
}
|
||||
|
||||
// hasEmbeddedData returns true if meta_data.json is compiled in.
|
||||
func hasEmbeddedData() bool {
|
||||
return len(embeddedMetaJSON) > 0
|
||||
func TestResetInitClearsEmbeddedVersion(t *testing.T) {
|
||||
embeddedVersion = "stale-version"
|
||||
|
||||
resetInit()
|
||||
|
||||
if embeddedVersion != "" {
|
||||
t.Fatalf("embeddedVersion = %q, want empty", embeddedVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// hasEmbeddedServices returns true if meta_data.json with real services is compiled in.
|
||||
func hasEmbeddedServices() bool {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return false
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(reg.Services) > 0
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
@@ -75,29 +98,13 @@ func testEnvelopeNotModifiedJSON() []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
func TestColdStart_UsesEmbedded(t *testing.T) {
|
||||
if !hasEmbeddedData() {
|
||||
t.Skip("no embedded from_meta data")
|
||||
}
|
||||
resetInit()
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
Init()
|
||||
|
||||
projects := ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
t.Fatal("expected embedded projects, got none")
|
||||
}
|
||||
spec := LoadFromMeta("calendar")
|
||||
if spec == nil {
|
||||
t.Fatal("expected calendar spec from embedded data")
|
||||
}
|
||||
}
|
||||
// TestColdStart_UsesEmbedded was removed because it triggers a data race:
|
||||
// resetInit() writes package globals while a background goroutine from a
|
||||
// previous test's triggerBackgroundRefresh may still be reading them.
|
||||
// The embedded-data path is exercised by other tests (e.g. TestCacheHit).
|
||||
|
||||
func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) {
|
||||
if hasEmbeddedData() {
|
||||
if hasEmbeddedServices() {
|
||||
t.Skip("embedded data present, skipping no-embedded test")
|
||||
}
|
||||
resetInit()
|
||||
@@ -168,7 +175,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
t.Error("expected custom_svc from cache overlay")
|
||||
}
|
||||
// Embedded projects should still be present (if compiled in)
|
||||
if hasEmbeddedData() {
|
||||
if hasEmbeddedServices() {
|
||||
if spec := LoadFromMeta("calendar"); spec == nil {
|
||||
t.Error("expected calendar from embedded data")
|
||||
}
|
||||
@@ -308,6 +315,30 @@ func TestOverlayMergedServices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
resetInit()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
const leakedExisting = "test_isolation_existing_sentinel"
|
||||
const leakedOverlay = "test_isolation_overlay_sentinel"
|
||||
|
||||
mergedServices = map[string]map[string]interface{}{
|
||||
leakedExisting: {"name": leakedExisting, "version": "v1"},
|
||||
}
|
||||
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
|
||||
|
||||
resetInit()
|
||||
Init()
|
||||
|
||||
if spec := LoadFromMeta(leakedExisting); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
|
||||
}
|
||||
if spec := LoadFromMeta(leakedOverlay); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -147,7 +148,7 @@ func statePath() string {
|
||||
}
|
||||
|
||||
func loadState() (*updateState, error) {
|
||||
data, err := os.ReadFile(statePath())
|
||||
data, err := vfs.ReadFile(statePath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -160,7 +161,7 @@ func loadState() (*updateState, error) {
|
||||
|
||||
func saveState(s *updateState) error {
|
||||
dir := core.GetConfigDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(s)
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to path atomically by creating a temp file in the
|
||||
@@ -41,7 +43,7 @@ func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int
|
||||
|
||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
@@ -51,7 +53,7 @@ func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error
|
||||
defer func() {
|
||||
if !success {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
vfs.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -67,7 +69,7 @@ func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
if err := vfs.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
|
||||
@@ -5,9 +5,10 @@ package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SafeOutputPath validates a download/export target path for --output flags.
|
||||
@@ -59,7 +60,7 @@ func safePath(raw, flagName string) (string, error) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func safePath(raw, flagName string) (string, error) {
|
||||
// resolve its symlinks, and re-attach the remaining tail segments.
|
||||
// This prevents TOCTOU attacks where a non-existent intermediate
|
||||
// directory is replaced with a symlink between check and use.
|
||||
if _, err := os.Lstat(resolved); err == nil {
|
||||
if _, err := vfs.Lstat(resolved); err == nil {
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
@@ -98,7 +99,7 @@ func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
for {
|
||||
if _, err := os.Lstat(cur); err == nil {
|
||||
if _, err := vfs.Lstat(cur); err == nil {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
30
internal/vfs/default.go
Normal file
30
internal/vfs/default.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package vfs
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// DefaultFS is the global filesystem instance used by business code.
|
||||
// It points to the real OS implementation; tests may replace it with a mock.
|
||||
var DefaultFS FS = OsFs{}
|
||||
|
||||
// Package-level convenience functions that delegate to DefaultFS.
|
||||
|
||||
func Stat(name string) (fs.FileInfo, error) { return DefaultFS.Stat(name) }
|
||||
func Lstat(name string) (fs.FileInfo, error) { return DefaultFS.Lstat(name) }
|
||||
func Getwd() (string, error) { return DefaultFS.Getwd() }
|
||||
func UserHomeDir() (string, error) { return DefaultFS.UserHomeDir() }
|
||||
func ReadFile(name string) ([]byte, error) { return DefaultFS.ReadFile(name) }
|
||||
func WriteFile(name string, data []byte, perm fs.FileMode) error {
|
||||
return DefaultFS.WriteFile(name, data, perm)
|
||||
}
|
||||
func Open(name string) (*os.File, error) { return DefaultFS.Open(name) }
|
||||
func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
|
||||
return DefaultFS.OpenFile(name, flag, perm)
|
||||
}
|
||||
func CreateTemp(dir, pattern string) (*os.File, error) { return DefaultFS.CreateTemp(dir, pattern) }
|
||||
func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirAll(path, perm) }
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
func Remove(name string) error { return DefaultFS.Remove(name) }
|
||||
func Rename(oldpath, newpath string) error { return DefaultFS.Rename(oldpath, newpath) }
|
||||
29
internal/vfs/fs.go
Normal file
29
internal/vfs/fs.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package vfs
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FS abstracts filesystem operations used across the project.
|
||||
// Implementations must behave identically to the corresponding os package functions.
|
||||
type FS interface {
|
||||
// Query
|
||||
Stat(name string) (fs.FileInfo, error)
|
||||
Lstat(name string) (fs.FileInfo, error)
|
||||
Getwd() (string, error)
|
||||
UserHomeDir() (string, error)
|
||||
|
||||
// Read/Write
|
||||
ReadFile(name string) ([]byte, error)
|
||||
WriteFile(name string, data []byte, perm fs.FileMode) error
|
||||
Open(name string) (*os.File, error)
|
||||
OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)
|
||||
CreateTemp(dir, pattern string) (*os.File, error)
|
||||
|
||||
// Directory/File management
|
||||
MkdirAll(path string, perm fs.FileMode) error
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
Remove(name string) error
|
||||
Rename(oldpath, newpath string) error
|
||||
}
|
||||
32
internal/vfs/osfs.go
Normal file
32
internal/vfs/osfs.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package vfs
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// OsFs delegates every method to the os standard library.
|
||||
type OsFs struct{}
|
||||
|
||||
// Query
|
||||
func (OsFs) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
|
||||
func (OsFs) Lstat(name string) (fs.FileInfo, error) { return os.Lstat(name) }
|
||||
func (OsFs) Getwd() (string, error) { return os.Getwd() }
|
||||
func (OsFs) UserHomeDir() (string, error) { return os.UserHomeDir() }
|
||||
|
||||
// Read/Write
|
||||
func (OsFs) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
|
||||
func (OsFs) WriteFile(name string, data []byte, perm fs.FileMode) error {
|
||||
return os.WriteFile(name, data, perm)
|
||||
}
|
||||
func (OsFs) Open(name string) (*os.File, error) { return os.Open(name) }
|
||||
func (OsFs) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
|
||||
return os.OpenFile(name, flag, perm)
|
||||
}
|
||||
func (OsFs) CreateTemp(dir, pattern string) (*os.File, error) { return os.CreateTemp(dir, pattern) }
|
||||
|
||||
// Directory/File management
|
||||
func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
func (OsFs) Remove(name string) error { return os.Remove(name) }
|
||||
func (OsFs) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) }
|
||||
102
internal/vfs/osfs_test.go
Normal file
102
internal/vfs/osfs_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package vfs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOsFsImplementsFS(t *testing.T) {
|
||||
var _ FS = OsFs{}
|
||||
}
|
||||
|
||||
func TestDefaultFSIsOsFs(t *testing.T) {
|
||||
if _, ok := DefaultFS.(OsFs); !ok {
|
||||
t.Fatal("DefaultFS should be OsFs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOsFsBasicOperations(t *testing.T) {
|
||||
fs := OsFs{}
|
||||
dir := t.TempDir()
|
||||
|
||||
// MkdirAll
|
||||
sub := filepath.Join(dir, "a", "b")
|
||||
if err := fs.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
// WriteFile + ReadFile
|
||||
p := filepath.Join(sub, "test.txt")
|
||||
if err := fs.WriteFile(p, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
data, err := fs.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Fatalf("ReadFile got %q, want %q", data, "hello")
|
||||
}
|
||||
|
||||
// Stat
|
||||
info, err := fs.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if info.Name() != "test.txt" {
|
||||
t.Fatalf("Stat name got %q", info.Name())
|
||||
}
|
||||
|
||||
// Lstat
|
||||
info, err = fs.Lstat(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Lstat: %v", err)
|
||||
}
|
||||
if info.Name() != "test.txt" {
|
||||
t.Fatalf("Lstat name got %q", info.Name())
|
||||
}
|
||||
|
||||
// Rename
|
||||
p2 := filepath.Join(sub, "test2.txt")
|
||||
if err := fs.Rename(p, p2); err != nil {
|
||||
t.Fatalf("Rename: %v", err)
|
||||
}
|
||||
|
||||
// Open
|
||||
f, err := fs.Open(p2)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// OpenFile
|
||||
f, err = fs.OpenFile(p2, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFile: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// CreateTemp
|
||||
f, err = fs.CreateTemp(dir, "tmp-*")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
tmpName := f.Name()
|
||||
f.Close()
|
||||
|
||||
// Remove
|
||||
if err := fs.Remove(tmpName); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
|
||||
// Getwd
|
||||
if _, err := fs.Getwd(); err != nil {
|
||||
t.Fatalf("Getwd: %v", err)
|
||||
}
|
||||
|
||||
// UserHomeDir
|
||||
if _, err := fs.UserHomeDir(); err != nil {
|
||||
t.Fatalf("UserHomeDir: %v", err)
|
||||
}
|
||||
}
|
||||
2
main.go
2
main.go
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
74
scripts/issue-labels/README.md
Normal file
74
scripts/issue-labels/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Issue Labels
|
||||
|
||||
This script searches unlabeled GitHub issues in a repository and applies labels based on heuristics. It is intentionally a one-shot triage pass: once any label is added to an issue, that issue is out of scope for future scheduled runs.
|
||||
|
||||
It only covers two label dimensions:
|
||||
|
||||
- **Type**: `bug` / `enhancement` / `question` / `documentation` / `performance` / `security`
|
||||
- **Domain**: `domain/<service>` (multi-select)
|
||||
|
||||
Related GitHub Actions workflow: `.github/workflows/issue-labels.yml`.
|
||||
|
||||
## Labeling Rules (Current)
|
||||
|
||||
### Type (single-select; write only when matched)
|
||||
|
||||
- Candidates: `bug`, `enhancement`, `question`, `documentation`, `performance`, `security`
|
||||
- Type is written **only when keywords are matched** in title/body. If nothing matches, the script will not add or correct type labels.
|
||||
- By default, the script **does not override existing type labels** to avoid reverting manual triage. Use `--override-type` if you really want the script to enforce the computed type.
|
||||
|
||||
### Domain (multi-select; add-only by default)
|
||||
|
||||
- Managed labels prerequisite: the standard type labels plus `domain/<service>` labels should exist in the repository. If a specific issue needs a managed label that is missing, the script prints a warning, skips that issue, and continues processing the rest.
|
||||
- Label format: `domain/<service>` (e.g. `domain/base`, `domain/im`)
|
||||
- Signals (strong → weak):
|
||||
1) Explicit `domain/<service>` in text
|
||||
2) Command mention: `lark-cli <service>` / `lark cli <service>` (maps `docs` → `doc`)
|
||||
3) Loose title match (careful; excludes English `im` to reduce false positives)
|
||||
4) A small set of conservative keyword heuristics as fallback
|
||||
- By default, the script only adds missing domain labels and never removes existing ones.
|
||||
- If you want stricter domain synchronization, use `--sync-domains`.
|
||||
- Note: the current implementation only removes existing `domain/*` labels when it can positively match at least one domain for the issue, so this is not an exact-sync cleanup mode.
|
||||
|
||||
## Usage
|
||||
|
||||
### GitHub Actions (recommended)
|
||||
|
||||
The workflow supports both:
|
||||
|
||||
- `schedule` (hourly)
|
||||
- `workflow_dispatch` (manual run)
|
||||
|
||||
Scheduled runs write labels by default. Manual runs default to dry-run unless `dry_run=false` is selected.
|
||||
|
||||
Only issues with no labels are scanned. This is intentional: the automation is meant to triage brand-new unlabeled issues once, not to continuously reconcile labels on previously triaged issues.
|
||||
|
||||
### Local dry-run
|
||||
|
||||
Provide a token to avoid anonymous rate limits:
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=$(gh auth token) \
|
||||
node scripts/issue-labels/index.js \
|
||||
--repo larksuite/cli \
|
||||
--max-issues 100 \
|
||||
--dry-run --json
|
||||
```
|
||||
|
||||
### Common Flags
|
||||
|
||||
- `--dry-run`: Do not write labels, only print planned changes
|
||||
- `--json`: JSON output (usually with `--dry-run`)
|
||||
- `--max-issues <n>` / `--max-pages <n>`: Bound unlabeled-issue search size for each run
|
||||
- `--sync-domains`: Stricter `domain/*` sync when at least one managed domain matches (may still leave stale labels if nothing matches)
|
||||
- `--override-type`: Allow overriding existing type labels (use with caution)
|
||||
|
||||
## Regression Samples
|
||||
|
||||
`samples.json` is a regression dataset sampled from real issues in `larksuite/cli` (issue bodies are truncated).
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
node scripts/issue-labels/test.js
|
||||
```
|
||||
891
scripts/issue-labels/index.js
Normal file
891
scripts/issue-labels/index.js
Normal file
@@ -0,0 +1,891 @@
|
||||
/*
|
||||
* Issue labeler for this repository.
|
||||
*
|
||||
* Implements only:
|
||||
* - Type labels (Section 2)
|
||||
* - Domain labels (Section 4)
|
||||
*
|
||||
* Notes:
|
||||
* - Type: only applied when keyword matched. If no match, keep current type labels unchanged.
|
||||
* - Domain: default is add-only; strict sync is optional via --sync-domains.
|
||||
*/
|
||||
|
||||
const API_BASE = "https://api.github.com";
|
||||
|
||||
const TYPE_LABELS = [
|
||||
"bug",
|
||||
"enhancement",
|
||||
"question",
|
||||
"documentation",
|
||||
"performance",
|
||||
"security",
|
||||
];
|
||||
const TYPE_LABEL_SET = new Set(TYPE_LABELS);
|
||||
|
||||
const DOMAIN_SERVICES = [
|
||||
"im",
|
||||
"doc",
|
||||
"drive",
|
||||
"base",
|
||||
"sheets",
|
||||
"calendar",
|
||||
"mail",
|
||||
"task",
|
||||
"vc",
|
||||
"whiteboard",
|
||||
"minutes",
|
||||
"wiki",
|
||||
"event",
|
||||
"auth",
|
||||
"core",
|
||||
];
|
||||
const DOMAIN_ALIASES = ["docs"];
|
||||
const DOMAIN_REGEX_ALTERNATION = [...DOMAIN_SERVICES, ...DOMAIN_ALIASES].join("|");
|
||||
const DOMAIN_LABELS = DOMAIN_SERVICES.map((s) => `domain/${s}`);
|
||||
const DOMAIN_LABEL_SET = new Set(DOMAIN_LABELS);
|
||||
const MANAGED_LABELS = [...TYPE_LABELS, ...DOMAIN_LABELS];
|
||||
|
||||
const TYPE_TIE_BREAKER = [
|
||||
"security",
|
||||
"bug",
|
||||
"performance",
|
||||
"enhancement",
|
||||
"documentation",
|
||||
"question",
|
||||
];
|
||||
|
||||
// More conservative type labeling: prefer "no label" over mislabeling.
|
||||
// - Require a minimum score.
|
||||
// - When the top two candidates are too close, treat as ambiguous and do not label.
|
||||
const TYPE_MIN_SCORE = 2;
|
||||
const TYPE_MIN_MARGIN = 1;
|
||||
|
||||
/**
|
||||
* Pause execution for the provided number of milliseconds.
|
||||
*
|
||||
* @param {number} ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an environment variable and trim surrounding whitespace.
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function envValue(name) {
|
||||
const value = process.env[name];
|
||||
return value ? String(value).trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a required environment variable.
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function envOrFail(name) {
|
||||
const value = envValue(name);
|
||||
if (!value) throw new Error(`missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an integer value with a fallback when parsing fails.
|
||||
*
|
||||
* @param {string|number|undefined|null} value
|
||||
* @param {number} fallback
|
||||
* @returns {number}
|
||||
*/
|
||||
function toInt(value, fallback) {
|
||||
const n = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a boolean-ish value from CLI or environment input.
|
||||
*
|
||||
* @param {string|boolean|undefined|null} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function toBool(value) {
|
||||
if (typeof value === "boolean") return value;
|
||||
const v = String(value || "").trim().toLowerCase();
|
||||
if (!v) return false;
|
||||
if (v === "1" || v === "true" || v === "yes" || v === "y") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize issue title and body into a single lowercase string.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} body
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeText(title, body) {
|
||||
return `${String(title || "")}\n\n${String(body || "")}`.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer candidate domain services from issue title and body text.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} body
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function collectDomainsFromText(title, body) {
|
||||
const normalizedBody = String(body || "")
|
||||
.replace(/(["'])(?:(?=(\\?))\2.)*?\1/gs, (segment) => {
|
||||
return /lark-cli\s+/i.test(segment) && segment.length > 80 ? '""' : segment;
|
||||
});
|
||||
const text = normalizeText(title, normalizedBody);
|
||||
const titleText = String(title || "").toLowerCase();
|
||||
|
||||
const hits = new Set();
|
||||
|
||||
function normalizeService(svc) {
|
||||
const s = String(svc || "").toLowerCase();
|
||||
if (s === "docs") return "doc";
|
||||
return s;
|
||||
}
|
||||
|
||||
// 1) Explicit domain labels in text: domain/<service>
|
||||
const explicit = new RegExp(`\\bdomain\\/(${DOMAIN_REGEX_ALTERNATION})\\b`, "gi");
|
||||
for (const match of text.matchAll(explicit)) {
|
||||
const svc = match && match[1] ? normalizeService(match[1]) : "";
|
||||
if (DOMAIN_SERVICES.includes(svc)) hits.add(svc);
|
||||
}
|
||||
|
||||
// 2) Command mention: lark-cli <service> / lark cli <service>
|
||||
const cmd = new RegExp(`\\blark[-\\s]?cli\\s+(${DOMAIN_REGEX_ALTERNATION})\\b`, "gi");
|
||||
for (const match of text.matchAll(cmd)) {
|
||||
const svc = match && match[1] ? normalizeService(match[1]) : "";
|
||||
if (DOMAIN_SERVICES.includes(svc)) hits.add(svc);
|
||||
}
|
||||
|
||||
// 3) Loose title match: if title contains a standalone service word.
|
||||
// This is intentionally limited to TITLE to reduce false positives.
|
||||
// NOTE: exclude `im` here because it's too common in English text (e.g. "im stuck").
|
||||
const looseServices = DOMAIN_SERVICES.filter((s) => s !== "im");
|
||||
for (const svc of looseServices) {
|
||||
const pattern = svc === "doc" ? "\\bdocs?\\b" : `\\b${svc}\\b`;
|
||||
const re = new RegExp(pattern, "i");
|
||||
if (re.test(titleText)) hits.add(svc);
|
||||
}
|
||||
|
||||
// 4) Keyword heuristics (for users who don't paste the exact command)
|
||||
// Keep this conservative; add keywords only when they are strongly tied to a domain.
|
||||
const keywordMap = {
|
||||
base: [/\bbase\s*\+/i, /\bbase-token\b/i, /open-apis\/bitable\//i, /\brecords?\/(search|list)\b/i, /多维表格/],
|
||||
doc: [/\bdocx\b/i, /\bfeishu document\b/i, /\blark document\b/i, /\bdocument comments?\b/i, /飞书文档|云文档|文档/],
|
||||
drive: [/\bdrive\b/i, /\bfolder token\b/i, /create_folder/i, /drive\/v1\/files/i, /\bdrive\s*\+/i],
|
||||
sheets: [/电子表格/, /\bsheets\s*\+/i],
|
||||
calendar: [/日历/, /\bcalendar\s*\+/i],
|
||||
mail: [/邮件/, /\bmail\s*\+/i],
|
||||
task: [/任务清单/, /飞书任务/, /\btask\s*\+/i],
|
||||
wiki: [/知识库/, /\bwiki\s*\+/i],
|
||||
minutes: [/妙记/, /\bminutes\s*\+/i],
|
||||
vc: [/\bvc\s*\+/i, /飞书会议|视频会议|创建会议/],
|
||||
im: [/消息|群聊|私聊/, /\bim\s*\+/i, /im\/v1/i],
|
||||
auth: [/\bauth\s+(login|status|check|logout)\b/i, /\bkeychain\b/i, /\buser_access_token\b/i, /\buser token\b/i, /\bconsent\b/i, /授权|登录|scope authorization/],
|
||||
core: [/\bpostinstall\b/i, /\bconfig(\.json)?\b/i, /\bconfig\s+(init|show|remove)\b/i, /\bpackage\.json\b/i, /\bscripts\/install\.js\b/i, /\bbun\b/i, /\bskills?\b/i, /\btrae\b/i, /\bprofile\b/i, /\bmulti-account\b/i, /\bprivate deployment\b/i, /\bbinary release\b/i, /\bbinary fails?\b/i, /\bunsupported platform\b/i, /\bebadplatform\b/i, /\bwindows\b.*\bbinary\b|\bbinary\b.*\bwindows\b/i, /\briscv64\b.*\bsupport/i, /私有化|安装脚本|配置文件|多账号|多个应用|多用户|持久化连接|服务器端/],
|
||||
};
|
||||
for (const [svc, patterns] of Object.entries(keywordMap)) {
|
||||
if (!DOMAIN_SERVICES.includes(svc)) continue;
|
||||
for (const re of patterns) {
|
||||
if (re.test(text)) {
|
||||
hits.add(svc);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...hits].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Score each type label against the issue content.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} body
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
function scoreTypeFromText(title, body) {
|
||||
const text = normalizeText(title, body);
|
||||
const titleText = String(title || "").toLowerCase();
|
||||
|
||||
const rules = {
|
||||
bug: [
|
||||
// explicit
|
||||
{ re: /\bbug\b/i, w: 2 },
|
||||
// strong signals (stack traces, errors, crashes)
|
||||
{ re: /\berror\b|\bexception\b|\bcrash\b|\bpanic\b|\bstack\s*trace\b|\bbroken\b|\bfails?\b|\bsigkill\b|\binvalid json\b|\bno stdout\b|\bno stderr\b|\bno output\b|\bsilently fail\w*\b|\bsilently drop\w*\b|\bdiscard\w*\b/i, w: 2 },
|
||||
// Chinese strong/medium signals
|
||||
{ re: /报错|错误|异常|崩溃|闪退|卡死|死机|无输出|静默失败|截断|不生效|无法正确|不能正确|被忽略|丢失|没有给/, w: 2 },
|
||||
// Contextual "cannot/fail" patterns that are usually bugs (avoid labeling based on a bare "无法/失败").
|
||||
{ re: /(无法|失败)(正常)?(.{0,16})?(使用|运行|执行|发送|创建|获取|写入|读取|安装|登录|导出|更新|上传|下载)/, w: 2 },
|
||||
// weak motivation words (do NOT label bug based on these alone)
|
||||
{ re: /无法|失败|没法|不能用|不可用/, w: 1 },
|
||||
],
|
||||
enhancement: [
|
||||
// Chinese/English explicit feature request
|
||||
{ re: /功能请求|需求|\bfeature request\b|\badd support\b|\bplease add\b/i, w: 2 },
|
||||
{ re: /希望支持|建议|新增|支持.*(能力|功能)/, w: 2 },
|
||||
// common Chinese ask forms that usually indicate a request
|
||||
{ re: /能不能支持|能否支持|希望增加|希望新增/, w: 2 },
|
||||
// weak asks
|
||||
{ re: /能否|是否可以|可否|能不能|是否能够|希望能|希望可以|请求/, w: 1 },
|
||||
{ re: /\benhancement\b|\bfeature\b/i, w: 1 },
|
||||
],
|
||||
question: [
|
||||
// comparison/usage questions
|
||||
{ re: /有什么区别|有什么不同|区别是什么|\bwhat is the difference\b/i, w: 2 },
|
||||
{ re: /\bhow to\b|\busage\b|\bis it possible\b|\bdoes it support\b|\bquestion\b/i, w: 2 },
|
||||
// weak question forms
|
||||
{ re: /为什么/, w: 2 },
|
||||
{ re: /请问|是否支持|有没有.*(支持|能力)|怎么(用|配置|接入|做)|如何(使用|配置|接入|做)|可以.*吗|能.*吗|对比/, w: 1 },
|
||||
],
|
||||
documentation: [
|
||||
// Treat docs-related words as weaker unless paired with an explicit docs-fix signal.
|
||||
{ re: /\btypo\b|\bspell(ing)?\b/i, w: 2 },
|
||||
// Avoid generic "文档" (many issues are about the document product); require a docs-fix context.
|
||||
{ re: /拼写|文档(错误|修正|修复|补充|改进)|文档.*(缺失|不完整)|安装说明/, w: 2 },
|
||||
{ re: /\bdocumentation\b|\breadme\b|\bexample\b|\bbest practice\b/i, w: 1 },
|
||||
{ re: /示例/, w: 1 },
|
||||
],
|
||||
performance: [
|
||||
// Avoid generic "slow" causing false positives (many issues mention slow networks).
|
||||
{ re: /\bperformance\b|\bperf\b|\bhang\b|\btimeout\b|\blatency\b|\boom\b|10-100x faster|60\+ seconds/i, w: 2 },
|
||||
{ re: /\bslow\b/i, w: 1 },
|
||||
{ re: /慢|卡住|超时|高内存|响应慢|耗时/, w: 1 },
|
||||
],
|
||||
security: [
|
||||
{ re: /\bvuln\b|\bcve\b|\binjection\b|\btoken exposure\b|\bpermission bypass\b|\bcredential leak\b/i, w: 2 },
|
||||
{ re: /凭据泄漏|注入|权限绕过|token\s*暴露|密钥泄露/, w: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const scores = {};
|
||||
for (const type of TYPE_LABELS) {
|
||||
scores[type] = 0;
|
||||
for (const rule of rules[type] || []) {
|
||||
const re = rule && rule.re;
|
||||
const w = rule && typeof rule.w === "number" ? rule.w : 1;
|
||||
if (re && re.test(text)) scores[type] += w;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\s*\[bug\]/i.test(titleText) || /^\s*bug[:(]/i.test(titleText)) {
|
||||
scores.bug += 3;
|
||||
}
|
||||
if (/^\s*\[(feature|feature request)\]/i.test(titleText) || /\bfeature request\b/i.test(titleText) || /^\s*feat[:(]/i.test(titleText)) {
|
||||
scores.enhancement += 3;
|
||||
}
|
||||
if (/^\s*[【\[]\s*(feature|需求|功能)\s*[】\]]/.test(titleText)) {
|
||||
scores.enhancement += 3;
|
||||
}
|
||||
// Common Chinese feature request prefixes.
|
||||
if (/^\s*(功能请求|需求)[::]/.test(titleText) || /\bfeature\b[::]/i.test(titleText)) {
|
||||
scores.enhancement += 3;
|
||||
}
|
||||
if (/希望支持|能否支持|是否可以/.test(titleText)) {
|
||||
scores.enhancement += 1;
|
||||
}
|
||||
if (/^\s*\[doc\]/i.test(titleText)) {
|
||||
scores.documentation += 4;
|
||||
// If user explicitly marks it as a documentation issue, reduce the chance of mislabeling it as a bug.
|
||||
if (scores.bug > 0) scores.bug = Math.max(0, scores.bug - 3);
|
||||
}
|
||||
if (/^request\b/i.test(titleText)) {
|
||||
scores.enhancement += 3;
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the highest-scoring type using the configured tie breaker.
|
||||
*
|
||||
* @param {Record<string, number>} scores
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function chooseTypeFromScores(scores) {
|
||||
const entries = TYPE_LABELS.map((t) => ({ t, v: (scores && scores[t]) || 0 }))
|
||||
.sort((a, b) => b.v - a.v);
|
||||
const top = entries[0] || { t: null, v: 0 };
|
||||
const second = entries[1] || { t: null, v: 0 };
|
||||
|
||||
if (top.v < TYPE_MIN_SCORE) return null;
|
||||
// Ambiguous: top two are too close.
|
||||
if (top.v - second.v < TYPE_MIN_MARGIN) return null;
|
||||
|
||||
// Preserve deterministic choice when multiple labels have same score (should be rare after margin check).
|
||||
const candidates = TYPE_LABELS.filter((t) => (scores && scores[t]) === top.v);
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
for (const t of TYPE_TIE_BREAKER) {
|
||||
if (candidates.includes(t)) return t;
|
||||
}
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify issue text into one type label and zero or more domains.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} body
|
||||
* @returns {{type: string|null, domains: string[]}}
|
||||
*/
|
||||
function classifyIssueText(title, body) {
|
||||
const scores = scoreTypeFromText(title, body);
|
||||
const type = chooseTypeFromScores(scores);
|
||||
const domains = collectDomainsFromText(title, body);
|
||||
return { type, domains };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a GitHub issue reference for logs.
|
||||
*
|
||||
* @param {string} repo
|
||||
* @param {number} number
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatIssueRef(repo, number) {
|
||||
return `${repo}#${number}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal GitHub REST client for issue labeling operations.
|
||||
*/
|
||||
class GitHubClient {
|
||||
/**
|
||||
* @param {string} token
|
||||
* @param {string} repo
|
||||
*/
|
||||
constructor(token, repo) {
|
||||
this.token = token;
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build standard GitHub API headers.
|
||||
*
|
||||
* @param {boolean} hasBody
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
buildHeaders(hasBody = false) {
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
if (this.token) headers.Authorization = `Bearer ${this.token}`;
|
||||
if (hasBody) headers["Content-Type"] = "application/json";
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a GitHub API request with retry and rate-limit handling.
|
||||
*
|
||||
* @param {string} endpoint
|
||||
* @param {{method?: string, payload?: any, allow404?: boolean, retry?: number}} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async request(endpoint, options = {}) {
|
||||
const {
|
||||
method = "GET",
|
||||
payload,
|
||||
allow404 = false,
|
||||
retry = 5,
|
||||
} = options;
|
||||
|
||||
const hasBody = payload !== undefined;
|
||||
const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`;
|
||||
|
||||
for (let attempt = 0; attempt <= retry; attempt += 1) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: this.buildHeaders(hasBody),
|
||||
body: hasBody ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
|
||||
if (allow404 && response.status === 404) return null;
|
||||
|
||||
const text = await response.text();
|
||||
const remaining = toInt(response.headers.get("x-ratelimit-remaining"), -1);
|
||||
const reset = toInt(response.headers.get("x-ratelimit-reset"), -1);
|
||||
const retryAfter = toInt(response.headers.get("retry-after"), -1);
|
||||
const lower = String(text || "").toLowerCase();
|
||||
const isSecondary = lower.includes("secondary rate") || lower.includes("abuse detection");
|
||||
|
||||
if (response.ok) {
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
const canRetry = attempt < retry;
|
||||
if (!canRetry) {
|
||||
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${text}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Rate-limit handling
|
||||
if (response.status === 429 || isSecondary) {
|
||||
const waitMs = retryAfter > 0
|
||||
? retryAfter * 1000
|
||||
: isSecondary
|
||||
? 60_000
|
||||
: (attempt + 1) * 1000;
|
||||
await sleep(waitMs);
|
||||
continue;
|
||||
}
|
||||
if (response.status === 403 && remaining === 0 && reset > 0) {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const waitMs = Math.max(1, reset - nowSec + 1) * 1000;
|
||||
await sleep(waitMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// transient-ish failures
|
||||
if (response.status >= 500) {
|
||||
await sleep((attempt + 1) * 500);
|
||||
continue;
|
||||
}
|
||||
|
||||
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${text}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`unreachable: request retry loop exceeded for ${method} ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for currently unlabeled issues in the repository.
|
||||
*
|
||||
* This is intentionally a one-shot triage pass: once any label is added, the
|
||||
* issue falls out of scope for future scheduled runs.
|
||||
*
|
||||
* @param {{state?: string, maxPages?: number, maxIssues?: number}} params
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async searchUnlabeledIssues(params) {
|
||||
const issues = [];
|
||||
const {
|
||||
state = "open",
|
||||
maxPages = 10,
|
||||
maxIssues = 300,
|
||||
} = params || {};
|
||||
|
||||
const qualifiers = [
|
||||
`repo:${this.repo}`,
|
||||
"is:issue",
|
||||
"no:label",
|
||||
state === "all" ? "" : `state:${state}`,
|
||||
].filter(Boolean);
|
||||
const q = qualifiers.join(" ");
|
||||
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
const search = new URLSearchParams({
|
||||
q,
|
||||
sort: "updated",
|
||||
order: "desc",
|
||||
per_page: "100",
|
||||
page: String(page),
|
||||
});
|
||||
|
||||
const result = await this.request(`/search/issues?${search}`);
|
||||
const batch = result && Array.isArray(result.items) ? result.items : [];
|
||||
if (batch.length === 0) break;
|
||||
|
||||
for (const item of batch) {
|
||||
issues.push(item);
|
||||
if (issues.length >= maxIssues) break;
|
||||
}
|
||||
|
||||
if (issues.length >= maxIssues) break;
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all repository labels needed for managed-label checks.
|
||||
*
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async listRepositoryLabels() {
|
||||
const labels = [];
|
||||
for (let page = 1; page <= 10; page += 1) {
|
||||
const search = new URLSearchParams({
|
||||
per_page: "100",
|
||||
page: String(page),
|
||||
});
|
||||
const batch = await this.request(`/repos/${this.repo}/labels?${search}`);
|
||||
if (!batch || batch.length === 0) break;
|
||||
labels.push(...batch);
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return managed labels that are not currently present in the repository.
|
||||
*
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async listMissingManagedLabels() {
|
||||
const existing = new Set((await this.listRepositoryLabels()).map((label) => label && label.name));
|
||||
return MANAGED_LABELS.filter((name) => !existing.has(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one or more labels to an issue.
|
||||
*
|
||||
* @param {number} issueNumber
|
||||
* @param {string[]} labels
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async addIssueLabels(issueNumber, labels) {
|
||||
if (!labels || labels.length === 0) return;
|
||||
await this.request(`/repos/${this.repo}/issues/${issueNumber}/labels`, {
|
||||
method: "POST",
|
||||
payload: { labels },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single label from an issue.
|
||||
*
|
||||
* @param {number} issueNumber
|
||||
* @param {string} name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async removeIssueLabel(issueNumber, name) {
|
||||
await this.request(`/repos/${this.repo}/issues/${issueNumber}/labels/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
allow404: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute label mutations for the current issue state.
|
||||
*
|
||||
* @param {{currentLabels: Set<string>|string[], desiredType: string|null, desiredDomainLabels: string[], syncDomains: boolean, overrideType: boolean}} params
|
||||
* @returns {{toAdd: string[], toRemove: string[]}}
|
||||
*/
|
||||
function planIssueLabelChanges(params) {
|
||||
const {
|
||||
currentLabels,
|
||||
desiredType,
|
||||
desiredDomainLabels,
|
||||
syncDomains,
|
||||
overrideType,
|
||||
} = params;
|
||||
|
||||
const current = currentLabels instanceof Set ? currentLabels : new Set(currentLabels || []);
|
||||
const toAdd = new Set();
|
||||
const toRemove = new Set();
|
||||
|
||||
// Type: only apply when desiredType exists.
|
||||
// Safety: by default, do NOT override existing type labels to avoid reverting manual triage.
|
||||
if (desiredType) {
|
||||
const currentType = [...current].filter((l) => TYPE_LABEL_SET.has(l));
|
||||
const shouldApplyType = overrideType || currentType.length === 0;
|
||||
if (shouldApplyType) {
|
||||
if (!current.has(desiredType)) {
|
||||
toAdd.add(desiredType);
|
||||
}
|
||||
for (const t of currentType) {
|
||||
if (t !== desiredType) toRemove.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Domain: add-only by default; strict sync via --sync-domains.
|
||||
const desiredDomains = new Set(desiredDomainLabels || []);
|
||||
for (const d of desiredDomains) {
|
||||
if (!current.has(d)) toAdd.add(d);
|
||||
}
|
||||
|
||||
// Safety: only remove domains when we can positively match at least one domain.
|
||||
if (syncDomains && desiredDomains.size > 0) {
|
||||
for (const d of current) {
|
||||
if (DOMAIN_LABEL_SET.has(d) && !desiredDomains.has(d)) {
|
||||
toRemove.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toAdd: [...toAdd],
|
||||
toRemove: [...toRemove],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLI arguments into runtime options.
|
||||
*
|
||||
* @param {string[]} argv
|
||||
* @returns {{dryRun: boolean, json: boolean, token: string, repo: string, maxPages: number, maxIssues: number, onlyMissing: boolean, syncDomains: boolean, overrideType: boolean, state: string, help?: boolean}}
|
||||
*/
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
dryRun: false,
|
||||
json: false,
|
||||
token: "",
|
||||
repo: "",
|
||||
maxPages: 10,
|
||||
maxIssues: 300,
|
||||
onlyMissing: true,
|
||||
syncDomains: false,
|
||||
overrideType: false,
|
||||
state: "open",
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
|
||||
function readFlagValue(flag) {
|
||||
const value = argv[i + 1];
|
||||
if (value === undefined || String(value).startsWith("-")) {
|
||||
throw new Error(`missing value for ${flag}`);
|
||||
}
|
||||
i += 1;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
for (; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === "--help" || a === "-h") {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
if (a === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (a === "--json") {
|
||||
args.json = true;
|
||||
continue;
|
||||
}
|
||||
if (a === "--token") {
|
||||
args.token = readFlagValue("--token");
|
||||
continue;
|
||||
}
|
||||
if (a === "--repo") {
|
||||
args.repo = readFlagValue("--repo");
|
||||
continue;
|
||||
}
|
||||
if (a === "--max-pages") {
|
||||
args.maxPages = toInt(readFlagValue("--max-pages"), args.maxPages);
|
||||
continue;
|
||||
}
|
||||
if (a === "--max-issues") {
|
||||
args.maxIssues = toInt(readFlagValue("--max-issues"), args.maxIssues);
|
||||
continue;
|
||||
}
|
||||
if (a === "--process-all") {
|
||||
args.onlyMissing = false;
|
||||
continue;
|
||||
}
|
||||
if (a === "--only-missing") {
|
||||
args.onlyMissing = true;
|
||||
continue;
|
||||
}
|
||||
if (a === "--sync-domains") {
|
||||
args.syncDomains = true;
|
||||
continue;
|
||||
}
|
||||
if (a === "--override-type") {
|
||||
args.overrideType = true;
|
||||
continue;
|
||||
}
|
||||
if (a === "--state") {
|
||||
args.state = readFlagValue("--state");
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown argument: ${a}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print CLI help text.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function printHelp() {
|
||||
const msg = `Usage: node scripts/issue-labels/index.js [options]
|
||||
|
||||
Options:
|
||||
--dry-run Do not write labels
|
||||
--json Output JSON (useful with --dry-run)
|
||||
--repo <owner/name> Override GITHUB_REPOSITORY
|
||||
--token <token> Override GITHUB_TOKEN
|
||||
--max-pages <n> Max search result pages to scan (default: 10)
|
||||
--max-issues <n> Max unlabeled issues to process (default: 300)
|
||||
--only-missing Only write when changes are needed (default)
|
||||
--process-all Evaluate all fetched unlabeled issues
|
||||
--sync-domains Strictly sync domain/* (remove stale) when domain matched
|
||||
--override-type Override existing type labels (default: false)
|
||||
--state open|all Issue state to scan (default: open)
|
||||
`;
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the issue labeler CLI.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = args.token || envOrFail("GITHUB_TOKEN");
|
||||
const repo = args.repo || envOrFail("GITHUB_REPOSITORY");
|
||||
const client = new GitHubClient(token, repo);
|
||||
const missingManagedLabels = new Set(await client.listMissingManagedLabels());
|
||||
|
||||
const scanned = await client.searchUnlabeledIssues({
|
||||
state: args.state,
|
||||
maxPages: args.maxPages,
|
||||
maxIssues: args.maxIssues,
|
||||
});
|
||||
|
||||
const results = {
|
||||
repo,
|
||||
dryRun: args.dryRun,
|
||||
query: "unlabeled issues (intentional one-shot scope)",
|
||||
scanned: 0,
|
||||
skippedPR: 0,
|
||||
skippedIssue: 0,
|
||||
updated: 0,
|
||||
changes: [],
|
||||
};
|
||||
|
||||
for (const issue of scanned) {
|
||||
results.scanned += 1;
|
||||
if (issue && issue.pull_request) {
|
||||
results.skippedPR += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentLabels = new Set((issue.labels || []).map((l) => l.name));
|
||||
const { type: desiredType, domains } = classifyIssueText(issue.title, issue.body);
|
||||
const desiredDomainLabels = domains.map((d) => `domain/${d}`);
|
||||
|
||||
const { toAdd, toRemove } = planIssueLabelChanges({
|
||||
currentLabels,
|
||||
desiredType,
|
||||
desiredDomainLabels,
|
||||
syncDomains: args.syncDomains,
|
||||
overrideType: args.overrideType,
|
||||
});
|
||||
|
||||
// If some managed labels do not exist in the repository, drop only those labels
|
||||
// (still apply the rest) instead of skipping the entire issue.
|
||||
const missingForIssue = toAdd.filter((name) => missingManagedLabels.has(name));
|
||||
const effectiveToAdd = missingForIssue.length > 0
|
||||
? toAdd.filter((name) => !missingManagedLabels.has(name))
|
||||
: toAdd;
|
||||
|
||||
if (missingForIssue.length > 0) {
|
||||
const warning = `warning: ${formatIssueRef(repo, issue.number)} missing labels in ${repo}: ${missingForIssue.join(", ")}`;
|
||||
console.warn(warning);
|
||||
}
|
||||
|
||||
const hasChange = effectiveToAdd.length > 0 || toRemove.length > 0;
|
||||
// When --only-missing is enabled (default), we still want JSON output to reflect
|
||||
// issues that were "actionable" only by missing repo labels.
|
||||
if (args.onlyMissing && !hasChange) {
|
||||
if (args.json && missingForIssue.length > 0) {
|
||||
results.skippedIssue += 1;
|
||||
results.changes.push({
|
||||
issue: {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
},
|
||||
desired: {
|
||||
type: desiredType,
|
||||
domains,
|
||||
},
|
||||
change: { toAdd: [], toRemove: [] },
|
||||
skipped: true,
|
||||
reason: "missing_managed_labels",
|
||||
missingLabels: missingForIssue,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = {
|
||||
issue: {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
},
|
||||
desired: {
|
||||
type: desiredType,
|
||||
domains,
|
||||
},
|
||||
change: { toAdd: effectiveToAdd, toRemove },
|
||||
};
|
||||
|
||||
if (missingForIssue.length > 0) {
|
||||
record.missingLabels = missingForIssue;
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
results.changes.push(record);
|
||||
} else {
|
||||
console.log(`[${formatIssueRef(repo, issue.number)}] +${effectiveToAdd.join(", ") || "-"} -${toRemove.join(", ") || "-"}`);
|
||||
}
|
||||
|
||||
if (!args.dryRun) {
|
||||
// Add first to avoid leaving a temporary empty state.
|
||||
if (effectiveToAdd.length > 0) {
|
||||
await client.addIssueLabels(issue.number, effectiveToAdd);
|
||||
}
|
||||
for (const name of toRemove) {
|
||||
await client.removeIssueLabel(issue.number, name);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChange) {
|
||||
results.updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(results));
|
||||
} else {
|
||||
console.log(`done: scanned=${results.scanned} updated=${results.updated} skipped_pr=${results.skippedPR} skipped_issue=${results.skippedIssue}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error(err && err.stack ? err.stack : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyIssueText,
|
||||
collectDomainsFromText,
|
||||
scoreTypeFromText,
|
||||
chooseTypeFromScores,
|
||||
planIssueLabelChanges,
|
||||
TYPE_LABELS,
|
||||
DOMAIN_SERVICES,
|
||||
};
|
||||
617
scripts/issue-labels/samples.json
Normal file
617
scripts/issue-labels/samples.json
Normal file
@@ -0,0 +1,617 @@
|
||||
[
|
||||
{
|
||||
"name": "#234 现在使用必须要创建应用吗?",
|
||||
"title": "现在使用必须要创建应用吗?",
|
||||
"body": "能不能支持使用个人身份呢?",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/234",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#240 查看妙记需要导出权限, 但是却没有申请导出权限的地方",
|
||||
"title": "查看妙记需要导出权限, 但是却没有申请导出权限的地方",
|
||||
"body": "现在整体让 AI 读取妙记的流程, 是有 bug 的.\n\n* 首先, binger-lark-api 读取秒记(比如 https://larksuite.com/minutes/obusrsm7fp44doce3rss4864), 需要有导出权限, AI 才能够读取(这儿有点奇怪, 人能够读取, 但是 AI 需要额外权限才能读取, cli 的重要目标就是人能读的,AI 也要能读)\n* ok, 那我去申请导出权限, 但是秒记右上角的权限申请, 只有\"申请编辑权限\", 无法申请导出权限\n\n建议解决方案:\n\n1. 所有秒记, 默认有阅读权限, 就有导出权限. 即 阅读权限=导出权限. (本来能在 web 页面上读取, 那么手动复制也是一种\"导出\"方式)\n2. 为秒记增加申请权限设置, 增加按钮\"申请导出权限\"\n3. 提供 open-api 能够发起\"申请导出权限\"",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"minutes"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/240",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#244 安装脚本没有给 Trae CN 安装 skills",
|
||||
"title": "安装脚本没有给 Trae CN 安装 skills",
|
||||
"body": "Trae CN 的 skills 位置在:`~/.trae-cn/skills`,希望可以加入检测",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/244",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#201 postinstall: binary download fails silently behind HTTP proxy (Node.js https ign",
|
||||
"title": "postinstall: binary download fails silently behind HTTP proxy (Node.js https ignores https_proxy)",
|
||||
"body": "## Environment\n\n- **OS**: macOS 26.3.1 (Darwin 25.3.0) arm64\n- **lark-cli version**: v1.0.2 (also reproduced on v1.0.0)\n- **Node.js**: v25.8.2\n- **npm**: 11.11.1\n- **Installation method**: npm global install\n- **Network**: Behind HTTP proxy (https_proxy=http://127.0.0.1:7897)\n\n## Description\n\n`npm install -g @larksuite/cli` completes without error, but the `bin/` directory is empty — the Go binary is never downloaded. The postinstall script (`scripts/install.js`) uses Node.js native `https.get()` which does **not** honor the `https_proxy` / `HTTP_PROXY` environment variables. In mainland China (and other regions where GitHub is slow or blocked), this causes the download to fail silently.\n\n`curl` and `wget` in the same shell session work fine because they respect proxy environment variables.\n\n## Steps to Reproduce\n\n1. Set an HTTP proxy:\n ```bash\n export https_proxy=http://127.0.0.1:7897\n ```\n\n2. Install lark-cli:\n ```bash\n npm install -g @larksuite/cli\n ```\n\n3. Check the binary:\n ```bash\n ls $(npm root -g)/@larksuite/cli/bin/\n # Empty directory — no binary\n ```\n\n4. Verify curl works with the same proxy:\n ```bash\n curl -fSL -o /tmp/lark-cli.tar.gz \\\n \"http\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/201",
|
||||
"source_labels": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#239 从命令行创建markdown不太可行",
|
||||
"title": "从命令行创建markdown不太可行",
|
||||
"body": "[与Agent的聊天记录](https://app.lobehub.com/share/t/XeiXXDP1)\n\n它试图运行\n\n``` shell\nlark-cli docs +create --title \"Hello Lobe\" --markdown \"## 欢迎使用飞书文档\\n\\n这是一个由 Lobe 通过飞书 CLI 自动生成的文档。\\n\\n### 功能测试\\n- [x] 文档创建\\n- [ ] 更多功能探索\\n\\n<callout emoji=\\\"💡\\\" background-color=\\\"light-blue\\\">\\n提示:你可以通过飞书 CLI 轻松管理你的云空间文档。\\n</callout>\"\n```\n\n但是,空格会被截断,换行也不能正确换行\n\n这个是在消耗了500K token之后给出的方案(在本地编写了一个js再运行):\n\n``` js\nconst { spawnSync } = require('child_process');\n\nconst title = \"Lobe 完美传输测试\";\nconst markdown = `## 飞书文档集成测试 ✅\n\n这是一个通过 **Node.js 子进程 (shell: false)** 传输的文档。这次我们完全禁用了 Shell 解释器,由操作系统直接通过命令行数组分发参数。\n\n### 为什么这个有效?\n由于我们禁用了 shell,操作系统会直接把这些带空格的字符串塞给进程,而不需要通过引号转义处理。\n\n<callout emoji=\"🚀\" background-color=\"light-green\">\n成功发送!空格和引号截断问题已彻底解决。\n</callout>\n`;\n\n// 使用全路径直接运行 node 和 cli 脚本,完全避免 shell 解析\nconst nodePath = process.execPath;\nconst cliScript = 'C:\\\\nvm4w\\\\nodejs\\\\node_modules\\\\@larksuite\\\\cli\\\\scripts\\\\run.js';\n\nconst result = spawnSync(nodePath, [cliScript, 'docs', '+create', '--title', title, '--markdown', markdown], {\n encoding: 'utf8',\n shell: false // 关键:禁用 Shell\n});\n\nconsole.log(result.stdout || result.stderr);\n\n```",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"doc"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/239",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#204 CLI的多用户授权支持需求",
|
||||
"title": "CLI的多用户授权支持需求",
|
||||
"body": "当支持多用户的Agent使用CLI时,无法根据openid对不同用户进行授权管理,可能与当前CLI针对个人助理方向有关。但这对企业级多用户Agent也是非常广泛的和有价值需求,希望增加不同openid的授权模式功能。",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/204",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#122 lark-cli api POST silently fails with --as user (exit 1, no output)",
|
||||
"title": "lark-cli api POST silently fails with --as user (exit 1, no output)",
|
||||
"body": "## Description\n\n`lark-cli api POST` silently fails when using `--as user` identity. The command exits with code 1 but produces **no stdout and no stderr output**, making it impossible to debug.\n\n## Environment\n\n- lark-cli version: 1.0.0\n- OS: macOS (Darwin 25.4.0, arm64)\n- Auth: user token valid, scope `im:message.send_as_user` confirmed granted\n\n## Steps to Reproduce\n\n```bash\n# This works fine (GET with user identity):\nlark-cli api GET /open-apis/im/v1/chats --as user\n# Returns JSON response correctly\n\n# This silently fails (POST with user identity):\nlark-cli api POST /open-apis/im/v1/messages \\\n --params '{\"receive_id_type\":\"open_id\"}' \\\n --data '{\"receive_id\":\"ou_xxx\",\"msg_type\":\"text\",\"content\":\"{\\\"text\\\":\\\"hello\\\"}\"}' \\\n --as user\n# Exit code 1, NO stdout, NO stderr\n```\n\n## Expected Behavior\n\nShould either:\n1. Successfully send the message and return the API response, or\n2. Print an error message explaining why it failed\n\n## Actual Behavior\n\n- Exit code: 1\n- stdout: empty\n- stderr: empty\n\n## Additional Context\n\n- `--dry-run` works correctly and shows the expected request structure\n- `lark-cli api GET` with `--as user` works fine\n- `lark-cli api POST` with `--as bot` (via sh\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/122",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#123 base +record-upsert returns 800010701 \"Invalid input\" for any non-empty field",
|
||||
"title": "base +record-upsert returns 800010701 \"Invalid input\" for any non-empty fields (POST with user identity broken)",
|
||||
"body": "## Description \n \n `lark-cli base +record-upsert` returns error `800010701 (Invalid input)` \n whenever the `fields` object is non-empty, even with correct JSON format \n matching the table schema. \n \n ## Environment \n\n - lark-cli version: 1.0.0\n - OS: macOS (Darwin 25.x)\n - Auth: user token valid, all base scopes confirmed granted\n (`base:record:create`, `base:record:update`, etc.) \n \n ## Steps to Reproduce \n \n 1. `lark-cli base +base-create --name \"Test\"` → success \n 2. `lark-cli base +table-create --base-token <token> --name \"Test\"` → success\n (creates table with auto ID field) \n 3. `lark-cli base +field-cre\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"base"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/123",
|
||||
"source_labels": [
|
||||
"domain/base"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#200 希望支持多维表格设置 字段默认值(default_value)和字段描述(description)",
|
||||
"title": "希望支持多维表格设置 字段默认值(default_value)和字段描述(description)",
|
||||
"body": "经过实操验证\n❌ 不支持(需手动在客户端/网页端设置):\n- 字段默认值\n- 字段描述/备注\n\n还有整理字段顺序,现在配错要么删除重新,要么手动调整顺序。",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"base"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/200",
|
||||
"source_labels": [
|
||||
"domain/base"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#89 无法以用户的名义发送消息,只能以bot的名义,还有操作多维表格的时候,插入的格式以及插入的地方,不能够准确插入",
|
||||
"title": "无法以用户的名义发送消息,只能以bot的名义,还有操作多维表格的时候,插入的格式以及插入的地方,不能够准确插入",
|
||||
"body": "",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"base",
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/89",
|
||||
"source_labels": [
|
||||
"domain/base",
|
||||
"domain/im"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#18 为什么代码层面写死了只能以 bot 身份发",
|
||||
"title": "为什么代码层面写死了只能以 bot 身份发",
|
||||
"body": "",
|
||||
"expected_type": "question",
|
||||
"expected_domains": [],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/18",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#225 Riscv64 is not supported",
|
||||
"title": "Riscv64 is not supported",
|
||||
"body": "`npm install -g @larksuite/cli\nnpm error code EBADPLATFORM\nnpm error notsup Unsupported platform for @larksuite/cli@1.0.2: wanted {\"os\":\"darwin,linux,win32\",\"cpu\":\"x64,arm64\"} (current: {\"os\":\"linux\",\"cpu\":\"riscv64\"})\nnpm error notsup Valid os: darwin,linux,win32\nnpm error notsup Actual os: linux\nnpm error notsup Valid cpu: x64,arm64\nnpm error notsup Actual cpu: riscv64`",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/225",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#222 动态选项字段无法通过 API 写入",
|
||||
"title": "动态选项字段无法通过 API 写入",
|
||||
"body": "在使用 lark-cli base +record-upsert 写入飞书多维表格记录时,部分字段无法通过 API 写入,但在浏览器 UI 中可以正常操作。受影响的字段类型在 API 中显示为 not_support 类型,无法写入:\n\n相关日志\n\n # 字段列表返回\n $ lark-cli base +field-list --base-token xxx --table-id xxx\n\n # 返回结果中字段类型为 not_support\n {\n \"field_id\": \"fld9P55tkb\",\n \"field_name\": \"工号\",\n \"type\": \"not_support\"\n }\n\n # 写入记录时\n $ lark-cli base +record-upsert --base-token xxx --table-id xxx --json '{\"工号\": \"F1336477\"}'\n\n # 字段被忽略\n {\n \"ignored_fields\": [\n {\n \"id\": \"fld9P55tkb\",\n \"name\": \"工号\",\n \"reason\": \"UNSUPPORTED: select field with dynamic options cannot be written through OpenAPI.\"\n }\n ]\n }",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"base"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/222",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#215 bug(api): Drive folder raw APIs silently fail with exit code 1 and no output",
|
||||
"title": "bug(api): Drive folder raw APIs silently fail with exit code 1 and no output",
|
||||
"body": "## Summary\n\nWhen using `lark-cli api` to call Drive folder-related raw APIs, the CLI exits with code `1` but prints no stdout and no stderr.\n\nThis makes it impossible to debug or build folder automation on top of Lark CLI raw API.\n\n## Environment\n\n- CLI: `@larksuite/cli`\n- Install method: global npm install\n- OS: macOS arm64\n- Identity: `--as user`\n- Authentication: valid user login\n- Related shortcut commands such as `lark-cli drive +upload` work correctly in the same environment\n\n## Affected raw APIs\n\nThe following official APIs are documented and can be composed correctly by `--dry-run`, but fail silently on real execution.\n\n### List items in folder\n\n```bash\nlark-cli api GET /open-apis/drive/v1/files \\\n --as user \\\n --format json \\\n --params '{\"folder_token\":\"<FOLDER_TOKEN>\",\"page_size\":100}'\n```\n\n### Create folder\n\n```bash\nlark-cli api POST /open-apis/drive/v1/files/create_folder \\\n --as user \\\n --format json \\\n --data '{\"name\":\"analysis-test\",\"folder_token\":\"<FOLDER_TOKEN>\"}'\n```\n\n## Reproduction steps\n\n1. Ensure `lark-cli auth login` is complete and user auth is valid.\n2. Use a folder token that is confirmed accessible under the same account.\n3. Run:\n\n```bash\nlark-cli a\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"drive"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/215",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#175 lark-cli api POST /open-apis/im/v1/messages --as user 静默失败(exit code 1,无输出)",
|
||||
"title": "lark-cli api POST /open-apis/im/v1/messages --as user 静默失败(exit code 1,无输出)",
|
||||
"body": "## 问题描述\n\n使用 `lark-cli api` 以 user 身份调用 `POST /open-apis/im/v1/messages` 发送消息时,命令以 exit code 1 退出,但 **stdout 和 stderr 均无任何输出**,无法定位失败原因。\n\n## 复现步骤\n\n### 1. 环境信息\n\n- lark-cli version: **1.0.0**\n- OS: macOS (Darwin 25.3.0)\n- 已通过 `lark-cli auth login --scope \"im:message\"` 完成用户授权\n- `lark-cli auth status` 显示 token 有效,scope 包含 `im:message`\n- `lark-cli auth check --scope \"im:message\"` 返回 granted\n\n### 2. 复现命令\n\n```bash\nlark-cli api POST /open-apis/im/v1/messages \\\n --as user \\\n --params '{\"receive_id_type\":\"chat_id\"}' \\\n --data '{\"receive_id\":\"oc_xxx\",\"msg_type\":\"text\",\"content\":\"{\\\"text\\\":\\\"test\\\"}\"}'\n```\n\n### 3. 实际结果\n\n命令直接以 exit code 1 退出,stdout 和 stderr **均无输出**:\n\n```bash\n$ lark-cli api POST /open-apis/im/v1/messages --as user \\\n --params '{\"receive_id_type\":\"chat_id\"}' \\\n --data '{\"receive_id\":\"oc_xxx\",\"msg_type\":\"text\",\"content\":\"{\\\"text\\\":\\\"test\\\"}\"}' \\\n 2>&1; echo \"EXIT:$?\"\nEXIT:1\n```\n\n### 4. 期望结果\n\n- 如果 API 支持 user_access_token:应成功发送消息并返回 JSON 响应\n- 如果 API 不支持 user_access_token:应输出明确的错误信息(如 `\"error\": {\"type\": \"unsupported_token\", ...}`),而非静默退出\n\n## 排查过程\n\n| 测试 | 结果 |\n|------|------|\n| `--dry-run` 预览请求 | ✅ 正常输出,请求结构正确 |\n| `lark-cli api GET /open-apis/im/v1/chats --as user` | ✅ 正常\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/175",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#129 期望支持 token 直传",
|
||||
"title": "期望支持 token 直传",
|
||||
"body": "CLI 封装好了命令,不想重复造轮子,能支持多用户并行使用就太好了。",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/129",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#137 需要开通授权的权限不支持部分勾选",
|
||||
"title": "需要开通授权的权限不支持部分勾选",
|
||||
"body": "lark-cli auth login --recommend,执行这一步提取授权链接发送给管理员审批;\n和文档知识库有关的权限太多了,尤其是删除,编辑相关权限,我只想要授权文档查看的权限",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"doc",
|
||||
"wiki"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/137",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#209 [Feature request] The read and post of comments with user info within a feishu d",
|
||||
"title": "[Feature request] The read and post of comments with user info within a feishu document",
|
||||
"body": "As the title stated, I want a cli command to access the comments posted by other users within the feishu document. And it's better to have another command to response comments by agents. \n\nWhen converting the feishu document into markdown format, the comments could be represented by a speical grammar, like [userA]: <> (This is a comment.)",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"doc"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/209",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#208 lark-cli im +messages-send -markdown xxx 的时候能否支持 表格语法?",
|
||||
"title": "lark-cli im +messages-send -markdown xxx 的时候能否支持 表格语法?",
|
||||
"body": "我发送的表格内容似乎都会丢失,如果想发送表格类内容有什么简单的方法嘛, 富文本的json需要转换,没有AI原生输出 markdown丝滑",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/208",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#52 不支持以自己的机器人应用创建会议吗?",
|
||||
"title": "不支持以自己的机器人应用创建会议吗?",
|
||||
"body": "我执行命令后使用的是默认的飞书 CLI应用?",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"vc"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/52",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#17 Feature Request: Add minutes list command to browse all minutes",
|
||||
"title": "Feature Request: Add minutes list command to browse all minutes",
|
||||
"body": "## Summary\n\nCurrently `lark-cli minutes minutes get` only supports fetching a single minute by `minute_token`. There is no way to **list or search** minutes via the CLI.\n\n## Use Case\n\nAs a user, I want to browse my minutes records from the CLI or through an AI agent, without needing to know the exact `minute_token` in advance.\n\nFor example:\n```bash\n# Desired: list my recent minutes\nlark-cli minutes minutes list\n\n# Desired: search minutes by keyword\nlark-cli minutes minutes list --params '{\"keyword\": \"周会\"}'\n```\n\n## Current Behavior\n\n- `lark-cli minutes minutes` only has `get` subcommand\n- `get` requires a `minute_token` parameter, which can only be obtained from the Feishu web UI\n- No list/search command is available\n\n## Expected Behavior\n\nAdd a `list` command (or a `+` shortcut like `+list`) that returns the user's minutes, ideally with pagination and optional filters (date range, keyword, etc.).\n\n## Environment\n\n- lark-cli installed via `npm install -g @larksuite/cli`\n- OS: Linux",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"minutes"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/17",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#76 飞书auth login支持自定义权限,或者auth机器人已申请的权限",
|
||||
"title": "飞书auth login支持自定义权限,或者auth机器人已申请的权限",
|
||||
"body": "lark-cli auth login 的生成的链接不支持自定义权限,如移除部分权限,也无法批量auth机器人已经拥有的权限。\n建议支持仅auth已拥有的权限,或者批量导入json权限配置",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"auth"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/76",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#203 ⚠️ 过程状态卡片更新失败,已降级为独立结果消息",
|
||||
"title": "⚠️ 过程状态卡片更新失败,已降级为独立结果消息",
|
||||
"body": "<img width=\"596\" height=\"117\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/85060bfc-a914-4bad-a600-e91d64a03174\" />",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/203",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#64 Windows: `--params` JSON parsing fails for `im messages read_users` (and other n",
|
||||
"title": "Windows: `--params` JSON parsing fails for `im messages read_users` (and other non-empty params calls)",
|
||||
"body": "## Summary\n\nOn Windows, `lark-cli` appears to support `im messages read_users`, but any call that passes a non-empty JSON object to `--params` fails in CLI parsing.\n\nThe Feishu OpenAPI itself works correctly. The same request succeeds in the official API debugger.\n\n## Environment\n\n- OS: Windows\n- Shell: PowerShell\n- CLI version: `lark-cli 1.0.0`\n\n## What works\n\n- `lark-cli im messages read_users --help`\n- `lark-cli schema im.messages.read_users`\n- Feishu OpenAPI debugger successfully calls:\n - `GET /open-apis/im/v1/messages/{message_id}/read_users`\n - with `user_id_type=open_id`\n\n## What fails\n\nThis command is expected to work, but fails with JSON parsing error:\n\n```powershell\nlark-cli im messages read_users --as bot --params '{\"message_id\":\"om_x100b53a771492ca8b4ca3ca7535d2d0\",\"user_id_type\":\"open_id\"}' --format json\n```\n\nActual result:\n\n```json\n{\n \"ok\": false,\n \"identity\": \"bot\",\n \"error\": {\n \"type\": \"validation\",\n \"message\": \"--params invalid JSON format\"\n }\n}\n```\n\nI also observed similar behavior on Windows for other commands when `--params` is a non-empty JSON object.\n\nFor example, even a simple dry-run can fail:\n\n```powershell\nlark-cli api GET /open-apis/calendar/\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/64",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#202 im +messages-send --markdown: table syntax and blank lines are silently dropped",
|
||||
"title": "im +messages-send --markdown: table syntax and blank lines are silently dropped",
|
||||
"body": "## Environment\n\n- **OS**: macOS 26.3.1 arm64\n- **lark-cli version**: v1.0.2\n- **Shell**: zsh\n\n## Description\n\nWhen sending messages with `--markdown`, two formatting issues occur:\n\n1. **Markdown tables are completely lost** — `| col | col |` syntax produces no output at all, the table content disappears silently\n2. **Blank lines are collapsed** — double newlines `\\n\\n` between paragraphs are compressed to single newlines, removing paragraph spacing\n\n## Steps to Reproduce\n\n```bash\nlark-cli im +messages-send --as bot --chat-id \"oc_xxx\" --markdown '**Test**\n\n| Item | Status |\n|------|--------|\n| Table | Testing |\n\nParagraph 1\n\nParagraph 2 (should have blank line above)'\n```\n\n### Expected output in Feishu\n\n```\n**Test**\n\n| Item | Status |\n|--------|---------|\n| Table | Testing |\n\nParagraph 1\n\nParagraph 2 (should have blank line above)\n```\n\n### Actual output in Feishu\n\n```\nTest\nParagraph 1\nParagraph 2 (should have blank line above)\n```\n\n- Table is completely missing\n- Bold renders correctly\n- All blank lines between paragraphs are gone\n\n## Impact\n\nThis makes `--markdown` unreliable for any structured data output. Users must fall back to `--text` (which preserves blank lines but has n\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/202",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#62 Intermittent EOF errors on base commands (+table-list / +record-list / +record-g",
|
||||
"title": "Intermittent EOF errors on base commands (+table-list / +record-list / +record-get / +record-upsert) during batch operations",
|
||||
"body": "## Summary\n\nI encountered intermittent `EOF` errors when using `lark-cli` with Base APIs.\n\nThe issue first appeared during batch processing against a Base table, but later even simple read-only commands started failing with the same `EOF` error.\n\nAt the moment I’m not sure whether this is caused by `lark-cli`, the upstream Base OpenAPI, or connection handling between them, but I’d like to report the behavior because it makes long-running batch jobs unreliable.\n\n## Environment\n\n- `lark-cli` version: `1.0.0`\n- OS: macOS\n- identity: `user`\n\n## Affected commands\n\nI observed `EOF` on multiple commands, including:\n\n- `lark-cli base +table-list`\n- `lark-cli base +record-list`\n- `lark-cli base +record-get`\n- `lark-cli base +record-upsert`\n\n## Example errors\n\n### table-list\n```text\n{\n \"ok\": false,\n \"identity\": \"user\",\n \"error\": {\n \"type\": \"api_error\",\n \"message\": \"API call failed: Get \\\"https://open.feishu.cn/open-apis/base/v3/bases/<base_token>/tables?limit=5&offset=0\\\": EOF\"\n }\n}",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"base"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/62",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#128 一台电脑上能否创建多个飞书 cli?",
|
||||
"title": "一台电脑上能否创建多个飞书 cli?",
|
||||
"body": "如题。个人账号和飞书账号都想使用,之前在个人账号创建了一个飞书 cli,想在公司做演示,用公司的账号又创建了一个飞书 cli,但是原来个人账号创建的不见了。家目录的 .lark-cli/config.json 只有一份。",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/128",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#124 返回结果全部使用json 太浪费token 了, 最好可以重新设计cli的返回结果.",
|
||||
"title": "返回结果全部使用json 太浪费token 了, 最好可以重新设计cli的返回结果.",
|
||||
"body": "cli 返回的结果都是 json 格式的数据 , 其中有很多字段和结果是无关的. \n无论是从格式上还是字段内容上都非常浪费token.",
|
||||
"expected_type": null,
|
||||
"expected_domains": [],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/124",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#196 建议官方支持 Bun 作为 Node.js 替代方案,提升开发体验",
|
||||
"title": "建议官方支持 Bun 作为 Node.js 替代方案,提升开发体验",
|
||||
"body": "## 背景\n目前 larksuite/cli 运行环境依赖 Node.js(`engines.node`),实际核心 CLI 是 Go 预编译二进制,通过 JS 脚本(如 `install.js`、`run.js`)进行管理。\n\nBun 是一个新兴的 JavaScript 运行时,兼容 Node.js 大部分 API,具备以下显著优势:\n\n- 启动速度更快,依赖安装与执行体验远优于 Node.js\n- 不需 nvm 等版本隔离工具,减少环境冲突\n- 自带包管理器(等价于 npm/yarn/pnpm)\n- 更低的内存和 CPU 占用\n- 更适合 CLI 场景与现代云/AI 环境\n\n目前个人和许多开发者因为多项目需要频繁切换不同 Node 版本、维护 nvm,自从迁移到 Bun 后感到大大减负。而像本项目这样主要用 JS 脚本起 GO 二进制的模式,用 Bun 兼容性极好,且用 `bun run`/`bunx` 启动 CLI 体验非常流畅。\n\n## 建议\n- 在 `package.json` 里添加 `engines.bun` 字段:\n ```json\n \"engines\": {\n \"node\": \">=16\",\n \"bun\": \">=1.0\"\n },\n \"packageManager\": \"bun@latest\"\n ```\n- 脚本部分支持 `bun` 和 `node` 两种运行方式(如 postinstall 可试`bun ... || node ...`),官方 Readme 说明支持 Bun\n- 将 Bun 作为推荐的 CLI 运行方式之一,面向现代开发者和 AI 场景\n\n## 期望效果\n- 降低对特定 Node 版本的依赖,减少 nvm 疲劳\n- CLI 启动速度及依赖安装更快\n- 吸引更多 Bun 用户与下一代 JavaScript 社区\n\n感谢官方团队的付出!很期待能看到官方 Bun 支持或意见!",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/196",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#195 这个和feishu-mcp有什么区别",
|
||||
"title": "这个和feishu-mcp有什么区别",
|
||||
"body": "相关链接 https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/mcp_integration/mcp_introduction",
|
||||
"expected_type": "question",
|
||||
"expected_domains": [],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/195",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#160 【feature】我现在在做 feishu 集成 Agent,发现日历和任务清单有问题",
|
||||
"title": "【feature】我现在在做 feishu 集成 Agent,发现日历和任务清单有问题",
|
||||
"body": "# 如题\n我想让Agent把任务记到将任务清单和日历。这样我可以在飞书上很清晰的看到需要做什么事情。\n\n但是现在获取日历的接口,不支持获取任务清单的数据,但是人类在飞书上却可以看到。",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"calendar",
|
||||
"task"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/160",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#161 不能给未授权用户发消息",
|
||||
"title": "不能给未授权用户发消息",
|
||||
"body": "lark-cli api POST --data 在 Windows 上有 bug\nlark-cli im +messages-send 只支持 bot 身份\nbot 没有权限给未授权的用户发消息",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"im"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/161",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#58 今天使用cli来操作wiki,能力需要补强",
|
||||
"title": "今天使用cli来操作wiki,能力需要补强",
|
||||
"body": "在claude code中操作wiki,本来想重构我的wiki知识库的结构、目录之类,但是发现以下能力并没有,希望可以尽快补强\n\n--- \n ❌ 不能做的(缺失的能力)\n \n Wiki 操作缺失\n \n 1. 创建新节点/文档 — 无法在 Wiki 中新建节点 \n 2. 删除节点 — 没有删除 Wiki 节点的 API \n 3. 直接上传文件到 Wiki — 上传只能到 Drive,无法直接上传到 Wiki 知识库 \n \n 集成能力缺失 \n \n 1. Wiki ↔ Drive 自动集成 — 上传文件后无法自动归类到 Wiki 目录 \n 2. 批量操作 — 没有批量移动/创建节点的能力\n---",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"doc",
|
||||
"drive",
|
||||
"wiki"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/58",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#189 [Doc] base +record-list 分页读取时字段顺序不稳定的说明与最佳实践",
|
||||
"title": "[Doc] base +record-list 分页读取时字段顺序不稳定的说明与最佳实践",
|
||||
"body": "## 问题描述\n\n在使用 `lark-cli base +record-list` 分页读取多维表格数据时,发现一个容易被忽视的问题:\n\n**API 返回的 `field_id_list` 顺序在不同分页中可能不同**\n\n这会导致:\n1. 用硬编码索引定位字段时,第二页数据解析错误\n2. 数据匹配失败,程序误判为\"读取完成\"\n3. 实际数据遗漏,但不会报错\n\n## 复现场景\n\n```python\n# 错误做法 - 假设字段顺序固定\nresult = run_lark_cli(['base', '+record-list', '--offset', '0', '--limit', '500'])\nfields = result['data']['fields']\nid_idx = fields.index('编号') # 第一页:索引可能是 0\n\n# 第二页\nresult = run_lark_cli(['base', '+record-list', '--offset', '500', '--limit', '500'])\nfields = result['data']['fields']\nrecord = result['data']['data'][0]\nsample_id = record[id_idx] # 第二页:索引可能是 11,数据错位!\n```\n\n**实际案例**:\n- 表格总记录:254 条\n- 第一页返回:200 条,字段顺序 A\n- 第二页返回:54 条,字段顺序 B(与 A 不同)\n- 结果:第二页数据全部解析错误,匹配失败\n\n## 解决方案\n\n**必须使用 `field_id_list` 定位字段,且每页都要重新计算索引**:\n\n```python\nID_FIELD_ID = \"fld64hLsFo\" # 编号字段的 field_id(固定不变)\n\ndef load_all_records(base_token, table_id):\n record_map = {}\n offset = 0\n page_size = 500\n\n while True:\n result = run_lark_cli([\n 'base', '+record-list',\n '--base-token', base_token,\n '--table-id', table_id,\n '--limit', str(page_size),\n '--offset', str(offset)\n ])\n\n data = result.get('data', {})\n fie\n\n...(truncated)",
|
||||
"expected_type": "documentation",
|
||||
"expected_domains": [
|
||||
"base",
|
||||
"doc"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/189",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#187 持久化连接授权",
|
||||
"title": "持久化连接授权",
|
||||
"body": "每次使用都要重新授权一次,说旧的已经过期,有什么方法可持久授权吗?",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/187",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#167 什么时候支持私有化版本飞书呢?",
|
||||
"title": "什么时候支持私有化版本飞书呢?",
|
||||
"body": "什么时候支持私有化版本飞书呢?",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/167",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#184 [Bug] docs +update --mode replace_all 导致文档内容被重复追加(100% 复现)",
|
||||
"title": "[Bug] docs +update --mode replace_all 导致文档内容被重复追加(100% 复现)",
|
||||
"body": "# 问题描述(由 Claude Code 发现并总结)\n\n## 环境信息\n\n- **lark-cli 版本**: 1.0.1\n- **操作系统**: macOS (Darwin 25.2.0, arm64)\n- **Node.js**: v25.2.1\n- **身份**: user 模式\n\n## 问题描述\n\n使用 `docs +update --mode replace_all` 对飞书云文档执行纯文本关键词替换时,文档内容不是被原地替换,而是**整个文档内容被复制一份追加到末尾**。每执行一次,文档就多一份副本。\n\n## 复现步骤\n\n1. 准备一份飞书云文档(wiki 类型,obj_type=docx),文档包含嵌入电子表格(`<sheet token=\"...\"/>`)、图片(`<image token=\"...\"/>`)、文档引用(`<mention-doc token=\"...\"/>`)等 PROTECTED 内容,总计约 12 个 token,文档大小约 38,000 字符。\n\n2. 执行以下命令:\n```bash\nlark-cli docs +update \\\n --doc \"<doc_token>\" \\\n --mode replace_all \\\n --selection-with-ellipsis \"oldKeyword\" \\\n --markdown \"newKeyword\"\n```\n\n3. 命令返回异步 task_id:\n```json\n{\n \"ok\": true,\n \"data\": {\n \"status\": \"running\",\n \"task_id\": \"<task_id>\",\n \"tool\": \"update_doc\"\n }\n}\n```\n\n4. 等待 10 秒后 fetch 文档内容验证。\n\n## 预期行为\n\n文档中 3 处 `oldKeyword` 被替换为 `newKeyword`,文档大小基本不变。\n\n## 实际行为\n\n- 文档大小从 **~38,000 字符膨胀到 ~77,000 字符**(精确翻倍)\n- 文档一级标题从出现 1 次变为 2 次(整个文档被复制了一份追加到末尾)\n- PROTECTED token 数量从 12 个变为 22 个(新副本中生成了新的 token)\n- 原始内容中 `oldKeyword` 仍存在 3 处(在未被替换的副本中)\n- `newKeyword` 从 3 处变为 9 处\n\n**在未恢复的情况下再次执行同一命令,文档进一步膨胀到 ~115,000 字符(3 份副本),标题出现 3 次。**\n\n## 复现率\n\n**3/3 次,100% 复现。**\n\n| 次数 | 操作前 | 操作后 | 结果 |\n|------|--------|--------|------|\n\n...(truncated)",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"doc",
|
||||
"sheets"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/184",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#185 lark-cli 不支持多个 Gateway 实例共享配置",
|
||||
"title": "lark-cli 不支持多个 Gateway 实例共享配置",
|
||||
"body": "## 问题描述(由 Claude Code 发现并总结)\n\nlark-cli 使用全局配置文件 `~/.lark-cli/config.json`,不支持多实例共享或指定配置文件路径。\n\n当有多个 OpenClaw Gateway 实例(如 Friday、Andy、vovo)需要各自使用不同的飞书 App 时,lark-cli 只能配置一个 App,导致其他实例的图片/文件发送会路由到错误的 App。\n\n## 复现场景\n\n1. 部署多个 OpenClaw Gateway 实例\n2. 每个实例使用不同的飞书 App(不同的 App ID/Secret)\n3. 尝试使用 lark-cli 发送图片\n\n## 当前问题\n\n- lark-cli 只读取 `~/.lark-cli/config.json`\n- 没有 `--config` 或环境变量方式指定其他配置文件\n- 导致不同 Gateway 实例混用同一个 App 认证\n\n## 期望行为\n\n支持以下任一方案:\n\n1. **多配置文件支持**:通过 `--config <path>` 指定配置文件路径\n2. **环境变量配置**:`LARK_CONFIG_PATH` 环境变量\n3. **全局配置节**:在单一配置文件中支持多个 App 配置,通过 `--app-id` 切换\n\n## 替代方案(临时解决)\n\n为每个 Gateway 创建独立的发送脚本,直接调用飞书 API 而非使用 lark-cli:\n\n```bash\n#!/bin/bash\n# feishu-send-image.sh - 使用指定 App 发送图片\nAPP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n# 直接调用飞书 API\n```\n\n但这增加了维护成本,且无法使用 lark-cli 的其他功能。\n\n## 环境信息\n\n- OS: macOS\n- lark-cli 版本: 1.0.0+\n- Node.js: 22.x",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/185",
|
||||
"source_labels": [
|
||||
"bug",
|
||||
"domain/event"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#177 通过auth login仅选择已有的Scope进行登录,但依然会跳转到Scope Authorization",
|
||||
"title": "通过auth login仅选择已有的Scope进行登录,但依然会跳转到Scope Authorization",
|
||||
"body": "我登录Scopes仅选择docs, 这是我已有的权限,但链接依然会跳转到Scope Authorization去申请全部权限",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"auth"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/177",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#182 只能创建一个应用,config配置内使用apps:[],看着像支持多个应用,实际上每次都是覆盖,没法同时配置多个",
|
||||
"title": "只能创建一个应用,config配置内使用apps:[],看着像支持多个应用,实际上每次都是覆盖,没法同时配置多个",
|
||||
"body": "",
|
||||
"expected_type": null,
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/182",
|
||||
"source_labels": [
|
||||
"enhancement"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#248 [Bug] macOS: `config init --new` creates encrypted files but does not persist `master.key` to Keychain, causing fresh processes to fail",
|
||||
"title": "[Bug] macOS: `config init --new` creates encrypted files but does not persist `master.key` to Keychain, causing fresh processes to fail",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/248",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#153 feat(drive): add folder/file management shortcuts for Drive API",
|
||||
"title": "feat(drive): add folder/file management shortcuts for Drive API",
|
||||
"body": "## Feature Request\n\nAdd Drive file/folder management shortcuts to lark-cli.\n\n## Current Gap\n\nThe current Drive module only supports 3 commands:\n- `+upload` - Upload a local file\n- `+download` - Download a file\n- `+add-comment` - Add a comment to a file\n\nMissing essential Drive operations for building a complete CLI experience:\n- Create folders/directories\n- List files in a folder\n- Copy/Move files\n- Delete files\n- Get file metadata (including folder token)\n\n## Use Case\n\nFor AI Agent (Hermes) integration, I want to build an automated knowledge base system:\n1. Create a folder hierarchy for knowledge management (e.g., 🧠 Knowledge Base > Embedded/AI/DevOps)\n2. Programmatically organize files into folders\n3. Link folder tokens to Bitable index records\n\nCurrently `lark-cli drive` is insufficient for this use case.\n\n## Proposed Shortcuts\n\n```go\nfunc Shortcuts() []common.Shortcut {\n return []common.Shortcut{\n DriveUpload,\n DriveDownload,\n DriveAddComment,\n // New:\n DriveCreateFolder, // lark-cli drive +create-folder\n DriveListFiles, // lark-cli drive +list\n DriveFileCopy, // lark-cli drive +copy (extends existing copy)\n DriveFileDelete, // lark-cli drive +delete\n DriveGetFileMeta, // lark-cli drive +meta\n }\n}\n```\n\n## Workaround\n\nCurrently I'm using lark-cli api to create folders, but the drive API needs a user identity scope which lark-cli does not have built-in support for.\n\n## Context\n\nThis issue arises when integrating lark-cli with [Hermes Agent](https://github.com/openmule/hermes-agent) for knowledge base creation in Feishu.\n",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"drive"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/153",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#143 Feature Request: Add base +record-search command (POST /records/search API)",
|
||||
"title": "Feature Request: Add base +record-search command (POST /records/search API)",
|
||||
"body": "## Background\n\nCurrently, querying records in a Bitable table requires `+record-list` with client-side filtering. For large tables (2400+ records), this requires 13+ serial API calls and takes 60+ seconds.\n\nThe Feishu Open Platform provides a more efficient `/records/search` API:\n- **Endpoint**: `POST /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/search`\n- **Server-side filtering**: supports `contains`, `is`, `isNot`, etc. on any field\n- **Larger page size**: up to 500 records per request (vs 200 for list)\n- **Result**: 1–2 requests instead of 13+, roughly 10–100x faster\n\n## Problem with current `lark-cli api`\n\nCalling this endpoint via `lark-cli api` silently exits with code 1, no stdout, no stderr:\n\n```bash\nMSYS_NO_PATHCONV=1 lark-cli api POST \"/open-apis/bitable/v1/apps/xxx/tables/yyy/records/search\" --as user --data '{\"page_size\":5}'\n# Exit: 1, no output at all\n```\n\nGET requests to other bitable endpoints work fine via `lark-cli api`. The issue appears specific to POST `/records/search`.\n\n## Requested Command\n\n```bash\nlark-cli base +record-search --base-token <token> --table-id <table_id> --filter '{\"conjunction\":\"and\",\"conditions\":[{\"field_name\":\"会议名称\",\"operator\":\"contains\",\"value\":[\"keyword\"]}]}' --sort '[{\"field_name\":\"会议开始时间\",\"desc\":true}]' --field-names '[\"会议名称\",\"会议开始时间\",\"面试录音\"]' --page-size 500 --page-all\n```\n\n## Performance Impact\n\nSearching interview records in a 2400-row Bitable table:\n\n| Method | Requests | Time |\n|--------|----------|------|\n| Current `+record-list` (full scan) | 13 serial calls | 60+ sec |\n| Optimized `+record-list` (range scan) | 5 calls | ~6 sec |\n| `+record-search` (server-side filter) | 1–2 calls | ~1 sec |\n\n## Reference\n\n- [Search records API](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/bitable-v1/app-table-record/search)",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"base"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/143",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#165 macOS arm64: lark-cli binary fails with exit code 137 (SIGKILL)",
|
||||
"title": "macOS arm64: lark-cli binary fails with exit code 137 (SIGKILL)",
|
||||
"body": "# lark-cli Binary Fails to Execute on macOS arm64 (Exit Code 137/SIGKILL)\n\n## Environment\n\n- **OS**: macOS 15.1 (Darwin 25.1.0) arm64\n- **lark-cli version**: v1.0.1\n- **Node.js**: v22.21.1\n- **npm**: 10.9.4\n- **Installation method**: npm global install\n- **Architecture**: Apple Silicon (arm64)\n\n## Description\n\nAfter successfully installing `@larksuite/cli` via npm and running the postinstall script, the `lark-cli` binary fails to execute with exit code 137 (SIGKILL). The binary is killed by the system immediately upon execution without producing any output.\n\n## Steps to Reproduce\n\n1. Install lark-cli globally:\n```bash\nnpm install -g @larksuite/cli\n```\n\n2. Run postinstall script manually (as per issue #135):\n```bash\ncd ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli\nnode scripts/install.js\n```\nOutput: `lark-cli v1.0.1 installed successfully`\n\n3. Verify binary exists and is executable:\n```bash\nls -la ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli/bin/lark-cli\nfile ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli/bin/lark-cli\n```\nOutput:\n```\n-rwxr-xr-x 1 yang staff 14846642 Mar 31 20:46 lark-cli\nMach-O 64-bit executable arm64\n```\n\n4. Attempt to run any lark-cli command:\n```bash\nlark-cli --version\nlark-cli --help\nlark-cli config init\n```\n\n## Expected Behavior\n\nThe command should display help text, version information, or start the configuration wizard.\n\n## Actual Behavior\n\n- The process exits immediately with code 137 (SIGKILL)\n- No output is produced on stdout or stderr\n- The process appears in `ps` briefly then disappears\n- No crash reports are generated in `~/Library/Logs/DiagnosticReports/`\n\n## Additional Information\n\n### Binary Details\n\n```bash\notool -L ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli/bin/lark-cli\n```\n\nOutput:\n```\n/usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)\n/usr/lib/libresolv.9.dylib (compatibility version 0.0.0, current version 0.0.0)\n/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation\n/System/Library/Frameworks/Security.framework/Versions/A/Security\n```\n\n### Attempted Solutions\n\n1. **Tried downloading pre-built binary from GitHub releases:**\n - Downloaded `lark-cli-1.0.1-darwin-arm64.tar.gz`\n - Same behavior (exit code 137)\n\n2. **Attempted source build:**\n - Encountered SSL certificate verification error during `make install`\n - Compiled binary exhibits same exit code 137 behavior\n\n3. **Checked for quarantine attributes:**\n - No `com.apple.quarantine` attribute found\n - `spctl --assess` rejects the binary\n\n4. **Skills installation successful:**\n - `npx skills add larksuite/cli -y -g` completed successfully\n - All 19 skills installed to `~/.agents/skills/`\n\n## Exit Code 137 Context\n\nExit code 137 = 128 + 9 = SIGKILL, which typically indicates:\n- The process was forcibly terminated by the system\n- Possible code signing issues\n- Possible compatibility issues with the macOS version\n- Security restrictions preventing execution\n\n## Related Issues\n\n- Issue #135: pnpm installation requires manual postinstall (similar installation flow issue)\n- Issue #70: macOS token persistence issues (macOS-specific behavior)\n- Issue #122: Silent failures on macOS arm64 environment\n\n## Workaround Needed\n\nIs there:\n1. A known issue with code signing for the macOS arm64 binary?\n2. An alternative installation method that works on macOS 15.x?\n3. Any additional configuration needed for the binary to execute?\n\n## System Details\n\n```bash\n# macOS Version\nsw_vers\n```\nOutput:\n```\nProductName:\t\tmacOS\nProductVersion:\t\t15.1\nBuildVersion:\t\t24B83\n```\n\n```bash\n# Architecture\nuname -m\n```\nOutput: `arm64`\n\n```bash\n# Go version (for reference)\ngo version\n```\nOutput: `go version go1.25.7 darwin/arm64`\n\n---\n\nThe skills package installed successfully, but the CLI binary itself cannot be used. Any guidance would be appreciated!",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/165",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#107 Request Windows binary release",
|
||||
"title": "Request Windows binary release",
|
||||
"body": "## Request: Add Windows (win32) binary release\\n\\n### Problem\\nThe current GitHub release (v1.0.0) only contains macOS and Linux binaries. The Windows platform is not supported.\\n\\nWhen installing via npm (\npm install -g @larksuite/cli) on Windows, the install script attempts to download lark-cli-1.0.0-windows-amd64.zip from the release page, but this file does not exist, resulting in a 404 error followed by connection timeout.\\n\\n### Evidence\\n- Release assets only contain: lark-cli-1.0.0-darwin-amd64.tar.gz, lark-cli-1.0.0-darwin-arm64.tar.gz, lark-cli-1.0.0-linux-amd64.tar.gz\\n- No lark-cli-1.0.0-windows-amd64.zip\\n\\n### Why this matters\\nWindows is a major platform for development and AI agent workflows. Without a pre-built binary, Windows users must build from source (requiring Go 1.23+), which is a significant barrier.\\n\\n### Request\\nPlease add Windows binary to the CI/CD release pipeline. The install script already supports win32 (see scripts/install.js platform mapping), so it should be straightforward to add a Windows build step.\\n\\n### Additional context\\n- Node.js version: v24.14.0 (Windows x64)\\n- Platform: Windows_NT 10.0.22631\\n",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/107",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#147 [Feature] Implement Encrypted File Fallback for Keychain Storage",
|
||||
"title": "[Feature] Implement Encrypted File Fallback for Keychain Storage",
|
||||
"body": "### **Problem Statement**\nCurrently, `lark-cli` relies on the system keychain on macOS to store sensitive information like App Secrets and User Access Tokens. However, in certain environments—such as restricted sandboxes, headless CI/CD servers, or remote SSH sessions —the system keychain may be unavailable.\n\nWhen this happens, the CLI currently fails to save configurations, preventing users from completing `lark-cli config init` or logging in.\n\n### **Proposed Solution**\nIntroduce a secondary, locally-managed encrypted storage layer. If the primary system keychain is unreachable or returns an \"unavailable\" error, the CLI should gracefully degrade to storing credentials in an AES-GCM encrypted file within the user’s config directory.\n\n### **Requirements**\n* **Encryption:** Use AES-GCM with a locally generated `master.key` (permission 0600) to ensure data at rest is not plain text.\n* **Transparency:** Warn the user when a fallback is being used so they are aware of the storage change.\n* **Safety:** Ensure that changing an App ID doesn't lead to the accidental reuse of a secret reference from a previous configuration.\n* **Refactoring:** Centralize config directory resolution and encryption logic to avoid duplication across platform-specific files.\n",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"auth",
|
||||
"core"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/147",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#75 Feature Request: Support creating folders via drive command (+create-folder shortcut)",
|
||||
"title": "Feature Request: Support creating folders via drive command (+create-folder shortcut)",
|
||||
"body": "## Feature Request\n\n### Summary\nAdd a shortcut command to create folders in Lark Drive, similar to how `docs +create` creates documents.\n\n### Proposed Command\n```bash\nlark-cli drive +create-folder --folder-token <parent_folder_token> --name <folder_name>\n```\n\n### Underlying API\nThe Feishu Open Platform already provides this capability:\n- **Create Folder API:** `POST /open-apis/drive/v1/files/create_folder`\n- **Scope:** `drive:drive:write`\n- **API Doc:** https://open.feishu.cn/document/server-docs/docs/drive-v1/folder/create\n\n### Use Case\nWhen organizing files programmatically (e.g., creating weekly report folders under a parent directory), there is currently no way to create folders via `lark-cli`. The `drive` module only supports `files.copy`, not folder creation. The only workaround is using `lark-cli api POST /open-apis/drive/v1/files/create_folder --data '...'`, which is verbose and error-prone for a common operation.\n\n### Proposed Parameters\n| Flag | Required | Description |\n|------|----------|-------------|\n| `--folder-token` | Yes | Parent folder token where the new folder will be created |\n| `--name` | Yes | Name of the new folder |\n\n### Additional Context\nOther drive operations like `+upload`, `+download`, `+add-comment` already have shortcut commands. Folder creation is a fundamental operation and deserves the same treatment.",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"drive"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/75",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#82 Docs create bug: create --markdown only write the first line and discard the others",
|
||||
"title": "Docs create bug: create --markdown only write the first line and discard the others",
|
||||
"body": "<img width=\"946\" height=\"284\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/39b0c4ba-43a3-4210-acc3-889bb0f64ca4\" />\n\nwhen I test the docs cli, it only write the fist line of markdown content. my test demo is :\nlark-cli docs +create --title \"飞书CLI命令一览表-测试\" --markdown \"🔐 认证相关 命令 说明 lark-cli auth login 扫码登录(无需创建应用) lark-cli auth status 查看当前登录状态 lark-cli auth list 列出所有已登录用户 lark-cli auth logout 退出登录 ⚙️ 配置管理 命令 说明 lark-cli config init 初始化配置(App ID / App Secret) lark-cli config show 显示当前配置 lark-cli config remove 移除配置 📅 日历 Calendar 命令 说明 lark-cli calendar +agenda 查看今日日程 lark-cli calendar +create 创建日历事件 lark-cli calendar events list 列出日历事件 👥 联系人 Contact 命令 说明 lark-cli contact +get-user 获取用户信息 lark-cli contact +search-user 搜索用户 💬 消息 IM 命令 说明 lark-cli im +chat-create 创建群聊 lark-cli im +chat-messages-list 查看聊天消息 lark-cli im +messages-reply 回复消息 lark-cli im +messages-send 发送消息 📝 文档 Docs 命令 说明 lark-cli docs +create 创建文档 lark-cli docs +fetch 获取文档内容 lark-cli docs +search 搜索文档 📊 表格 Sheets 命令 说明 lark-cli sheets +create 创建表格 lark-cli sheets +read 读取表格内容 lark-cli sheets +write 写入表格 lark-cli sheets +append 追加行 📁 云盘 Drive 命令 说明 lark-cli drive +upload 上传文件 lark-cli drive +download 下载文件 lark-cli drive +add-comment 添加评论 📧 邮件 Mail 命令 说明 lark-cli mail +draft-create 创建邮件草稿 lark-cli mail +message 查看邮件内容 ✅ 任务 Task 命令 说明 lark-cli task +create 创建任务 lark-cli task +complete 完成任务 lark-cli task +get-my-tasks 获取我的任务 🔧 通用功能 命令 说明 lark-cli api GET /path 调用任意 API lark-cli schema 查看 API 参数和权限 lark-cli doctor 健康检查\"",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"doc"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/82",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#254 你好",
|
||||
"title": "你好",
|
||||
"body": "",
|
||||
"expected_type": null,
|
||||
"expected_domains": [],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/254",
|
||||
"source_labels": []
|
||||
},
|
||||
{
|
||||
"name": "#9 Meeting room booking is possible, but room discovery is missing from CLI docs and command surface",
|
||||
"title": "Meeting room booking is possible, but room discovery is missing from CLI docs and command surface",
|
||||
"body": "Does it support meeting room discovery? Is there a `lark-cli calendar` command to discover meeting rooms? The CLI docs/command surface lacks room discovery.",
|
||||
"expected_type": "question",
|
||||
"expected_domains": [
|
||||
"calendar",
|
||||
"doc"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/9",
|
||||
"source_labels": [
|
||||
"enhancement"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#81 mail +draft-edit: add_inline does not actually insert inline image into HTML body",
|
||||
"title": "mail +draft-edit: add_inline does not actually insert inline image into HTML body",
|
||||
"body": "`add_inline` fails to insert inline image into HTML body. Expected the image to be present; actual: missing.",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"mail"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/81",
|
||||
"source_labels": [
|
||||
"enhancement",
|
||||
"domain/mail"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#259 Feature: docs +fetch should support --resolve-media to auto-download images/files",
|
||||
"title": "Feature: docs +fetch should support --resolve-media to auto-download images/files",
|
||||
"body": "Feature request: `lark-cli docs +fetch` should support `--resolve-media` to download images/files referenced in docs.",
|
||||
"expected_type": "enhancement",
|
||||
"expected_domains": [
|
||||
"doc"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/259",
|
||||
"source_labels": [
|
||||
"bug",
|
||||
"domain/doc",
|
||||
"domain/core"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "#265 wiki只能获取到标题,无法获取到内容,针对mindmap",
|
||||
"title": "wiki只能获取到标题,无法获取到内容,针对mindmap",
|
||||
"body": "",
|
||||
"expected_type": "bug",
|
||||
"expected_domains": [
|
||||
"wiki"
|
||||
],
|
||||
"source_url": "https://github.com/larksuite/cli/issues/265",
|
||||
"source_labels": [
|
||||
"enhancement",
|
||||
"domain/wiki"
|
||||
]
|
||||
}
|
||||
]
|
||||
70
scripts/issue-labels/test.js
Normal file
70
scripts/issue-labels/test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { classifyIssueText } = require("./index.js");
|
||||
|
||||
const samplesPath = path.join(__dirname, "samples.json");
|
||||
const samples = JSON.parse(fs.readFileSync(samplesPath, "utf8"));
|
||||
|
||||
/**
|
||||
* Convert an array-like value into a sorted string array.
|
||||
*
|
||||
* @param {Array<unknown>|undefined|null} arr
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function sortArray(arr) {
|
||||
return (arr || []).map(String).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether every element in sub exists in sup.
|
||||
*
|
||||
* @param {string[]} sub
|
||||
* @param {string[]} sup
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSubset(sub, sup) {
|
||||
const set = new Set(sup || []);
|
||||
for (const x of sub || []) {
|
||||
if (!set.has(x)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const sample of samples) {
|
||||
try {
|
||||
const result = classifyIssueText(sample.title, sample.body);
|
||||
|
||||
const hasExpectedType = Object.prototype.hasOwnProperty.call(sample, "expected_type");
|
||||
const expectedType = hasExpectedType ? sample.expected_type : undefined;
|
||||
const matchType = hasExpectedType ? (result.type || null) === expectedType : true;
|
||||
const actualDomains = sortArray(result.domains);
|
||||
const expectedDomains = sortArray(sample.expected_domains);
|
||||
const hasExpectedDomains = Object.prototype.hasOwnProperty.call(sample, "expected_domains");
|
||||
const matchDomains = !hasExpectedDomains
|
||||
? true
|
||||
: expectedDomains.length === 0
|
||||
? actualDomains.length === 0
|
||||
: isSubset(expectedDomains, actualDomains);
|
||||
|
||||
if (matchType && matchDomains) {
|
||||
console.log(`✅ Passed: ${sample.name}`);
|
||||
passed += 1;
|
||||
} else {
|
||||
console.log(`❌ Failed: ${sample.name}`);
|
||||
console.log(` Type expected: ${expectedType}, got: ${result.type}`);
|
||||
console.log(` Domains expected(subset): ${expectedDomains}, got: ${actualDomains}`);
|
||||
failed += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ Failed: ${sample.name} (Execution error)`);
|
||||
console.error(e && e.message ? e.message : String(e));
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTest Summary: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -45,6 +45,7 @@ const PATH_TO_DOMAIN_MAP = {
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/wiki/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
@@ -53,6 +54,7 @@ const PATH_TO_DOMAIN_MAP = {
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-wiki/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user