Compare commits

..

10 Commits

Author SHA1 Message Date
zhengzhijie
21c2e3950e feat(sheets): add chart data update shortcut 2026-07-23 17:12:08 +08:00
zhengzhijie
080fb57ad0 fix(sheets): normalize irregular chart ranges 2026-07-23 16:00:57 +08:00
zhengzhijie
87aa303ca1 fix(sheets): normalize chart range and flag inputs 2026-07-23 16:00:57 +08:00
zhengzhijie
c8f57c659e fix(sheets): prefer semantic chart shortcuts 2026-07-22 18:13:45 +08:00
zhengzhijie
e81e42029f feat(sheets): improve semantic chart workflows 2026-07-22 16:52:48 +08:00
zhengzhijie
e303da4b5e feat(sheets): add semantic chart shortcuts 2026-07-22 11:21:55 +08:00
zhengzhijie
f6bbd86303 feat(sheets): support partial chart snapshot schemas 2026-07-20 21:34:25 +08:00
xiongyuanwen-byted
67fc870582 feat(sheets): add --output-path for full-read file offload on cells/csv/table-get
Reads are capped by max_chars (default 500000; the backend tool also truncates
at ~50000 when unset). Add --output-path to +cells-get / +csv-get / +table-get:
when set, the result is written to a cwd-relative path as JSON and the char cap
is lifted to unbounded, so a large sheet lands on disk in full instead of being
clipped for stdout.

+table-get previously never sent max_chars, so it silently dropped rows past the
backend ~50000 default with no signal. It now takes --max-chars (default 500000,
sent explicitly) and surfaces truncated / truncation_warning when the read is
clipped, steering callers to --output-path for a lossless full read.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
af8e027269 feat(sheets): support --include truncation on +cells-get
Map the new `truncation` value in --include to include_truncation_info on
the get_cell_ranges tool input, so +cells-get can return per-cell
isRowTruncated / isColTruncated. Flag metadata and reference synced from
sheet-skill-spec; flag_defs_gen.go regenerated.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
2efadec335 feat(sheets): cut agent error rate and --help lookups (#1911)
## Background

Round 2 of eval-driven sheets optimization, rebased onto the latest `feat/lark-sheets-develop` (`8897196d`).

## Changes

- **feat(sheets): cut agent error rate and --help lookups (eval round 2)** — targets the top failure modes from round 2 evals, reducing agent error rate and the number of `--help` lookups.
- **chore(sheets): sync skill docs and flag data from sheet-skill-spec** — syncs skill docs and flag data from sheet-skill-spec.

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
864 changed files with 15519 additions and 64352 deletions

3
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

@@ -9,40 +9,7 @@ permissions:
contents: read
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
goreleaser:
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -59,79 +26,35 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
needs: build-release
needs: goreleaser
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.14.0'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -25,16 +25,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -250,16 +253,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

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

View File

@@ -2,176 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
- **slides**: add history rollback shortcuts (#1714)
- **base**: support per-record batch updates (#1889)
### Bug Fixes
- preserve slides schema issues
- allow jq examples in quality gate dry-runs
- **im**: warn when flag pagination is truncated (#1906)
- **slides**: warn on text shape overflow
- **slides**: exempt chart roundtrip attributes from lint
- **slides**: detect image text occlusion
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
### Documentation
- clarify drive upload overwrite guidance (#1982)
### Tests
- isolate unit tests from user state (#1883)
### Refactoring
- converge success output through a single Emitter that owns the write (#1899)
## [v1.0.73] - 2026-07-20
### Features
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
### Bug Fixes
- **slides**: detect visual elements outside canvas
- reduce public content credential fixture false positives
- standardize CLI shortcut text in English (#1942)
### Documentation
- **base**: reduce filter and update retry loops (#1879)
- **vc**: default transcript routing to smart notes over minutes (#1961)
- clarify local trigger automation (#1958)
### Tests
- synchronize temporary Git maintenance (#1946)
### Misc
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
- [codex] support bot menu events (#1765)
## [v1.0.72] - 2026-07-17
### Features
@@ -1722,13 +1552,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
all: test
@@ -49,21 +49,15 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/check-layering-ratchet.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
live-skills-test: fetch_meta
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
go test -v -count=1 ./cmd/update \
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:

View File

@@ -285,29 +285,6 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,29 +286,6 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

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

View File

@@ -13,8 +13,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -34,7 +33,7 @@ type APIOptions struct {
// Flags
Params string
Data string
As identity.Identity
As core.Identity
Output string
PageAll bool
PageSize int
@@ -88,7 +87,7 @@ Examples:
opts.Path = args[1]
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = identity.Identity(asStr)
opts.As = core.Identity(asStr)
if runF != nil {
return runF(opts)
}
@@ -305,7 +304,7 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *APIOptions) error {
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}
@@ -345,18 +344,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)

View File

@@ -1,398 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
)
type apiFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &configpkg.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: brand.Feishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func apiPaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: identity.AsBot,
}
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -16,13 +16,11 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/spf13/cobra"
)
@@ -42,8 +40,8 @@ func newTestRootCmd() *cobra.Command {
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -62,7 +60,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
if gotOpts.Path != "/open-apis/test" {
t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path)
}
if gotOpts.As != identity.AsBot {
if gotOpts.As != core.AsBot {
t.Errorf("expected as=bot, got %s", gotOpts.As)
}
if !gotOpts.DryRun {
@@ -71,8 +69,8 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -106,8 +104,8 @@ func TestApiCmd_DryRun(t *testing.T) {
}
func TestApiCmd_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -124,8 +122,8 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -139,8 +137,8 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// Register API endpoint stub
@@ -172,8 +170,8 @@ func TestApiCmd_BotMode(t *testing.T) {
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -185,8 +183,8 @@ func TestApiCmd_MissingArgs(t *testing.T) {
}
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -201,8 +199,8 @@ func TestApiCmd_EmptyMethodRejected(t *testing.T) {
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -214,8 +212,8 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
}
func TestApiValidArgsFunction(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -280,8 +278,8 @@ func TestApiValidArgsFunction(t *testing.T) {
}
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu, SupportedIdentities: 2,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
@@ -298,8 +296,8 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -318,8 +316,8 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
@@ -334,8 +332,8 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -354,11 +352,8 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -376,39 +371,14 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
}
savedPath, _ := got["saved_path"].(string)
if savedPath == "" {
t.Fatalf("saved_path missing from output: %#v", got)
}
// The file must land inside the temporary cwd — this pins the isolation
// contract: rolling back TestChdir would leave download.bin in the repo.
wantDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
if err != nil {
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
}
if gotDir != wantDir {
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
}
content, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if string(content) != "fake-binary-content" {
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
}
}
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
})
// Register a non-batch API that returns scalar data (no array field)
@@ -451,8 +421,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
}
func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
})
// Non-batch API that returns a business error (code != 0)
@@ -488,8 +458,8 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
})
// Register a batch API that returns an array field
@@ -521,8 +491,8 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -563,8 +533,8 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -629,8 +599,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -680,8 +650,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: brand.Feishu,
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -725,8 +695,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -811,8 +781,8 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
}
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -831,8 +801,8 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
}
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -851,8 +821,8 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
}
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -869,8 +839,8 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
}
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -903,8 +873,8 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
}
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -921,8 +891,8 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
}
func TestApiCmd_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -939,8 +909,8 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
}
func TestApiCmd_PageAll_WithJq(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: brand.Feishu,
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -970,8 +940,8 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
}
func TestApiCmd_MethodUppercase(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -990,8 +960,8 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
}
func TestApiCmd_FileFlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
@@ -1009,8 +979,8 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
}
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1026,8 +996,8 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
}
func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1043,8 +1013,8 @@ func TestApiCmd_FileWithGET(t *testing.T) {
}
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
@@ -1066,8 +1036,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
@@ -1104,8 +1074,8 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
// — there is no raw-payload passthrough; new Lark diagnostic fields require
// a CLI release.
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
@@ -1143,8 +1113,8 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
@@ -1196,8 +1166,8 @@ func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]stri
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
@@ -1225,8 +1195,8 @@ func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
@@ -1260,8 +1230,8 @@ func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
@@ -1293,8 +1263,8 @@ func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))

View File

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

View File

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

View File

@@ -9,10 +9,9 @@ import (
"testing"
"time"
"github.com/larksuite/cli/brand"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -24,8 +23,8 @@ import (
// branch. These tests pin that contract end-to-end through the dispatcher.
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
// UserOpenId left empty: triggers the not_logged_in branch.
})
@@ -56,8 +55,8 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
}
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_user", UserName: "tester",
})
@@ -93,10 +92,10 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &configpkg.CliConfig{
cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: brand.Feishu,
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
@@ -151,8 +150,8 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
// Scope validation is a real input error, not a predicate negative
// answer — it must surface as a typed ValidationError with the normal
// stderr envelope, distinct from the silent ErrBare predicate path.
f, _, _, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: brand.Feishu,
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates auth command tests from the host machine: config, logs
// and the registry cache are redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Domain-completion
// tests read the registry, so without seeding a clean checkout would either
// fail or trigger a remote metadata fetch.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
if err != nil {
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,8 @@
package config
import (
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
@@ -31,13 +31,12 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
return cmd
}
func parseBrand(value string) brand.Brand {
return brand.ParseBrand(value)
func parseBrand(value string) core.LarkBrand {
return core.ParseBrand(value)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := configpkg.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := configpkg.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *configpkg.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/secret"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}
if err := configpkg.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := configpkg.SaveMultiAppConfig(&configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &configpkg.MultiAppConfig{Apps: []configpkg.AppConfig{{
AppId: "cli_test", AppSecret: secret.PlainSecret("secret"), Brand: brand.Feishu,
}}}
if err := configpkg.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := configpkg.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,10 +16,9 @@ import (
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
identitypkg "github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
@@ -135,7 +134,7 @@ type ServiceMethodOptions struct {
// Flags
Params string
Data string
As identitypkg.Identity
As core.Identity
Output string
PageAll bool
PageLimit int
@@ -268,7 +267,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
RunE: func(cmd *cobra.Command, args []string) error {
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = identitypkg.Identity(asStr)
opts.As = core.Identity(asStr)
if runF != nil {
return runF(opts)
}
@@ -371,7 +370,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return err
}
// Check if this API method supports the resolved identitypkg.
// Check if this API method supports the resolved identity.
if opts.Method.RestrictsIdentity() {
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
return err
@@ -454,7 +453,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity identitypkg.Identity, config *configpkg.CliConfig, method meta.Method) error {
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
if ctx.Err() != nil {
return ctx.Err()
}
@@ -668,7 +667,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *configpkg.CliConfig, opts *ServiceMethodOptions) error {
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
}
@@ -683,7 +682,7 @@ func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions)
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, identitypkg.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
@@ -708,18 +707,20 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err

View File

@@ -1,402 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
)
type serviceFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &configpkg.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: brand.Feishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func servicePaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: identity.AsBot,
}
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newServicePaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
if !errors.Is(err, sentinel) {
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newServicePaginateTestHarness(t)
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

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

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates service command tests from the host machine: config (and
// the registry cache under it) is redirected to a temp dir, then the registry
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
if err != nil {
println("cmd/service test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd/service test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -6,9 +6,8 @@ package cmd
import (
"os"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/envnames"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// ResolveStartupBrand resolves the brand before the command tree is built, so
@@ -16,14 +15,14 @@ import (
// first catalog access. It mirrors the credential chain's brand precedence —
// environment, then the active profile's raw config entry — without touching
// the keychain (no secrets are needed to know the brand).
func ResolveStartupBrand(profile string) brand.Brand {
if raw := os.Getenv(envnames.CliBrand); raw != "" {
return brand.ParseBrand(raw)
func ResolveStartupBrand(profile string) core.LarkBrand {
if raw := os.Getenv(envvars.CliBrand); raw != "" {
return core.ParseBrand(raw)
}
if cfg, err := configpkg.LoadMultiAppConfig(); err == nil {
if cfg, err := core.LoadMultiAppConfig(); err == nil {
if app := cfg.CurrentAppConfig(profile); app != nil {
return brand.ParseBrand(string(app.Brand))
return core.ParseBrand(string(app.Brand))
}
}
return brand.Feishu
return core.BrandFeishu
}

View File

@@ -5,7 +5,6 @@ package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
@@ -13,34 +12,11 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
func isStartupBrandHelper() bool {
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
}
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
return envNonce != "" && envNonce == argNonce
}
func startupBrandHelperNonce(args []string) string {
const prefix = "-startup-brand-helper="
for _, arg := range args {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
return ""
}
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
@@ -48,7 +24,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
os.Unsetenv("LARKSUITE_CLI_BRAND")
// No config at all → default brand.
if got := ResolveStartupBrand(""); got != brand.Feishu {
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
t.Errorf("empty state brand = %q, want feishu", got)
}
@@ -59,16 +35,16 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
if got := ResolveStartupBrand(""); got != brand.Feishu {
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
t.Errorf("default profile brand = %q, want feishu", got)
}
if got := ResolveStartupBrand("lark-prof"); got != brand.Lark {
if got := ResolveStartupBrand("lark-prof"); got != core.BrandLark {
t.Errorf("lark profile brand = %q, want lark (normalized)", got)
}
// Environment wins over the config file.
t.Setenv("LARKSUITE_CLI_BRAND", "lark")
if got := ResolveStartupBrand(""); got != brand.Lark {
if got := ResolveStartupBrand(""); got != core.BrandLark {
t.Errorf("env brand = %q, want lark", got)
}
}
@@ -78,7 +54,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
// sync.Once, so the brand must be injected before the first catalog access.
// It runs in a subprocess because the registry is process-global.
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
if isStartupBrandHelper() {
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
@@ -95,11 +71,9 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Fatal(err)
}
nonce := uuid.NewString()
t.Setenv(startupBrandHelperEnv, nonce)
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
cmd.Env = append(os.Environ(),
"GO_TEST_STARTUP_BRAND_HELPER=1",
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
)
@@ -111,33 +85,3 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
for _, tt := range []struct {
name string
envNonce string
argNonce string
want bool
}{
{name: "neither set"},
{name: "ambient environment only", envNonce: "ambient"},
{name: "command argument only", argNonce: "command"},
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
} {
t.Run(tt.name, func(t *testing.T) {
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestStartupBrandHelperNonce(t *testing.T) {
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
}
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates command-tree tests from the host machine: config (and the
// registry cache under it) is redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
if isStartupBrandHelper() {
// Re-exec helper subprocess (startup_brand_test.go): the parent test
// already provides an isolated config dir and disables remote metadata,
// and the helper must own the first registry Init to prove the startup
// order — do not seed or eagerly initialize here.
os.Exit(m.Run())
}
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
if err != nil {
println("cmd test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

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

View File

@@ -16,35 +16,28 @@ import (
"testing"
"time"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
)
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
f, stdout, stderr, _ := cmdutil.TestFactory(t, &configpkg.CliConfig{})
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
// and fully mocked skills operations. Tests that only care about install-method
// detection must never fall through to the real npx skills CLI.
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
@@ -111,18 +104,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func mockSkillsSync(t *testing.T) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -247,9 +228,6 @@ func TestNormalizeVersion(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -278,9 +256,6 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -306,7 +281,6 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
}
func TestUpdateManual_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -338,7 +312,6 @@ func TestUpdateManual_JSON(t *testing.T) {
}
func TestUpdateManual_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -1188,7 +1161,6 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
}
called := false
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
@@ -1205,10 +1177,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: successfulSkillsCommand(),
}
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
@@ -1228,7 +1197,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
@@ -1545,133 +1513,28 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
}
}
// liveSkillsIsolationEnv is the single source of truth for the user-state
// directories a live skills test must redirect under the temporary home. It
// covers the CLI's own config, the agent homes the skills CLI installs into,
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
// .skill-lock.json), and the npm/npx overrides that take precedence over
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
func liveSkillsIsolationEnv(home string) map[string]string {
return map[string]string{
"HOME": home,
"USERPROFILE": home,
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
"CODEX_HOME": filepath.Join(home, ".codex"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
"npm_config_cache": filepath.Join(home, ".npm-cache"),
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
"npm_config_prefix": filepath.Join(home, ".npm-global"),
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
}
}
func prepareLiveSkillsIntegration(t *testing.T) string {
t.Helper()
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
}
home := t.TempDir()
for key, value := range liveSkillsIsolationEnv(home) {
t.Setenv(key, value)
}
return home
}
func TestPrepareLiveSkillsIntegration(t *testing.T) {
reachedAfterGate := false
t.Run("requires explicit opt-in", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "")
prepareLiveSkillsIntegration(t)
reachedAfterGate = true
})
if reachedAfterGate {
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
}
t.Run("isolates user directories", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "1")
home := prepareLiveSkillsIntegration(t)
// Pin the isolation contract by key: removing a variable from
// liveSkillsIsolationEnv must fail this list, and every redirected
// value must live under the temporary home.
required := []string{
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
"npm_config_cache", "NPM_CONFIG_CACHE",
"npm_config_prefix", "NPM_CONFIG_PREFIX",
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
}
env := liveSkillsIsolationEnv(home)
for _, key := range required {
expected, ok := env[key]
if !ok {
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
continue
}
if !strings.HasPrefix(expected, home) {
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
}
if got := os.Getenv(key); got != expected {
t.Errorf("%s = %q, want %q", key, got, expected)
}
}
})
}
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
// lark-calendar into the isolated global skills dir, and returns the parsed
// global skills list. The caller opted in explicitly, so every missing
// precondition is a hard failure — skipping would report "nothing verified"
// as a green run.
func seedLiveSkillsGlobal(t *testing.T) []string {
t.Helper()
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI, so the test is skipped when
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
t.Skipf("npx not found in PATH: %v", err)
}
// Three sequential npx runs against a cold cache (the isolated home starts
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
// the generous side.
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
}
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
t.Fatalf("failed to seed isolated global skills: %v", err)
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Fatalf("real global skills CLI unavailable: %v", err)
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if len(localSkills) == 0 {
t.Fatal("seeded lark-calendar but global skills list is empty")
}
if err := ctx.Err(); err != nil {
t.Fatalf("real skills CLI availability check timed out: %v", err)
t.Skipf("real skills CLI availability check timed out: %v", err)
}
return localSkills
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI and only runs with explicit
// opt-in. All user directories are redirected to a temporary home.
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed the
// isolated global skills install.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
@@ -1767,17 +1630,26 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
// This is a live integration test that calls the real npx skills CLI and only
// runs with explicit opt-in. All user directories are redirected to a temporary
// home.
// This is a live integration test that calls the real npx skills CLI; it is
// skipped when npx or the skills registry is unavailable.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed one known
// official skill into the isolated global install. Cold start means no
// skills-state.json — locally installed skills may still exist, and seeding
// one keeps the Phase 4 per-skill assertions from running zero times.
localSkills := seedLiveSkillsGlobal(t)
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -1865,10 +1737,10 @@ func containsString(values []string, target string) bool {
func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
// Layer 1: resolved config wins.
var errBuf bytes.Buffer
f := &cmdutil.Factory{Config: func() (*configpkg.CliConfig, error) {
return &configpkg.CliConfig{Brand: brand.Brand(" LARK ")}, nil
f := &cmdutil.Factory{Config: func() (*core.CliConfig, error) {
return &core.CliConfig{Brand: core.LarkBrand(" LARK ")}, nil
}}
if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark {
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
t.Errorf("resolved-config brand = %q, want lark", got)
}
@@ -1880,9 +1752,9 @@ func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
f = &cmdutil.Factory{Config: func() (*configpkg.CliConfig, error) { return nil, errors.New("keychain locked") }}
f = &cmdutil.Factory{Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") }}
errBuf.Reset()
if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark {
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
t.Errorf("raw-config brand = %q, want lark", got)
}
if errBuf.Len() != 0 {
@@ -1892,7 +1764,7 @@ func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
// Layer 3: nothing readable → default brand with a notice.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
errBuf.Reset()
if got := resolveSkillsBrand(f, &errBuf); got != brand.Feishu {
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandFeishu {
t.Errorf("fallback brand = %q, want feishu", got)
}
if !strings.Contains(errBuf.String(), "could not resolve the configured brand") {
@@ -1912,10 +1784,10 @@ func TestResolveSkillsBrand_RespectsActiveProfile(t *testing.T) {
}
f := &cmdutil.Factory{
Invocation: cmdutil.InvocationContext{Profile: "lark-prof"},
Config: func() (*configpkg.CliConfig, error) { return nil, errors.New("keychain locked") },
Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") },
}
var errBuf bytes.Buffer
if got := resolveSkillsBrand(f, &errBuf); got != brand.Lark {
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
t.Errorf("brand = %q, want lark (the active profile's brand)", got)
}
if errBuf.Len() != 0 {

View File

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

View File

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

View File

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

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
type BotMenuOutput struct {
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
}
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
Operator struct {
OperatorID struct {
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
UserID string `json:"user_id"`
} `json:"operator_id"`
OperatorName string `json:"operator_name"`
} `json:"operator"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = menuTimestamp
}
operatorID := envelope.Event.Operator.OperatorID.OpenID
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
AppID: envelope.Header.AppID,
TenantKey: envelope.Header.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,
OperatorOpenID: operatorID,
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
OperatorName: envelope.Event.Operator.OperatorName,
}
return json.Marshal(out)
}
func rawScalarString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
return s
}
func timestampMillisString(raw json.RawMessage) string {
s := rawScalarString(raw)
if len(s) == 10 && allDigits(s) {
return s + "000"
}
return s
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}

View File

@@ -1,227 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestKeysBotMenuMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeBotMenuV6 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
}
if def.EventType != eventTypeBotMenuV6 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
}
if def.SubscriptionType != "" {
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
}
if def.Schema.Custom == nil {
t.Fatal("Schema.Custom is nil")
}
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
}
if def.Schema.Native != nil {
t.Fatal("Schema.Native must be nil for processed output")
}
if def.Process == nil {
t.Fatal("Process is nil")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
func TestProcessBotMenu(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_001",
"event_type": "application.bot.menu_v6",
"create_time": "1776409469273",
"app_id": "cli_test",
"tenant_key": "tenant_test"
},
"event": {
"event_key": "start_eval",
"timestamp": 1776409469000,
"operator": {
"operator_id": {
"open_id": "ou_operator",
"union_id": "on_operator",
"user_id": "user_operator"
},
"operator_name": "Test User"
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
if out.EventID != "ev_menu_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.EventKey != "start_eval" {
t.Errorf("EventKey = %q", out.EventKey)
}
if out.MenuTimestamp != "1776409469000" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
}
if out.OperatorUnionID != "on_operator" {
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
}
if out.OperatorUserID != "user_operator" {
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
}
if out.OperatorName != "Test User" {
t.Errorf("OperatorName = %q", out.OperatorName)
}
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
}
}
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_002",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": "1776409469001",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1776409469001" {
t.Errorf("Timestamp fallback = %q", out.Timestamp)
}
if out.MenuTimestamp != "1776409469001" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
}
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_seconds",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": 1694592375,
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1694592375000" {
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
}
if out.MenuTimestamp != "1694592375000" {
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
}
}
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_003",
"event_type": "unexpected.event_type",
"create_time": "1776409469275"
},
"event": {
"event_key": "start_eval",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
}
func TestProcessBotMenuMalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)
}
var out BotMenuOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, got)
}
return out
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application registers Application-domain EventKeys.
package application
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeBotMenuV6 = "application.bot.menu_v6"
// Keys returns all Application-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeBotMenuV6,
DisplayName: "Bot menu",
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
EventType: eventTypeBotMenuV6,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
},
Process: processBotMenu,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
},
}
}

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