mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
70 Commits
main
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412dcba4b8 | ||
|
|
e5f66ce22e | ||
|
|
1d313a56b1 | ||
|
|
1864b7fae9 | ||
|
|
22ae7ab04d | ||
|
|
110107458a | ||
|
|
e28a00c2fe | ||
|
|
2f50e39203 | ||
|
|
b5d3e9896e | ||
|
|
a552aed3bc | ||
|
|
70aec2726b | ||
|
|
52894d095b | ||
|
|
7810a01eba | ||
|
|
b33fe32718 | ||
|
|
4229ea7735 | ||
|
|
72c61cc59e | ||
|
|
33458e6770 | ||
|
|
35446837a2 | ||
|
|
9fa28be312 | ||
|
|
bca7f7d30d | ||
|
|
6764949014 | ||
|
|
eb3ace1427 | ||
|
|
8f0d0725fc | ||
|
|
7121ff1e2a | ||
|
|
431160a204 | ||
|
|
490006ee7b | ||
|
|
4e2abab504 | ||
|
|
3e430dd821 | ||
|
|
9efa8b3b69 | ||
|
|
81c3736da2 | ||
|
|
6cbb9d68b8 | ||
|
|
0ff2957c6e | ||
|
|
f334cc9b34 | ||
|
|
41aefd63f0 | ||
|
|
09984fa92a | ||
|
|
de5de57ced | ||
|
|
d2452b7f9c | ||
|
|
911f584ab0 | ||
|
|
08340bf3aa | ||
|
|
a99dc33195 | ||
|
|
0552c5c595 | ||
|
|
bb891e0c50 | ||
|
|
d5f65d1aa4 | ||
|
|
5365cb97ab | ||
|
|
8037bd8037 | ||
|
|
0f88409ab8 | ||
|
|
2cfe090c1d | ||
|
|
dbc1c93b71 | ||
|
|
6ff02ea10c | ||
|
|
46c99cb878 | ||
|
|
8939bff9c5 | ||
|
|
736db1ce72 | ||
|
|
2beb110523 | ||
|
|
112183f447 | ||
|
|
9b9ac8759e | ||
|
|
fdcd9f6dde | ||
|
|
e9fde3e8f7 | ||
|
|
3b9ee1af67 | ||
|
|
8d061ea3bd | ||
|
|
a5386f6053 | ||
|
|
d6c37232e6 | ||
|
|
999ac4e7d6 | ||
|
|
a91f2cdd85 | ||
|
|
d80636d7da | ||
|
|
0a999171be | ||
|
|
1d9f102b36 | ||
|
|
d7820f7c1f | ||
|
|
b8f45c96d7 | ||
|
|
9dc032ca73 | ||
|
|
e9f2da086f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,9 +27,6 @@ Thumbs.db
|
|||||||
# Go
|
# Go
|
||||||
docs/ref
|
docs/ref
|
||||||
docs/
|
docs/
|
||||||
!tests/cli_e2e/docs/
|
|
||||||
!tests/cli_e2e/docs/*.go
|
|
||||||
!tests/cli_e2e/docs/*.md
|
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
112
CHANGELOG.md
112
CHANGELOG.md
@@ -2,112 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [v1.0.65] - 2026-07-03
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **drive**: Document 30-char query limit for `+search` (#1560)
|
|
||||||
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
|
||||||
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
|
||||||
|
|
||||||
## [v1.0.64] - 2026-07-02
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
|
||||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
|
||||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
|
||||||
- **cli**: Improve secure label error handling (#1707)
|
|
||||||
- **cli**: Reduce public content token false positives
|
|
||||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
|
||||||
- **doc**: Align word statistics compound tokens (#1706)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
|
||||||
- **doc**: Support `reference_map` in docs (#1690)
|
|
||||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
|
||||||
|
|
||||||
## [v1.0.62] - 2026-07-01
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **vc**: Add meeting message send shortcut (#1643)
|
|
||||||
- **doc**: Add document word statistics helper (#1697)
|
|
||||||
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
|
||||||
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
|
||||||
- **base**: Support JSON array input for field create (#1661)
|
|
||||||
- **task**: Expose completion state in `my tasks` output (#1641)
|
|
||||||
- **cli**: Reduce public content credential false positives (#1700)
|
|
||||||
|
|
||||||
## [v1.0.61] - 2026-06-30
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
|
||||||
- **identity**: Add `whoami` command showing effective identity (#1666)
|
|
||||||
- **docs**: Add reference map flags (#1547)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
|
||||||
- **cli**: Harden git credential error handling (#1676)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **doc**: Guide document copy skill usage (#1673)
|
|
||||||
- **doc**: Fix lark-doc media token examples (#1662)
|
|
||||||
|
|
||||||
## [v1.0.60] - 2026-06-29
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
|
||||||
- **event**: Support VC meeting lifecycle events (#1632)
|
|
||||||
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
|
||||||
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
|
||||||
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
- **ci**: Reduce public content false positives
|
|
||||||
|
|
||||||
## [v1.0.59] - 2026-06-26
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
|
||||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
|
||||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
|
||||||
|
|
||||||
## [v1.0.58] - 2026-06-25
|
## [v1.0.58] - 2026-06-25
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1371,12 +1265,6 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
|
||||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
|
||||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
|
||||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
|
||||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
|
||||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
|
||||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # Comma-separated values
|
--format csv # Comma-separated values
|
||||||
```
|
```
|
||||||
|
|
||||||
### JSON Output Contract
|
|
||||||
|
|
||||||
With `--format json` (the default), success and error envelopes are distinct.
|
|
||||||
|
|
||||||
Success goes to **stdout**, exit code `0`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors go to **stderr**, non-zero exit code:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
|
||||||
```
|
|
||||||
|
|
||||||
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
README.zh.md
18
README.zh.md
@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # 逗号分隔值
|
--format csv # 逗号分隔值
|
||||||
```
|
```
|
||||||
|
|
||||||
### JSON 输出契约
|
|
||||||
|
|
||||||
`--format json`(默认)下,成功与错误的信封结构不同。
|
|
||||||
|
|
||||||
成功信封写入 **stdout**,退出码 0:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
|
||||||
```
|
|
||||||
|
|
||||||
错误信封写入 **stderr**,退出码非 0:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
|
||||||
```
|
|
||||||
|
|
||||||
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
|
||||||
|
|
||||||
### 分页
|
### 分页
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# Affordance
|
|
||||||
|
|
||||||
Per-command usage guidance for the CLI, authored as one markdown file per domain
|
|
||||||
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
|
|
||||||
`schema` output, and read directly at runtime (lazy, cached) — there is no build
|
|
||||||
step. Maintain these files alongside `skills/` and `shortcuts/`.
|
|
||||||
|
|
||||||
## Format
|
|
||||||
|
|
||||||
A small, fixed markdown subset; each file describes one domain:
|
|
||||||
|
|
||||||
# <domain> optional `> skill: <name>` applies to every command below
|
|
||||||
## <command> the command as typed, minus `lark-cli <domain>`
|
|
||||||
<lead paragraph> when to use this command
|
|
||||||
### Avoid when when not to use it / which command to use instead
|
|
||||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
|
||||||
### Tips gotchas and constraints
|
|
||||||
### Examples **description** lines, each followed by a fenced command
|
|
||||||
### <other heading> a custom section; flows through verbatim
|
|
||||||
|
|
||||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
|
||||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
|
||||||
("… from [[command]]") it means "get the input there first".
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
## messages get
|
|
||||||
Fetch the full content of a single message by id.
|
|
||||||
|
|
||||||
### Avoid when
|
|
||||||
- Reading several at once → use [[messages batch_get]]
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- message_id from [[messages list]]
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
**Fetch one message**
|
|
||||||
```bash
|
|
||||||
lark-cli mail user_mailbox.messages get --message-id "<id>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
|
|
||||||
- Keep it concise and high-signal — don't restate field/flag names, id types, or
|
|
||||||
anything the schema and flags already show; the agent infers the rest.
|
|
||||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
|
||||||
names (`messages`) map to the singular method id (`message`) automatically.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# contact
|
|
||||||
> skill: lark-contact
|
|
||||||
|
|
||||||
## user_profiles batch_query
|
|
||||||
Bulk-fetch personal status and signature for user ids you already have.
|
|
||||||
|
|
||||||
### Avoid when
|
|
||||||
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
- Off by default — set include_personal_status / include_description to true under query_option
|
|
||||||
- ids in user_ids must match --user-id-type (default open_id)
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
**Bulk-query status and signature**
|
|
||||||
```bash
|
|
||||||
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
|
|
||||||
```
|
|
||||||
@@ -67,21 +67,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
|||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "api <method> <path>",
|
Use: "api <method> <path>",
|
||||||
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
|
Short: "Generic Lark API requests",
|
||||||
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
|
Args: cobra.ExactArgs(2),
|
||||||
|
|
||||||
Prefer the typed domain command when one exists — it validates parameters,
|
|
||||||
shows the Risk level, gates destructive calls behind --yes, and carries usage
|
|
||||||
guidance that this raw command does not. If a domain command covers your task
|
|
||||||
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
|
|
||||||
|
|
||||||
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
|
|
||||||
newer/preview APIs), where you already have the HTTP path from the Lark docs.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
|
||||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
opts.Method = strings.ToUpper(args[0])
|
opts.Method = strings.ToUpper(args[0])
|
||||||
opts.Path = args[1]
|
opts.Path = args[1]
|
||||||
|
|||||||
@@ -20,28 +20,13 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
|
||||||
cmd := NewCmdApi(f, runF)
|
|
||||||
cmd.SilenceErrors = true
|
|
||||||
cmd.SilenceUsage = true
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestRootCmd() *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "lark-cli",
|
|
||||||
SilenceErrors: true,
|
|
||||||
SilenceUsage: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -69,7 +54,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||||
@@ -113,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
|||||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,7 +125,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET"}) // missing path
|
cmd.SetArgs([]string{"GET"}) // missing path
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -153,7 +138,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -166,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
fn := cmd.ValidArgsFunction
|
fn := cmd.ValidArgsFunction
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -232,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
flag := cmd.Flags().Lookup("as")
|
flag := cmd.Flags().Lookup("as")
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
t.Fatal("expected --as flag to be registered")
|
t.Fatal("expected --as flag to be registered")
|
||||||
@@ -251,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -270,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -287,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
@@ -312,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|||||||
ContentType: "application/octet-stream",
|
ContentType: "application/octet-stream",
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -343,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -383,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
// Should return an error
|
// Should return an error
|
||||||
@@ -424,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -463,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -498,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -564,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := newTestRootCmd()
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
root.AddCommand(newTestApiCmd(f, nil))
|
root.AddCommand(NewCmdApi(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -615,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := newTestRootCmd()
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
root.AddCommand(newTestApiCmd(f, nil))
|
root.AddCommand(NewCmdApi(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -671,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := newTestRootCmd()
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
root.AddCommand(newTestApiCmd(f, nil))
|
root.AddCommand(NewCmdApi(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := root.Execute()
|
err := root.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -736,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -756,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -775,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||||
@@ -806,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -827,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||||
@@ -845,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||||
@@ -874,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -895,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -914,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -932,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||||
@@ -949,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||||
@@ -966,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||||
@@ -989,7 +974,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
|||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1030,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := newTestApiCmd(f, nil)
|
cmd := NewCmdApi(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1056,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
11
cmd/build.go
11
cmd/build.go
@@ -19,7 +19,6 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
"github.com/larksuite/cli/cmd/skill"
|
"github.com/larksuite/cli/cmd/skill"
|
||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
"github.com/larksuite/cli/cmd/whoami"
|
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
"github.com/larksuite/cli/internal/apicatalog"
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
@@ -171,10 +170,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.SetOut(cfg.streams.Out)
|
rootCmd.SetOut(cfg.streams.Out)
|
||||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||||
|
|
||||||
// Root-only usage template (curated Usage synopsis + skills footer); see
|
|
||||||
// rootUsageTemplate.
|
|
||||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
|
||||||
|
|
||||||
installTipsHelpFunc(rootCmd)
|
installTipsHelpFunc(rootCmd)
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||||
@@ -195,7 +190,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
|
||||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||||
@@ -211,12 +205,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
}
|
}
|
||||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||||
|
|
||||||
groupRootCommands(rootCmd)
|
|
||||||
|
|
||||||
installUnknownSubcommandGuard(rootCmd)
|
installUnknownSubcommandGuard(rootCmd)
|
||||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
|
||||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
|
||||||
installRootUpgradePrompt(f, rootCmd)
|
|
||||||
|
|
||||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
|
|||||||
@@ -129,10 +129,7 @@ func doctorRun(opts *DoctorOptions) error {
|
|||||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||||
} else {
|
} else {
|
||||||
// No hint: this only summarizes the two checks above, which already carry
|
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||||
// the source-appropriate remediation. A command here would be redundant,
|
|
||||||
// or wrong (`auth status` is blocked under an external provider).
|
|
||||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4 & 5. Endpoint reachability ──
|
// ── 4 & 5. Endpoint reachability ──
|
||||||
|
|||||||
@@ -4,19 +4,14 @@
|
|||||||
package doctor
|
package doctor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||||
@@ -145,84 +140,14 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||||
t.Helper()
|
|
||||||
if got := findCheck(t, checks, name); got.Status != status {
|
|
||||||
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, check := range checks {
|
for _, check := range checks {
|
||||||
if check.Name == name {
|
if check.Name == name {
|
||||||
return check
|
if check.Status != status {
|
||||||
|
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("check %q not found in %#v", name, checks)
|
t.Fatalf("check %q not found in %#v", name, checks)
|
||||||
return checkResult{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeExtProvider struct {
|
|
||||||
name string
|
|
||||||
account *extcred.Account
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *fakeExtProvider) Name() string { return p.name }
|
|
||||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
|
||||||
return p.account, nil
|
|
||||||
}
|
|
||||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Under an external credential provider with no usable identity, the
|
|
||||||
// identity_ready hint must not point at `auth status` (blocked there); the
|
|
||||||
// per-identity checks already carry the source-appropriate escalation.
|
|
||||||
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
|
||||||
CurrentApp: "default",
|
|
||||||
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 := &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,
|
|
||||||
func() (*http.Client, error) { return nil, nil },
|
|
||||||
)
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
f := &cmdutil.Factory{
|
|
||||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
|
||||||
Credential: cred,
|
|
||||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
|
||||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
|
||||||
}
|
|
||||||
var got struct {
|
|
||||||
Checks []checkResult `json:"checks"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
ready := findCheck(t, got.Checks, "identity_ready")
|
|
||||||
if ready.Status != "fail" {
|
|
||||||
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
|
|
||||||
}
|
|
||||||
// The summary defers to the per-identity checks; it carries no hint of its
|
|
||||||
// own (a command here would be wrong under an external provider).
|
|
||||||
if ready.Hint != "" {
|
|
||||||
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
|
|
||||||
}
|
|
||||||
user := findCheck(t, got.Checks, "user_identity")
|
|
||||||
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
|
|
||||||
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,10 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
|
||||||
|
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
|
||||||
for _, key := range []string{
|
|
||||||
"vc.meeting.participant_meeting_started_v1",
|
|
||||||
"vc.meeting.participant_meeting_joined_v1",
|
|
||||||
} {
|
|
||||||
if _, ok := eventlib.Lookup(key); !ok {
|
|
||||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunList_TextOutput(t *testing.T) {
|
func TestRunList_TextOutput(t *testing.T) {
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
|
||||||
@@ -39,8 +27,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
|||||||
"im.message.receive_v1",
|
"im.message.receive_v1",
|
||||||
"im.message.message_read_v1",
|
"im.message.message_read_v1",
|
||||||
"task.task.update_user_access_v2",
|
"task.task.update_user_access_v2",
|
||||||
"vc.meeting.participant_meeting_started_v1",
|
|
||||||
"vc.meeting.participant_meeting_joined_v1",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||||
@@ -71,15 +57,9 @@ func TestRunList_JSONOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gotKeys := map[string]map[string]interface{}{}
|
|
||||||
for _, row := range rows {
|
|
||||||
if key, ok := row["key"].(string); ok {
|
|
||||||
gotKeys[key] = row
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var foundTask bool
|
var foundTask bool
|
||||||
for key, row := range gotKeys {
|
for _, row := range rows {
|
||||||
if key == "task.task.update_user_access_v2" {
|
if row["key"] == "task.task.update_user_access_v2" {
|
||||||
foundTask = true
|
foundTask = true
|
||||||
if row["single_consumer"] != true {
|
if row["single_consumer"] != true {
|
||||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||||
@@ -89,12 +69,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
|||||||
if !foundTask {
|
if !foundTask {
|
||||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
|
||||||
"vc.meeting.participant_meeting_started_v1",
|
|
||||||
"vc.meeting.participant_meeting_joined_v1",
|
|
||||||
} {
|
|
||||||
if _, ok := gotKeys[want]; !ok {
|
|
||||||
t.Errorf("JSON list output missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,45 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
|
||||||
for _, key := range []string{
|
|
||||||
"vc.meeting.participant_meeting_started_v1",
|
|
||||||
"vc.meeting.participant_meeting_joined_v1",
|
|
||||||
} {
|
|
||||||
t.Run(key, func(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
|
||||||
|
|
||||||
if err := runSchema(f, key, true); err != nil {
|
|
||||||
t.Fatalf("runSchema json: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if payload["key"] != key {
|
|
||||||
t.Errorf("key = %v, want %s", payload["key"], key)
|
|
||||||
}
|
|
||||||
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
|
|
||||||
}
|
|
||||||
properties, ok := resolved["properties"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
|
|
||||||
}
|
|
||||||
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
|
|
||||||
if _, ok := properties[field]; !ok {
|
|
||||||
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := properties["end_time"]; ok {
|
|
||||||
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||||
const syntheticKey = "test.evt_sub"
|
const syntheticKey = "test.evt_sub"
|
||||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
|
|||||||
127
cmd/root.go
127
cmd/root.go
@@ -11,11 +11,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/cmd/service"
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdmeta"
|
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/deprecation"
|
"github.com/larksuite/cli/internal/deprecation"
|
||||||
@@ -30,60 +28,43 @@ import (
|
|||||||
|
|
||||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||||
|
|
||||||
AGENT QUICKSTART (driving this as an agent? start here):
|
USAGE:
|
||||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
lark-cli <command> [subcommand] [method] [options]
|
||||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
lark-cli schema <service.resource.method>
|
||||||
Risk: each command's --help shows read | write | high-risk-write;
|
|
||||||
high-risk-write needs --yes, only after the user confirms.
|
|
||||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
|
||||||
|
|
||||||
EXAMPLES (one per command style, in order of preference):
|
EXAMPLES:
|
||||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
# View upcoming events
|
||||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
lark-cli calendar +agenda
|
||||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
|
||||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
|
||||||
|
|
||||||
// rootUsageTemplate is cobra's default usage template with two root-only
|
# List calendar events
|
||||||
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||||
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
|
||||||
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
|
||||||
// cobra so the command groups and flags are untouched.
|
|
||||||
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
|
||||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
|
||||||
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
|
||||||
lark-cli <command> [subcommand] [method] [flags]
|
|
||||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
|
||||||
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
|
||||||
|
|
||||||
Aliases:
|
# Search users
|
||||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
lark-cli contact +search-user --query "John"
|
||||||
|
|
||||||
Examples:
|
# Generic API call
|
||||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||||
|
|
||||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
AI AGENT SKILLS:
|
||||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||||
|
teach the agent Lark API patterns, best practices, and workflows.
|
||||||
|
|
||||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
Install all skills:
|
||||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
npx skills add larksuite/cli -g -y
|
||||||
|
|
||||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
Or pick specific domains:
|
||||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
npx skills add larksuite/cli -s lark-calendar -y
|
||||||
|
npx skills add larksuite/cli -s lark-im -y
|
||||||
|
|
||||||
Flags:
|
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
|
||||||
|
|
||||||
Global Flags:
|
COMMUNITY:
|
||||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
GitHub: https://github.com/larksuite/cli
|
||||||
|
Issues: https://github.com/larksuite/cli/issues
|
||||||
|
Docs: https://open.feishu.cn/document/
|
||||||
|
|
||||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
More help: lark-cli <command> --help`
|
||||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
|
||||||
|
|
||||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
|
||||||
|
|
||||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
|
||||||
`
|
|
||||||
|
|
||||||
// Execute runs the root command and returns the process exit code.
|
// Execute runs the root command and returns the process exit code.
|
||||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||||
@@ -548,49 +529,6 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
|||||||
return available, deprecated
|
return available, deprecated
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root command help groups, so an agent sees content domains, agent tooling, and
|
|
||||||
// CLI management as distinct blocks instead of one flat alphabetical dump.
|
|
||||||
const (
|
|
||||||
groupDomains = "lark-domains"
|
|
||||||
groupTooling = "agent-tooling"
|
|
||||||
groupManagement = "cli-management"
|
|
||||||
)
|
|
||||||
|
|
||||||
// groupRootCommands classifies root's direct children into the help groups,
|
|
||||||
// called once after all commands are registered. Unclassified commands fall to
|
|
||||||
// cobra's "Additional Commands" section.
|
|
||||||
func groupRootCommands(root *cobra.Command) {
|
|
||||||
root.AddGroup(
|
|
||||||
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
|
||||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
|
||||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
|
||||||
)
|
|
||||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
|
||||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
|
||||||
for _, c := range root.Commands() {
|
|
||||||
if c.GroupID != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case tooling[c.Name()]:
|
|
||||||
c.GroupID = groupTooling
|
|
||||||
case management[c.Name()]:
|
|
||||||
c.GroupID = groupManagement
|
|
||||||
case isLarkDomain(c):
|
|
||||||
c.GroupID = groupDomains
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
|
||||||
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
|
||||||
func isLarkDomain(c *cobra.Command) bool {
|
|
||||||
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return cmdmeta.Domain(c) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||||
@@ -672,17 +610,6 @@ func installTipsHelpFunc(root *cobra.Command) {
|
|||||||
defer func() { f.Hidden = true }()
|
defer func() { f.Hidden = true }()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Domain and method commands compose their agent guidance into Long lazily
|
|
||||||
// here (shortcuts attach after service registration); both skip the generic
|
|
||||||
// bottom-of-help append below.
|
|
||||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
|
||||||
defaultHelp(cmd, args)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if service.PrepareMethodHelp(cmd) {
|
|
||||||
defaultHelp(cmd, args)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultHelp(cmd, args)
|
defaultHelp(cmd, args)
|
||||||
out := cmd.OutOrStdout()
|
out := cmd.OutOrStdout()
|
||||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||||
|
|||||||
@@ -76,13 +76,11 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||||
// The human skills-install guidance now lives in the root usage-template
|
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||||
// footer (below the command list), not in the agent-facing Long.
|
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||||
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
|
||||||
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/build"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/update"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
|
|
||||||
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
|
|
||||||
// (install-method detection, output, error handling). Package-level var so
|
|
||||||
// tests can stub it and avoid real network / self-update.
|
|
||||||
var runRootUpgrade = func(cmd *cobra.Command) {
|
|
||||||
for _, c := range cmd.Root().Commands() {
|
|
||||||
if c.Name() == "update" && c.RunE != nil {
|
|
||||||
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
|
||||||
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
|
||||||
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
|
||||||
// AND no flag tokens in the raw invocation.
|
|
||||||
func isBareRootInvocation(args []string) bool {
|
|
||||||
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// readYes reads one line and reports whether it is an affirmative y/yes.
|
|
||||||
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
|
|
||||||
func readYes(r io.Reader) bool {
|
|
||||||
line, _ := bufio.NewReader(r).ReadString('\n')
|
|
||||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
|
||||||
case "y", "yes":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
|
||||||
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
|
||||||
// failure is swallowed — it must never affect help output or the exit code.
|
|
||||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
|
||||||
ios := f.IOStreams
|
|
||||||
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
|
||||||
// stdout TTY too so this only fires in a pure foreground terminal session.
|
|
||||||
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
|
||||||
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
|
||||||
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
|
||||||
info := update.CheckCached(build.Version)
|
|
||||||
if info == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
|
||||||
if !readYes(ios.In) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runRootUpgrade(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// installRootUpgradePrompt wraps the root command's RunE (set to
|
|
||||||
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
|
||||||
// invocation offers an interactive upgrade before printing help. Non-bare
|
|
||||||
// invocations are passed straight through, unchanged.
|
|
||||||
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
|
||||||
inner := root.RunE
|
|
||||||
if inner == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
|
||||||
if isBareRootInvocation(args) {
|
|
||||||
offerRootUpgrade(f, cmd)
|
|
||||||
}
|
|
||||||
return inner(cmd, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/build"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func writeUpdateState(t *testing.T, dir, latest string) {
|
|
||||||
t.Helper()
|
|
||||||
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadYes(t *testing.T) {
|
|
||||||
cases := map[string]bool{
|
|
||||||
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
|
|
||||||
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := readYes(strings.NewReader(in)); got != want {
|
|
||||||
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsBareRootInvocation(t *testing.T) {
|
|
||||||
orig := rawInvocationArgs
|
|
||||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
|
||||||
|
|
||||||
rawInvocationArgs = nil
|
|
||||||
if !isBareRootInvocation([]string{}) {
|
|
||||||
t.Error("empty args + no raw flag tokens should be bare")
|
|
||||||
}
|
|
||||||
rawInvocationArgs = []string{"--profile", "x"}
|
|
||||||
if isBareRootInvocation([]string{}) {
|
|
||||||
t.Error("flag token present → not bare")
|
|
||||||
}
|
|
||||||
rawInvocationArgs = nil
|
|
||||||
if isBareRootInvocation([]string{"im"}) {
|
|
||||||
t.Error("positional arg → not bare")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOfferRootUpgrade(t *testing.T) {
|
|
||||||
origV := build.Version
|
|
||||||
build.Version = "1.0.0" // release version so shouldSkip()==false
|
|
||||||
t.Cleanup(func() { build.Version = origV })
|
|
||||||
|
|
||||||
origRun := runRootUpgrade
|
|
||||||
t.Cleanup(func() { runRootUpgrade = origRun })
|
|
||||||
|
|
||||||
// This test builds a Factory literal (no NewDefault), so it never runs
|
|
||||||
// workspace detection; pin the process-global workspace to Local so
|
|
||||||
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
|
|
||||||
// subdir inherited from a prior test in the package.
|
|
||||||
origWS := core.CurrentWorkspace()
|
|
||||||
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
|
|
||||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in, out, err bool
|
|
||||||
input string
|
|
||||||
latest string // "" → no state file (CheckCached nil)
|
|
||||||
optOut bool
|
|
||||||
wantPrompt, wantRun bool
|
|
||||||
}{
|
|
||||||
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
|
|
||||||
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
|
|
||||||
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
|
|
||||||
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
|
|
||||||
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
|
|
||||||
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
|
|
||||||
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
|
|
||||||
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
|
|
||||||
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
|
|
||||||
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
|
|
||||||
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
|
|
||||||
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
|
||||||
// Clear env that update.shouldSkip treats as "suppress" so the
|
|
||||||
// test is deterministic regardless of host (GitHub Actions sets
|
|
||||||
// CI=true, which would otherwise suppress the prompt).
|
|
||||||
t.Setenv("CI", "")
|
|
||||||
t.Setenv("BUILD_NUMBER", "")
|
|
||||||
t.Setenv("RUN_ID", "")
|
|
||||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
|
||||||
if tc.latest != "" {
|
|
||||||
writeUpdateState(t, dir, tc.latest)
|
|
||||||
}
|
|
||||||
if tc.optOut {
|
|
||||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
|
||||||
}
|
|
||||||
called := false
|
|
||||||
runRootUpgrade = func(*cobra.Command) { called = true }
|
|
||||||
|
|
||||||
var errBuf bytes.Buffer
|
|
||||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
|
||||||
In: strings.NewReader(tc.input),
|
|
||||||
Out: &bytes.Buffer{},
|
|
||||||
ErrOut: &errBuf,
|
|
||||||
IsTerminal: tc.in,
|
|
||||||
OutIsTerminal: tc.out,
|
|
||||||
StderrIsTerminal: tc.err,
|
|
||||||
}}
|
|
||||||
offerRootUpgrade(f, &cobra.Command{})
|
|
||||||
|
|
||||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
|
||||||
if gotPrompt != tc.wantPrompt {
|
|
||||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
|
||||||
}
|
|
||||||
if called != tc.wantRun {
|
|
||||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
|
||||||
orig := rawInvocationArgs
|
|
||||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
|
||||||
rawInvocationArgs = nil
|
|
||||||
|
|
||||||
innerCalls := 0
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
|
||||||
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
|
|
||||||
|
|
||||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
|
||||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
|
||||||
}}
|
|
||||||
installRootUpgradePrompt(f, root)
|
|
||||||
|
|
||||||
if err := root.RunE(root, []string{}); err != nil {
|
|
||||||
t.Fatalf("bare RunE err = %v", err)
|
|
||||||
}
|
|
||||||
if err := root.RunE(root, []string{"im"}); err != nil {
|
|
||||||
t.Fatalf("non-bare RunE err = %v", err)
|
|
||||||
}
|
|
||||||
if innerCalls != 2 {
|
|
||||||
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
|
|
||||||
// path (not the stub used elsewhere): from any command it must locate the
|
|
||||||
// registered "update" subcommand via cmd.Root() and invoke its RunE.
|
|
||||||
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
|
||||||
ran := 0
|
|
||||||
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
|
|
||||||
child := &cobra.Command{Use: "im"}
|
|
||||||
root.AddCommand(child)
|
|
||||||
|
|
||||||
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
|
|
||||||
|
|
||||||
if ran != 1 {
|
|
||||||
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
|
|
||||||
// when root has no RunE, installRootUpgradePrompt must not wrap it.
|
|
||||||
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
|
||||||
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
|
|
||||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
|
||||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
|
||||||
}}
|
|
||||||
installRootUpgradePrompt(f, root)
|
|
||||||
if root.RunE != nil {
|
|
||||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||||
// It uses the same source as schema execution so completion candidates match
|
// It uses the embedded source so completion candidates match what `schema`
|
||||||
// what `schema` can resolve.
|
// execution can resolve (both overlay-free).
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||||
directive := cobra.ShellCompDirectiveNoFileComp
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
if noSpace {
|
if noSpace {
|
||||||
directive |= cobra.ShellCompDirectiveNoSpace
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
@@ -86,19 +86,13 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSchema resolves the path through the schema catalog and renders the
|
// runSchema resolves the path through the embedded catalog and renders the
|
||||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||||
// output shape — a single resolved method renders as one envelope object,
|
// output shape — a single resolved method renders as one envelope object,
|
||||||
// anything broader as an array — and maps resolve failures to hints.
|
// anything broader as an array — and maps resolve failures to hints.
|
||||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||||
catalog := registry.SchemaCatalog()
|
catalog := registry.EmbeddedCatalog()
|
||||||
if len(catalog.Services()) == 0 {
|
|
||||||
// No embedded metadata and the runtime fallback is empty too: offline
|
|
||||||
// with a cold cache, remote meta off, or an unwritable cache dir.
|
|
||||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
|
||||||
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
|
||||||
}
|
|
||||||
target, err := catalog.Resolve(parts)
|
target, err := catalog.Resolve(parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolveError(err)
|
return resolveError(err)
|
||||||
|
|||||||
@@ -4,211 +4,41 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/affordance"
|
|
||||||
"github.com/larksuite/cli/internal/cmdmeta"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
|
// methodLong composes a method command's long help in one place: the
|
||||||
// skill pointer) to a top-level Lark domain's description, returning false for
|
// description, the affordance guidance block (when the method has one), the
|
||||||
// anything that is not such a domain. Built lazily at help time because
|
// pointer to the full schema, and the params-only addendum (params whose flag
|
||||||
// shortcuts attach after service registration. skillFS (nil-safe) gates the
|
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
||||||
// skill pointer.
|
// sits near the top so an agent sees when-to-use and few-shot examples before
|
||||||
//
|
// the flag list.
|
||||||
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
|
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
||||||
// consume <EventKey>'…"); service domains carry only a Short at this point, so
|
|
||||||
// we fall back to it. The pristine base is captured once into an annotation so
|
|
||||||
// re-rendering does not append the guidance twice.
|
|
||||||
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
|
||||||
if cmd.Annotations[schemaPathAnnotation] != "" {
|
|
||||||
return false // a method command
|
|
||||||
}
|
|
||||||
// Direct child of root only — so Domain() reads this command's own tag, and
|
|
||||||
// nested resource groups are excluded.
|
|
||||||
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
|
|
||||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !cmd.HasAvailableSubCommands() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
hasShortcuts, hasResources := false, false
|
|
||||||
for _, c := range cmd.Commands() {
|
|
||||||
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(c.Name(), "+") {
|
|
||||||
hasShortcuts = true
|
|
||||||
} else {
|
|
||||||
hasResources = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(domainHelpBase(cmd))
|
|
||||||
if hasShortcuts && hasResources { // routing only matters when both styles exist
|
|
||||||
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
|
|
||||||
}
|
|
||||||
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
|
|
||||||
if skill := "lark-" + cmd.Name(); skillFS != nil {
|
|
||||||
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
|
|
||||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cmd.Long = b.String()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainHelpBase returns the description to seed domain help with — the
|
|
||||||
// hand-authored Long when present, else the Short — captured once into an
|
|
||||||
// annotation so re-rendering reuses the pristine text instead of the
|
|
||||||
// already-augmented Long.
|
|
||||||
func domainHelpBase(cmd *cobra.Command) string {
|
|
||||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
base := cmd.Long
|
|
||||||
if base == "" {
|
|
||||||
base = cmd.Short
|
|
||||||
}
|
|
||||||
if cmd.Annotations == nil {
|
|
||||||
cmd.Annotations = map[string]string{}
|
|
||||||
}
|
|
||||||
cmd.Annotations[domainBaseAnnotation] = base
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
// methodLong is the build-time Long (description + schema pointer +
|
|
||||||
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
|
|
||||||
// so command construction never parses the overlay.
|
|
||||||
func methodLong(description, schemaPath, paramsOnly string) string {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(description)
|
b.WriteString(description)
|
||||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
if affordance != "" {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(affordance)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||||
b.WriteString(paramsOnly)
|
b.WriteString(paramsOnly)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
// renderAffordance renders a method's affordance as a help block — when to use,
|
||||||
const (
|
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
||||||
affordanceServiceAnnotation = "affordance-service"
|
// the method carries no affordance. It reads the single typed model
|
||||||
affordanceMethodAnnotation = "affordance-method"
|
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
||||||
schemaPathAnnotation = "method-schema-path"
|
|
||||||
paramsOnlyAnnotation = "method-params-only"
|
|
||||||
domainBaseAnnotation = "affordance-domain-base"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
|
||||||
// few strings is the only build-time cost; the overlay stays untouched).
|
|
||||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
|
||||||
if cmd.Annotations == nil {
|
|
||||||
cmd.Annotations = map[string]string{}
|
|
||||||
}
|
|
||||||
if service != "" && methodID != "" {
|
|
||||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
|
||||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
|
||||||
}
|
|
||||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
|
||||||
if paramsOnly != "" {
|
|
||||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
|
||||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
|
||||||
// pointer), returning false for non-method commands. The overlay is parsed
|
|
||||||
// here — only when help is rendered.
|
|
||||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
|
||||||
ann := cmd.Annotations
|
|
||||||
if ann == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
schemaPath, ok := ann[schemaPathAnnotation]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(cmd.Short)
|
|
||||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
|
||||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
|
||||||
if level == cmdutil.RiskHighRiskWrite {
|
|
||||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var skills []string
|
|
||||||
if raw, ok := affordanceRaw(cmd); ok {
|
|
||||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(block)
|
|
||||||
}
|
|
||||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
|
||||||
skills = a.Skills
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
|
||||||
b.WriteString(ann[paramsOnlyAnnotation])
|
|
||||||
|
|
||||||
if len(skills) > 0 {
|
|
||||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
|
||||||
for _, s := range skills {
|
|
||||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Long = b.String()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
|
||||||
var affordanceLookup = affordance.For
|
|
||||||
|
|
||||||
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
|
|
||||||
// it carries none.
|
|
||||||
func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
|
||||||
raw, ok := affordanceRaw(cmd)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return renderAffordance(meta.Method{Affordance: raw})
|
|
||||||
}
|
|
||||||
|
|
||||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
|
||||||
if cmd.Annotations == nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
|
||||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
|
||||||
if service == "" || methodID == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return affordanceLookup(service, methodID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderAffordance renders a method's affordance as a help block, or "" when it
|
|
||||||
// has none. Sections are joined with blank lines so they scan as distinct groups.
|
|
||||||
func renderAffordance(m meta.Method) string {
|
func renderAffordance(m meta.Method) string {
|
||||||
a, ok := m.ParsedAffordance()
|
a, ok := m.ParsedAffordance()
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var sections []string
|
var b strings.Builder
|
||||||
bullets := func(title string, items []string) {
|
bullets := func(title string, items []string) {
|
||||||
var nonEmpty []string
|
var nonEmpty []string
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
@@ -219,18 +49,15 @@ func renderAffordance(m meta.Method) string {
|
|||||||
if len(nonEmpty) == 0 {
|
if len(nonEmpty) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var s strings.Builder
|
fmt.Fprintf(&b, "%s:\n", title)
|
||||||
fmt.Fprintf(&s, "%s:\n", title)
|
|
||||||
for _, it := range nonEmpty {
|
for _, it := range nonEmpty {
|
||||||
fmt.Fprintf(&s, " • %s\n", it)
|
fmt.Fprintf(&b, " • %s\n", it)
|
||||||
}
|
}
|
||||||
sections = append(sections, strings.TrimRight(s.String(), "\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bullets("When to use", a.UseWhen)
|
bullets("When to use", a.UseWhen)
|
||||||
bullets("Avoid when", a.AvoidWhen)
|
bullets("Avoid when", a.DoNotUseWhen)
|
||||||
bullets("Prerequisites", a.Prerequisites)
|
bullets("Prerequisites", a.Prerequisites)
|
||||||
bullets("Tips", a.Tips)
|
|
||||||
if len(a.Examples) > 0 {
|
if len(a.Examples) > 0 {
|
||||||
var lines []string
|
var lines []string
|
||||||
for _, ex := range a.Examples {
|
for _, ex := range a.Examples {
|
||||||
@@ -244,13 +71,10 @@ func renderAffordance(m meta.Method) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
|
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, ext := range a.Extensions {
|
|
||||||
bullets(ext.Label, ext.Items)
|
|
||||||
}
|
|
||||||
bullets("Related", a.Related)
|
bullets("Related", a.Related)
|
||||||
|
|
||||||
return strings.Join(sections, "\n\n")
|
return strings.TrimRight(b.String(), "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdmeta"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderAffordance(t *testing.T) {
|
func TestRenderAffordance(t *testing.T) {
|
||||||
raw := json.RawMessage(`{
|
raw := json.RawMessage(`{
|
||||||
"use_when": ["发送文本消息"],
|
"use_when": ["发送文本消息"],
|
||||||
"avoid_when": ["群已解散"],
|
"do_not_use_when": ["群已解散"],
|
||||||
"prerequisites": ["已获取 chat_id"],
|
"prerequisites": ["已获取 chat_id"],
|
||||||
"tips": ["富文本用 msg_type=post"],
|
|
||||||
"examples": [
|
"examples": [
|
||||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||||
{"command":"lark-cli im messages list"},
|
{"command":"lark-cli im messages list"},
|
||||||
@@ -32,7 +29,6 @@ func TestRenderAffordance(t *testing.T) {
|
|||||||
"When to use:", "发送文本消息",
|
"When to use:", "发送文本消息",
|
||||||
"Avoid when:", "群已解散",
|
"Avoid when:", "群已解散",
|
||||||
"Prerequisites:", "已获取 chat_id",
|
"Prerequisites:", "已获取 chat_id",
|
||||||
"Tips:", "富文本用 msg_type=post",
|
|
||||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||||
"lark-cli im messages list", // example with no description -> bare command line
|
"lark-cli im messages list", // example with no description -> bare command line
|
||||||
"Related:", "im.messages.list",
|
"Related:", "im.messages.list",
|
||||||
@@ -52,12 +48,9 @@ func TestRenderAffordance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Affordance is rendered lazily (at --help time) rather than baked into the
|
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||||
// command's Long, so building a command never carries the affordance block —
|
|
||||||
// even for a method whose metadata happens to declare one.
|
|
||||||
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
|
||||||
withAff := map[string]interface{}{
|
withAff := map[string]interface{}{
|
||||||
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
|
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||||
"affordance": map[string]interface{}{
|
"affordance": map[string]interface{}{
|
||||||
"examples": []interface{}{
|
"examples": []interface{}{
|
||||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||||
@@ -66,120 +59,14 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||||
if strings.Contains(cmd.Long, "Examples:") {
|
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
||||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
||||||
}
|
}
|
||||||
// The lookup ref is recorded so the help path can resolve it later.
|
|
||||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
// A method with no affordance adds no guidance block.
|
||||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
||||||
}
|
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
||||||
}
|
if strings.Contains(cmd2.Long, "Examples:") {
|
||||||
|
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
||||||
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
|
|
||||||
// lookup and renders it; commands without a ref render nothing.
|
|
||||||
func TestRenderAffordanceForCmd(t *testing.T) {
|
|
||||||
orig := affordanceLookup
|
|
||||||
t.Cleanup(func() { affordanceLookup = orig })
|
|
||||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
|
||||||
if service != "im" || methodID != "messages.create" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
||||||
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
|
||||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
|
|
||||||
block := RenderAffordanceForCmd(cmd)
|
|
||||||
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
|
|
||||||
if !strings.Contains(block, want) {
|
|
||||||
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No overlay for this method id -> empty block.
|
|
||||||
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
|
|
||||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
|
|
||||||
if got := RenderAffordanceForCmd(cmd2); got != "" {
|
|
||||||
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrepareMethodHelp composes the guidance into Long at the top: description,
|
|
||||||
// then the affordance block, then the full-schema pointer — so an agent reads
|
|
||||||
// when-to-use/examples before the flag list.
|
|
||||||
func TestPrepareMethodHelp(t *testing.T) {
|
|
||||||
orig := affordanceLookup
|
|
||||||
t.Cleanup(func() { affordanceLookup = orig })
|
|
||||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
|
||||||
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
|
||||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
|
||||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
|
||||||
|
|
||||||
if !PrepareMethodHelp(cmd) {
|
|
||||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
|
||||||
}
|
|
||||||
long := cmd.Long
|
|
||||||
// Description leads; affordance block sits above the schema pointer.
|
|
||||||
descAt := strings.Index(long, "发送消息")
|
|
||||||
useAt := strings.Index(long, "When to use:")
|
|
||||||
exAt := strings.Index(long, "Examples:")
|
|
||||||
schemaAt := strings.Index(long, "Full parameter schema:")
|
|
||||||
if descAt != 0 {
|
|
||||||
t.Errorf("description should lead Long, got:\n%s", long)
|
|
||||||
}
|
|
||||||
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
|
|
||||||
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A non-service command (no schema-path annotation) is left untouched.
|
|
||||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
|
||||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
|
||||||
// shape PrepareDomainHelp expects.
|
|
||||||
func domainCmd(short, long string) *cobra.Command {
|
|
||||||
root := &cobra.Command{Use: "root"}
|
|
||||||
dom := &cobra.Command{Use: "event", Short: short, Long: long}
|
|
||||||
cmdmeta.SetDomain(dom, "event")
|
|
||||||
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
|
|
||||||
root.AddCommand(dom)
|
|
||||||
return dom
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
|
||||||
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
|
|
||||||
dom := domainCmd("Consume and manage real-time events", long)
|
|
||||||
|
|
||||||
if !PrepareDomainHelp(dom, nil) {
|
|
||||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(dom.Long, long) {
|
|
||||||
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
|
|
||||||
}
|
|
||||||
if !strings.Contains(dom.Long, "Risk levels") {
|
|
||||||
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-rendering must not append the guidance a second time.
|
|
||||||
PrepareDomainHelp(dom, nil)
|
|
||||||
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
|
|
||||||
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A service domain carries only a Short at help time; it seeds the base.
|
|
||||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
|
||||||
dom := domainCmd("Message and group chat management", "")
|
|
||||||
if !PrepareDomainHelp(dom, nil) {
|
|
||||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
|
|
||||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,8 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
|||||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||||
}
|
}
|
||||||
// The redundant "<name>, required|optional." prefix is gone: required-ness is
|
if !strings.Contains(out, "chat_id, required") {
|
||||||
// carried by the Required:/Optional: subheadings, and the snake-case --params
|
t.Errorf("typed flag help format wrong:\n%s", out)
|
||||||
// key by the schema envelope — so it isn't echoed on every flag line.
|
|
||||||
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
|
|
||||||
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ func fieldFacts(f meta.Field) []string {
|
|||||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||||
facts = append(facts, d)
|
facts = append(facts, d)
|
||||||
}
|
}
|
||||||
if f.CanonicalType() == "boolean" {
|
|
||||||
// cobra shows no type word for bools and swallows a separate value as a
|
|
||||||
// positional, so spell out the presence-only contract.
|
|
||||||
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
|
|
||||||
}
|
|
||||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||||
}
|
}
|
||||||
@@ -47,15 +42,20 @@ func fieldFacts(f meta.Field) []string {
|
|||||||
return facts
|
return facts
|
||||||
}
|
}
|
||||||
|
|
||||||
// paramFlagUsage renders the typed param flag's help line: the field's facts
|
// paramFlagUsage renders the typed param flag's help line:
|
||||||
// joined inline. Required/optional is not repeated here — the grouped help's
|
//
|
||||||
// Required:/Optional: subheadings already partition the flags — and the
|
// <param_name>, required|optional[. <fact>]...
|
||||||
// snake-case --params key is carried by the schema envelope (each param's
|
//
|
||||||
// property + "flag") and the params-only addendum, so it isn't echoed on every
|
// It leads with the canonical underscore param name (the key this flag
|
||||||
// line either. Returns "" when the field has no facts (cobra then shows the bare
|
// overrides in --params) and required/optional, then joins the field's facts
|
||||||
// flag with its type).
|
// inline.
|
||||||
func paramFlagUsage(f meta.Field) string {
|
func paramFlagUsage(f meta.Field) string {
|
||||||
return strings.Join(fieldFacts(f), ". ")
|
req := "optional"
|
||||||
|
if f.Required {
|
||||||
|
req = "required"
|
||||||
|
}
|
||||||
|
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||||
|
return strings.Join(parts, ". ") + "."
|
||||||
}
|
}
|
||||||
|
|
||||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||||
@@ -103,23 +103,8 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
|
|||||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||||
// keep full sentences and cut only at note separators (meta_data appends
|
// keep full sentences and cut only at note separators (meta_data appends
|
||||||
// bullet notes after ;/;) — the later sentence often carries the key
|
// bullet notes after ;/;) — the later sentence often carries the key
|
||||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
|
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
||||||
// cross-reference is dropped first (see cutDocRef).
|
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||||
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) }
|
|
||||||
|
|
||||||
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
|
|
||||||
// On the compact flag line the markdown link's URL is stripped, so the
|
|
||||||
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
|
|
||||||
// so a subject that runs straight into the phrase isn't orphaned.
|
|
||||||
var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
|
|
||||||
|
|
||||||
// cutDocRef truncates s at the first doc-reference breadcrumb.
|
|
||||||
func cutDocRef(s string) string {
|
|
||||||
if loc := docRefRe.FindStringIndex(s); loc != nil {
|
|
||||||
return s[:loc[0]]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
@@ -65,38 +64,15 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
|
|||||||
// resource-command chain — one level for a flat dotted resource like
|
// resource-command chain — one level for a flat dotted resource like
|
||||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||||
// methods keeps its bare command (svcCmd is created above regardless).
|
// methods keeps its bare command (svcCmd is created above regardless).
|
||||||
refs := apicatalog.ServiceMethods(svc, nil)
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
|
|
||||||
// Collect each resource's verbs up front so resourceShort can summarize a
|
|
||||||
// resource as its verb list from the first ensureChildCommand call.
|
|
||||||
verbs := map[string][]string{}
|
|
||||||
for _, ref := range refs {
|
|
||||||
key := strings.Join(ref.ResourcePath, ".")
|
|
||||||
verbs[key] = append(verbs[key], ref.Method.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ref := range refs {
|
|
||||||
resCmd := svcCmd
|
resCmd := svcCmd
|
||||||
var path []string
|
|
||||||
for _, seg := range ref.ResourcePath {
|
for _, seg := range ref.ResourcePath {
|
||||||
path = append(path, seg)
|
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||||
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
|
|
||||||
}
|
}
|
||||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resourceShort summarizes a resource as its sorted verb list, or the
|
|
||||||
// "<name> operations" placeholder for an intermediate group with no methods.
|
|
||||||
func resourceShort(seg string, verbs []string) string {
|
|
||||||
if len(verbs) == 0 {
|
|
||||||
return seg + " operations"
|
|
||||||
}
|
|
||||||
sorted := append([]string(nil), verbs...)
|
|
||||||
sort.Strings(sorted)
|
|
||||||
return strings.Join(sorted, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// serviceShort is the service command's help summary: the localized description
|
// serviceShort is the service command's help summary: the localized description
|
||||||
// from the registry, falling back to the metadata's own description.
|
// from the registry, falling back to the metadata's own description.
|
||||||
func serviceShort(svc meta.Service) string {
|
func serviceShort(svc meta.Service) string {
|
||||||
@@ -201,19 +177,7 @@ type methodCommandSpec struct {
|
|||||||
// the API declares a body.
|
// the API declares a body.
|
||||||
acceptsBody bool
|
acceptsBody bool
|
||||||
declaresBody bool
|
declaresBody bool
|
||||||
paginates bool // method accepts a page_token param (so --page-all is meaningful)
|
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||||
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
|
|
||||||
}
|
|
||||||
|
|
||||||
// methodPaginates reports whether a method takes a page_token param, the signal
|
|
||||||
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
|
|
||||||
func methodPaginates(m meta.Method) bool {
|
|
||||||
for _, f := range m.Params() {
|
|
||||||
if f.Name == "page_token" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||||
@@ -222,7 +186,6 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
|||||||
method: m,
|
method: m,
|
||||||
schemaPath: ref.SchemaPath(),
|
schemaPath: ref.SchemaPath(),
|
||||||
servicePath: ref.Service.ServicePath,
|
servicePath: ref.Service.ServicePath,
|
||||||
serviceName: ref.Service.Name,
|
|
||||||
risk: m.Risk,
|
risk: m.Risk,
|
||||||
restricts: m.RestrictsIdentity(),
|
restricts: m.RestrictsIdentity(),
|
||||||
identities: m.Identities(),
|
identities: m.Identities(),
|
||||||
@@ -230,7 +193,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
|||||||
fileFields: detectFileFields(m),
|
fileFields: detectFileFields(m),
|
||||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||||
paginates: methodPaginates(m),
|
affordance: renderAffordance(m),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,14 +254,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
|||||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||||
// Keep the pagination flags registered (a harmless no-op if passed) but hide
|
|
||||||
// them from help on non-paginating commands, so help doesn't imply a
|
|
||||||
// get/write can paginate.
|
|
||||||
if !spec.paginates {
|
|
||||||
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
|
|
||||||
_ = cmd.Flags().MarkHidden(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||||
@@ -316,11 +271,10 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
|||||||
|
|
||||||
// Registered last so the collision guard sees the standard flags above.
|
// Registered last so the collision guard sees the standard flags above.
|
||||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||||
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
|
// Single composition point for Long: description, affordance, schema
|
||||||
// (setMethodHelpData records the coordinates it needs).
|
// pointer, and the binder's params-only addendum (params whose flag name is
|
||||||
paramsOnly := opts.binder.paramsOnlyHelp()
|
// taken, reachable via --params only).
|
||||||
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
|
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
||||||
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
|
|
||||||
|
|
||||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||||
@@ -338,11 +292,13 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
|||||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||||
// Keep the precedence rule on the flag's own one line (not a multi-line
|
// State the precedence rule where the agent reads it: --params is the
|
||||||
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
|
// base, typed flags override. Only meaningful when typed flags exist.
|
||||||
// meaningful when typed flags exist to override.
|
|
||||||
if len(spec.params) > 0 {
|
if len(spec.params) > 0 {
|
||||||
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
|
annotate(fl, flagNoteAnnotation, []string{
|
||||||
|
"Typed API parameter flags above are preferred.",
|
||||||
|
"If both are set, typed flags override matching keys in --params.",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
Long: `Update lark-cli to the latest version.
|
Long: `Update lark-cli to the latest version.
|
||||||
|
|
||||||
Detects the installation method automatically:
|
Detects the installation method automatically:
|
||||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
|
||||||
- manual/other: shows GitHub Releases download URL
|
- manual/other: shows GitHub Releases download URL
|
||||||
|
|
||||||
Use --json for structured output (for AI agents and scripts).
|
Use --json for structured output (for AI agents and scripts).
|
||||||
@@ -165,7 +164,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
if !detect.CanAutoUpdate() {
|
if !detect.CanAutoUpdate() {
|
||||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Output helpers ---
|
// --- Output helpers ---
|
||||||
@@ -227,23 +226,12 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
|||||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if detect.Method == selfupdate.InstallPnpm {
|
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
|
||||||
}
|
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||||
pm := "npm"
|
|
||||||
install := updater.RunNpmInstall
|
|
||||||
if detect.Method == selfupdate.InstallPnpm {
|
|
||||||
pm = "pnpm"
|
|
||||||
install = updater.RunPnpmInstall
|
|
||||||
}
|
|
||||||
|
|
||||||
restore, err := updater.PrepareSelfReplace()
|
restore, err := updater.PrepareSelfReplace()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reportError(opts, io, "update_error",
|
return reportError(opts, io, "update_error",
|
||||||
@@ -251,19 +239,19 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||||
}
|
}
|
||||||
|
|
||||||
npmResult := install(latest)
|
npmResult := updater.RunNpmInstall(latest)
|
||||||
if npmResult.Err != nil {
|
if npmResult.Err != nil {
|
||||||
restore()
|
restore()
|
||||||
combined := npmResult.CombinedOutput()
|
combined := npmResult.CombinedOutput()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false, "error": map[string]interface{}{
|
"ok": false, "error": map[string]interface{}{
|
||||||
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||||
"hint": permissionHint(combined, pm),
|
"hint": permissionHint(combined),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -275,7 +263,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
|||||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||||
if hint := permissionHint(combined, pm); hint != "" {
|
if hint := permissionHint(combined); hint != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -286,7 +274,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
|||||||
if err := updater.VerifyBinary(latest); err != nil {
|
if err := updater.VerifyBinary(latest); err != nil {
|
||||||
restore()
|
restore()
|
||||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||||
hint := verificationFailureHint(updater, latest, pm)
|
hint := verificationFailureHint(updater, latest)
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -316,33 +304,23 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
|||||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if skillsResult != nil {
|
if skillsResult != nil {
|
||||||
skillsPM := "npx"
|
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||||
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
|
||||||
skillsPM = "pnpm dlx"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
|
||||||
}
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionHint(pmOutput, pm string) string {
|
func permissionHint(npmOutput string) string {
|
||||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||||
return ""
|
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
}
|
}
|
||||||
if pm == "pnpm" {
|
return ""
|
||||||
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
|
||||||
}
|
|
||||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||||
if updater.CanRestorePreviousVersion() {
|
if updater.CanRestorePreviousVersion() {
|
||||||
return "the previous version has been restored"
|
return "the previous version has been restored"
|
||||||
}
|
}
|
||||||
if pm == "pnpm" {
|
|
||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,27 +57,6 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
|
||||||
// and fails the test if the npm install path is invoked.
|
|
||||||
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
|
||||||
t.Helper()
|
|
||||||
origNew := newUpdater
|
|
||||||
newUpdater = func() *selfupdate.Updater {
|
|
||||||
u := selfupdate.New()
|
|
||||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
|
||||||
u.PnpmInstallOverride = pnpmFn
|
|
||||||
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
|
||||||
t.Errorf("npm install must not be called for a pnpm install")
|
|
||||||
return &selfupdate.NpmResult{}
|
|
||||||
}
|
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
|
||||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
|
||||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { newUpdater = origNew })
|
|
||||||
}
|
|
||||||
|
|
||||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
return func() *selfupdate.NpmResult {
|
return func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -102,110 +81,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
|
||||||
cmd := NewCmdUpdate(f)
|
|
||||||
cmd.SetArgs([]string{"--json"})
|
|
||||||
origFetch := fetchLatest
|
|
||||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
|
||||||
defer func() { fetchLatest = origFetch }()
|
|
||||||
origVersion := currentVersion
|
|
||||||
currentVersion = func() string { return "1.0.0" }
|
|
||||||
defer func() { currentVersion = origVersion }()
|
|
||||||
mockDetectAndPnpm(t,
|
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
|
||||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
|
||||||
)
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
|
||||||
t.Errorf("expected updated in output, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdatePnpm_Human(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, _, stderr := newTestFactory(t)
|
|
||||||
cmd := NewCmdUpdate(f)
|
|
||||||
cmd.SetArgs([]string{})
|
|
||||||
origFetch := fetchLatest
|
|
||||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
|
||||||
defer func() { fetchLatest = origFetch }()
|
|
||||||
origVersion := currentVersion
|
|
||||||
currentVersion = func() string { return "1.0.0" }
|
|
||||||
defer func() { currentVersion = origVersion }()
|
|
||||||
mockDetectAndPnpm(t,
|
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
|
||||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
|
||||||
)
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
out := stderr.String()
|
|
||||||
if !strings.Contains(out, "via pnpm") {
|
|
||||||
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
|
||||||
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "Successfully updated") {
|
|
||||||
t.Errorf("expected success message, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
|
||||||
cmd := NewCmdUpdate(f)
|
|
||||||
cmd.SetArgs([]string{"--json"})
|
|
||||||
origFetch := fetchLatest
|
|
||||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
|
||||||
defer func() { fetchLatest = origFetch }()
|
|
||||||
origVersion := currentVersion
|
|
||||||
currentVersion = func() string { return "1.0.0" }
|
|
||||||
defer func() { currentVersion = origVersion }()
|
|
||||||
mockDetectAndPnpm(t,
|
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
|
||||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
|
||||||
)
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error exit")
|
|
||||||
}
|
|
||||||
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
|
||||||
t.Errorf("expected failure envelope, got: %s", out)
|
|
||||||
}
|
|
||||||
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
|
||||||
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, _, stderr := newTestFactory(t)
|
|
||||||
cmd := NewCmdUpdate(f)
|
|
||||||
cmd.SetArgs([]string{})
|
|
||||||
origFetch := fetchLatest
|
|
||||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
|
||||||
defer func() { fetchLatest = origFetch }()
|
|
||||||
origVersion := currentVersion
|
|
||||||
currentVersion = func() string { return "1.0.0" }
|
|
||||||
defer func() { currentVersion = origVersion }()
|
|
||||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
out := stderr.String()
|
|
||||||
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
|
||||||
t.Errorf("expected pnpm manual reason, got: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "pnpm add -g") {
|
|
||||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
func TestNormalizeVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
@@ -391,9 +266,6 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Successfully updated") {
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
t.Errorf("expected success message in stderr, got: %s", out)
|
t.Errorf("expected success message in stderr, got: %s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "Updating skills via npx ...") {
|
|
||||||
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateForce_JSON(t *testing.T) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
@@ -867,9 +739,9 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
origOS := currentOS
|
origOS := currentOS
|
||||||
defer func() { currentOS = origOS }()
|
defer func() { currentOS = origOS }()
|
||||||
|
|
||||||
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||||
if !strings.Contains(hint, "npm global prefix") {
|
if !strings.Contains(hint, "npm global prefix") {
|
||||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||||
}
|
}
|
||||||
@@ -877,25 +749,16 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
|
||||||
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
|
||||||
if !strings.Contains(pnpmHint, "pnpm setup") {
|
|
||||||
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
|
||||||
}
|
|
||||||
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
|
||||||
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||||
currentOS = "windows"
|
currentOS = "windows"
|
||||||
hint = permissionHint("EACCES: permission denied", "npm")
|
hint = permissionHint("EACCES: permission denied")
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-EACCES error: always empty.
|
// Non-EACCES error: always empty.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
if got := permissionHint("some other error", "npm"); got != "" {
|
if got := permissionHint("some other error"); got != "" {
|
||||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package whoami
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/identitydiag"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
)
|
|
||||||
|
|
||||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
|
||||||
//
|
|
||||||
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
|
|
||||||
// the app acting as itself; a user identity is the app acting *on behalf of* a
|
|
||||||
// person (calls are attributed to that user, who is not necessarily present).
|
|
||||||
// onBehalfOf only *names* that person and so appears only once a user is
|
|
||||||
// resolved — a user identity that is not signed in still has identity "user"
|
|
||||||
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
|
|
||||||
type whoamiResult struct {
|
|
||||||
Profile string `json:"profile"`
|
|
||||||
AppID string `json:"appId"`
|
|
||||||
Brand core.LarkBrand `json:"brand"`
|
|
||||||
DefaultAs string `json:"defaultAs"`
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
IdentitySource string `json:"identitySource"`
|
|
||||||
Available bool `json:"available"`
|
|
||||||
TokenStatus string `json:"tokenStatus"`
|
|
||||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
|
||||||
Hint string `json:"hint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// delegatedUser is the user a user-identity acts on behalf of.
|
|
||||||
type delegatedUser struct {
|
|
||||||
UserName string `json:"userName,omitempty"`
|
|
||||||
OpenID string `json:"openId,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options holds inputs for the whoami command.
|
|
||||||
type Options struct {
|
|
||||||
Factory *cmdutil.Factory
|
|
||||||
As string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
|
||||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
|
||||||
// together with the active profile, app, and token status. Output is always
|
|
||||||
// JSON — whoami is consumed by agents. With the built-in credential path it is
|
|
||||||
// local-only; when an external credential provider manages tokens, resolving
|
|
||||||
// the identity may contact that provider.
|
|
||||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
|
||||||
opts := &Options{Factory: f}
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "whoami",
|
|
||||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return whoamiRun(cmd, opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmdutil.DisableAuthCheck(cmd)
|
|
||||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
|
||||||
// Output is always JSON. Accept (and ignore) --json so existing
|
|
||||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
|
||||||
// mode exists.
|
|
||||||
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
|
|
||||||
_ = cmd.Flags().MarkHidden("json")
|
|
||||||
cmdutil.SetRisk(cmd, "read")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
|
||||||
f := opts.Factory
|
|
||||||
cfg, err := f.Config()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ctx := cmd.Context()
|
|
||||||
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.
|
|
||||||
if err := f.CheckStrictMode(ctx, as); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
source := resolveSource(
|
|
||||||
cmd.Flags().Changed("as"),
|
|
||||||
flagAs,
|
|
||||||
f.IdentityAutoDetected,
|
|
||||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
|
||||||
)
|
|
||||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
|
||||||
res := buildResult(cfg, as, source, diag)
|
|
||||||
output.PrintJson(f.IOStreams.Out, res)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveSource derives how the effective identity became effective.
|
|
||||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
|
||||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
|
||||||
// identity means strict-mode; otherwise it came from configured default-as.
|
|
||||||
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
|
|
||||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
|
||||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
|
||||||
return "flag"
|
|
||||||
}
|
|
||||||
if autoDetected {
|
|
||||||
return "auto_detect"
|
|
||||||
}
|
|
||||||
if strictForced != "" {
|
|
||||||
return "strict_mode"
|
|
||||||
}
|
|
||||||
return "default_as"
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
|
||||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
|
||||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
|
||||||
defaultAs := cfg.DefaultAs
|
|
||||||
if defaultAs == "" {
|
|
||||||
defaultAs = core.AsAuto
|
|
||||||
}
|
|
||||||
res := &whoamiResult{
|
|
||||||
Profile: cfg.ProfileName,
|
|
||||||
AppID: cfg.AppID,
|
|
||||||
Brand: cfg.Brand,
|
|
||||||
DefaultAs: string(defaultAs),
|
|
||||||
Identity: string(as),
|
|
||||||
IdentitySource: source,
|
|
||||||
}
|
|
||||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
|
||||||
// it never says "auth login" when that is blocked under an external provider.
|
|
||||||
switch as {
|
|
||||||
case core.AsBot:
|
|
||||||
res.Available = diag.Bot.Available
|
|
||||||
res.TokenStatus = diag.Bot.Status
|
|
||||||
if !diag.Bot.Available {
|
|
||||||
res.Hint = diag.Bot.Hint
|
|
||||||
}
|
|
||||||
default: // user
|
|
||||||
res.Available = diag.User.Available
|
|
||||||
// Use Status (not the raw TokenStatus) so the vocab matches the bot
|
|
||||||
// branch: "ready" means usable for both. available stays the canonical
|
|
||||||
// usable signal; tokenStatus is the readable state behind it.
|
|
||||||
res.TokenStatus = diag.User.Status
|
|
||||||
// Set onBehalfOf only when a user is actually resolved; an unresolved
|
|
||||||
// user identity (not signed in) has no one to act on behalf of yet.
|
|
||||||
if diag.User.UserName != "" || diag.User.OpenID != "" {
|
|
||||||
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
|
|
||||||
}
|
|
||||||
if !diag.User.Available {
|
|
||||||
res.Hint = diag.User.Hint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package whoami
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/credential"
|
|
||||||
"github.com/larksuite/cli/internal/identitydiag"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveSource(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
changedAs bool
|
|
||||||
flagAs core.Identity
|
|
||||||
autoDetected bool
|
|
||||||
strictForced core.Identity
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
|
||||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
|
||||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
|
|
||||||
{"auto detected", false, "", true, "", "auto_detect"},
|
|
||||||
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
|
|
||||||
{"default_as", false, "", false, "", "default_as"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildResult_UserValid(t *testing.T) {
|
|
||||||
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, core.AsUser, "auto_detect", diag)
|
|
||||||
|
|
||||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
|
||||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
|
||||||
}
|
|
||||||
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
|
|
||||||
if !r.Available || r.TokenStatus != "ready" {
|
|
||||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
|
||||||
}
|
|
||||||
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
|
|
||||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
|
|
||||||
}
|
|
||||||
if r.Hint != "" {
|
|
||||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
|
||||||
}
|
|
||||||
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 := &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, core.AsUser, "auto_detect", diag)
|
|
||||||
|
|
||||||
if r.Available {
|
|
||||||
t.Fatalf("available = true, want false")
|
|
||||||
}
|
|
||||||
if r.TokenStatus != "missing" {
|
|
||||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
|
||||||
}
|
|
||||||
// whoami renders the diagnosed hint verbatim (single source of truth) so it
|
|
||||||
// stays correct for the external-provider path without whoami knowing about it.
|
|
||||||
if r.Hint != diag.User.Hint {
|
|
||||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
|
|
||||||
}
|
|
||||||
if r.DefaultAs != "auto" {
|
|
||||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildResult_BotReady(t *testing.T) {
|
|
||||||
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, core.AsBot, "default_as", diag)
|
|
||||||
|
|
||||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
|
||||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
|
||||||
}
|
|
||||||
if !r.Available || r.TokenStatus != "ready" {
|
|
||||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
|
||||||
}
|
|
||||||
if r.OnBehalfOf != nil {
|
|
||||||
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
|
|
||||||
}
|
|
||||||
if r.Hint != "" {
|
|
||||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
|
||||||
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, core.AsBot, "auto_detect", diag)
|
|
||||||
|
|
||||||
if r.Available {
|
|
||||||
t.Fatalf("available = true, want false")
|
|
||||||
}
|
|
||||||
if r.TokenStatus != "not_configured" {
|
|
||||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
|
||||||
}
|
|
||||||
if r.Hint != diag.Bot.Hint {
|
|
||||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoami_BotJSON(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := NewCmdWhoami(f)
|
|
||||||
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("Execute() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got whoamiResult
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if got.Identity != "bot" {
|
|
||||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
|
||||||
}
|
|
||||||
if !got.Available || got.TokenStatus != "ready" {
|
|
||||||
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
|
||||||
}
|
|
||||||
if got.Profile != "test-profile" {
|
|
||||||
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
|
||||||
}
|
|
||||||
if got.IdentitySource == "" {
|
|
||||||
t.Fatalf("identitySource empty")
|
|
||||||
}
|
|
||||||
if got.OnBehalfOf != nil {
|
|
||||||
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
|
||||||
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
|
||||||
t.Run("as="+bad, func(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
cmd := NewCmdWhoami(f)
|
|
||||||
cmd.SetArgs([]string{"--as", bad})
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
|
||||||
}
|
|
||||||
// Lock in the typed validation contract: an unsupported identity must
|
|
||||||
// surface as a *errs.ValidationError on --as, not just any error.
|
|
||||||
var ve *errs.ValidationError
|
|
||||||
if !errors.As(err, &ve) {
|
|
||||||
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
|
||||||
}
|
|
||||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
||||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
||||||
}
|
|
||||||
if ve.Param != "--as" {
|
|
||||||
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
wantErr := fmt.Errorf("boom")
|
|
||||||
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
|
||||||
|
|
||||||
cmd := NewCmdWhoami(f)
|
|
||||||
cmd.SetArgs([]string{"--json"})
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Execute() error = nil, want propagated config error")
|
|
||||||
}
|
|
||||||
// The f.Config() failure must propagate unchanged, not be masked by a later
|
|
||||||
// command-execution error.
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
|
|
||||||
// Bot-only account → strict mode bot. A real `--as user` call would be
|
|
||||||
// rejected by CheckStrictMode; whoami must reject it identically rather than
|
|
||||||
// previewing a user identity the next call would refuse.
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
SupportedIdentities: 2, // bot only
|
|
||||||
})
|
|
||||||
cmd := NewCmdWhoami(f)
|
|
||||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
|
|
||||||
}
|
|
||||||
var ve *errs.ValidationError
|
|
||||||
if !errors.As(err, &ve) {
|
|
||||||
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeExtProvider struct {
|
|
||||||
name string
|
|
||||||
account *extcred.Account
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *fakeExtProvider) Name() string { return p.name }
|
|
||||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
|
||||||
return p.account, nil
|
|
||||||
}
|
|
||||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
|
||||||
return nil, nil // no UAT served locally; whoami runs with verify=false
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
func() (*http.Client, error) { return nil, nil },
|
|
||||||
)
|
|
||||||
out := &bytes.Buffer{}
|
|
||||||
f := &cmdutil.Factory{
|
|
||||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
|
||||||
Credential: cred,
|
|
||||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
|
||||||
}
|
|
||||||
return f, out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression for the external-provider blind spot: with credentials managed by
|
|
||||||
// an extension provider, a signed-in user must read as available, and an
|
|
||||||
// unavailable identity must not be told to "auth login" (which is blocked).
|
|
||||||
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
|
|
||||||
cfg := &core.CliConfig{
|
|
||||||
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
|
||||||
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
|
|
||||||
}
|
|
||||||
f, out := externalWhoamiFactory(cfg)
|
|
||||||
|
|
||||||
cmd := NewCmdWhoami(f)
|
|
||||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("Execute() error = %v", err)
|
|
||||||
}
|
|
||||||
var got whoamiResult
|
|
||||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
|
||||||
}
|
|
||||||
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
|
|
||||||
t.Fatalf("got %#v, want user/available/ready", got)
|
|
||||||
}
|
|
||||||
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
|
|
||||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
|
|
||||||
}
|
|
||||||
if got.Hint != "" {
|
|
||||||
t.Fatalf("hint = %q, want empty when available", got.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
|
||||||
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)
|
|
||||||
|
|
||||||
cmd := NewCmdWhoami(f)
|
|
||||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("Execute() error = %v", err)
|
|
||||||
}
|
|
||||||
var got whoamiResult
|
|
||||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
|
||||||
}
|
|
||||||
if got.Identity != "user" || got.Available {
|
|
||||||
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
|
|
||||||
}
|
|
||||||
if strings.Contains(got.Hint, "auth login") {
|
|
||||||
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got.Hint, "external") {
|
|
||||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/cmd"
|
|
||||||
"github.com/larksuite/cli/internal/affordance"
|
|
||||||
)
|
|
||||||
|
|
||||||
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
|
||||||
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
|
|
||||||
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
|
|
||||||
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
|
|
||||||
// a new content type is omitted until added to the embed list. The embed must live
|
|
||||||
// in this root package because go:embed cannot reach up out of a package's dir.
|
|
||||||
//
|
|
||||||
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
|
|
||||||
var embeddedContentFS embed.FS
|
|
||||||
|
|
||||||
// init wires the embedded content into the CLI. It compiles into `go build .` but
|
|
||||||
// not the single-file preview build (`go build ./main.go`), so that build stays
|
|
||||||
// self-contained (shipping no embedded content). Assembly failures warn on stderr
|
|
||||||
// rather than panicking — embedded content is nice-to-have, not load-bearing.
|
|
||||||
func init() {
|
|
||||||
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
|
|
||||||
} else {
|
|
||||||
cmd.SetEmbeddedSkillContent(sub)
|
|
||||||
}
|
|
||||||
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
|
|
||||||
} else {
|
|
||||||
affordance.SetSource(sub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -72,28 +72,6 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
`CategoryPolicy`.
|
`CategoryPolicy`.
|
||||||
|
|
||||||
### Success envelope (stdout)
|
|
||||||
|
|
||||||
For contrast: success responses render to **stdout** as an
|
|
||||||
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"identity": "user",
|
|
||||||
"data": { "guid": "e297d3d0-..." },
|
|
||||||
"meta": { "count": 1 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Consumers must branch on `ok` (or the process exit code). The success
|
|
||||||
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
|
||||||
inside `error`, where it is the upstream numeric code (invariant 4).
|
|
||||||
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
|
||||||
will misclassify every successful call as a failure, which is
|
|
||||||
especially dangerous around write commands (e.g. retrying a create that
|
|
||||||
already succeeded).
|
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
| Category | When | Exit | Typed struct |
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
|||||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||||
WithMissingScopes("mail:user_mailbox.message:send").
|
WithMissingScopes("mail:user_mailbox.message:send").
|
||||||
WithIdentity("user").
|
WithIdentity("user").
|
||||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||||
|
|
||||||
if got.Category != errs.CategoryAuthorization {
|
if got.Category != errs.CategoryAuthorization {
|
||||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
|||||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||||
WithMissingScopes("calendar:event:create").
|
WithMissingScopes("calendar:event:create").
|
||||||
WithIdentity("user").
|
WithIdentity("user").
|
||||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||||
|
|
||||||
buf, err := json.Marshal(e)
|
buf, err := json.Marshal(e)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
|||||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||||
"log_id": "20260520-0a1b2c3d",
|
"log_id": "20260520-0a1b2c3d",
|
||||||
"identity": "user",
|
"identity": "user",
|
||||||
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||||
"missing_scopes": []any{"calendar:event:create"},
|
"missing_scopes": []any{"calendar:event:create"},
|
||||||
}
|
}
|
||||||
for k, want := range wantFields {
|
for k, want := range wantFields {
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
|
|
||||||
type VCParticipantMeetingJoinedOutput struct {
|
|
||||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
|
|
||||||
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); taken from header.create_time when present" kind:"timestamp_ms"`
|
|
||||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
|
||||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
|
||||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
|
||||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
|
||||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func processVCParticipantMeetingJoined(_ 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"`
|
|
||||||
} `json:"header"`
|
|
||||||
Event struct {
|
|
||||||
Meeting struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
MeetingNo string `json:"meeting_no"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
EndTime string `json:"end_time"`
|
|
||||||
CalendarEventID string `json:"calendar_event_id"`
|
|
||||||
} `json:"meeting"`
|
|
||||||
} `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
|
|
||||||
}
|
|
||||||
|
|
||||||
meeting := envelope.Event.Meeting
|
|
||||||
out := &VCParticipantMeetingJoinedOutput{
|
|
||||||
Type: envelope.Header.EventType,
|
|
||||||
EventID: envelope.Header.EventID,
|
|
||||||
Timestamp: envelope.Header.CreateTime,
|
|
||||||
MeetingID: meeting.ID,
|
|
||||||
Topic: meeting.Topic,
|
|
||||||
MeetingNo: meeting.MeetingNo,
|
|
||||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
|
||||||
CalendarEventID: meeting.CalendarEventID,
|
|
||||||
}
|
|
||||||
if out.Type == "" {
|
|
||||||
out.Type = raw.EventType
|
|
||||||
}
|
|
||||||
return json.Marshal(out)
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
eventType string
|
|
||||||
schemaType reflect.Type
|
|
||||||
}{
|
|
||||||
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
|
||||||
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
|
||||||
} {
|
|
||||||
t.Run(tc.eventType, func(t *testing.T) {
|
|
||||||
def, ok := event.Lookup(tc.eventType)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
|
||||||
}
|
|
||||||
if def.Schema.Custom == nil {
|
|
||||||
t.Error("Processed key must set Schema.Custom")
|
|
||||||
}
|
|
||||||
if def.Schema.Native != nil {
|
|
||||||
t.Error("Processed key must not set Schema.Native")
|
|
||||||
}
|
|
||||||
if def.Process == nil {
|
|
||||||
t.Error("Process must not be nil for processed key")
|
|
||||||
}
|
|
||||||
if def.PreConsume == nil {
|
|
||||||
t.Error("PreConsume must not be nil for processed key")
|
|
||||||
}
|
|
||||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
|
||||||
t.Errorf("Scopes = %v", def.Scopes)
|
|
||||||
}
|
|
||||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
|
||||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
|
||||||
}
|
|
||||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
|
||||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
|
||||||
}
|
|
||||||
if def.Schema.Custom.Type != tc.schemaType {
|
|
||||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
eventType string
|
|
||||||
process event.ProcessFunc
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "started",
|
|
||||||
eventType: eventTypeMeetingStarted,
|
|
||||||
process: processVCParticipantMeetingStarted,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "joined",
|
|
||||||
eventType: eventTypeMeetingJoined,
|
|
||||||
process: processVCParticipantMeetingJoined,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
payload := `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_lifecycle_001",
|
|
||||||
"event_type": "` + tc.eventType + `",
|
|
||||||
"create_time": "1608725989000",
|
|
||||||
"app_id": "cli_test"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"meeting": {
|
|
||||||
"id": "6911188411934433028",
|
|
||||||
"topic": "my meeting",
|
|
||||||
"meeting_no": "235812466",
|
|
||||||
"start_time": "1608883322",
|
|
||||||
"end_time": "1608883899",
|
|
||||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
|
|
||||||
|
|
||||||
if out["type"] != tc.eventType {
|
|
||||||
t.Errorf("type = %q", out["type"])
|
|
||||||
}
|
|
||||||
if out["event_id"] != "ev_vc_lifecycle_001" {
|
|
||||||
t.Errorf("event_id = %q", out["event_id"])
|
|
||||||
}
|
|
||||||
if out["timestamp"] != "1608725989000" {
|
|
||||||
t.Errorf("timestamp = %q", out["timestamp"])
|
|
||||||
}
|
|
||||||
if out["meeting_id"] != "6911188411934433028" {
|
|
||||||
t.Errorf("meeting_id = %q", out["meeting_id"])
|
|
||||||
}
|
|
||||||
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
|
|
||||||
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
|
|
||||||
}
|
|
||||||
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
|
||||||
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
|
|
||||||
}
|
|
||||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
|
|
||||||
t.Errorf("start_time = %q, want %q", out["start_time"], want)
|
|
||||||
}
|
|
||||||
if _, hasEndTime := out["end_time"]; hasEndTime {
|
|
||||||
t.Error("end_time should not be present in started/joined output")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
eventType string
|
|
||||||
process event.ProcessFunc
|
|
||||||
}{
|
|
||||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
|
||||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
payload := `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_lifecycle_002",
|
|
||||||
"event_type": "` + tc.eventType + `",
|
|
||||||
"create_time": "1608725989001"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"meeting": {
|
|
||||||
"id": "meeting_invalid_time",
|
|
||||||
"start_time": "bad",
|
|
||||||
"end_time": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
|
|
||||||
switch tc.eventType {
|
|
||||||
case eventTypeMeetingStarted:
|
|
||||||
var started VCParticipantMeetingStartedOutput
|
|
||||||
if err := json.Unmarshal(out, &started); err != nil {
|
|
||||||
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
|
|
||||||
}
|
|
||||||
if started.StartTime != "" {
|
|
||||||
t.Errorf("StartTime = %q, want empty string", started.StartTime)
|
|
||||||
}
|
|
||||||
case eventTypeMeetingJoined:
|
|
||||||
var joined VCParticipantMeetingJoinedOutput
|
|
||||||
if err := json.Unmarshal(out, &joined); err != nil {
|
|
||||||
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
|
|
||||||
}
|
|
||||||
if joined.StartTime != "" {
|
|
||||||
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
eventType string
|
|
||||||
process event.ProcessFunc
|
|
||||||
}{
|
|
||||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
|
||||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: tc.eventType,
|
|
||||||
Payload: json.RawMessage(`not json`),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := tc.process(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 TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
|
|
||||||
t.Run(eventType, func(t *testing.T) {
|
|
||||||
def, ok := event.Lookup(eventType)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s should be registered via Keys()", eventType)
|
|
||||||
}
|
|
||||||
|
|
||||||
type call struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
body any
|
|
||||||
}
|
|
||||||
var calls []call
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
calls = append(calls, call{method: method, path: path, body: body})
|
|
||||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PreConsume error: %v", err)
|
|
||||||
}
|
|
||||||
if cleanup == nil {
|
|
||||||
t.Fatal("cleanup must not be nil")
|
|
||||||
}
|
|
||||||
if len(calls) != 1 {
|
|
||||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
|
||||||
t.Fatalf("subscribe call = %+v", calls[0])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[0].body, eventType)
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
if len(calls) != 2 {
|
|
||||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
|
||||||
}
|
|
||||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
|
||||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[1].body, eventType)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
got := runMeetingLifecycleRaw(t, eventType, process, payload)
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("Process output is nil")
|
|
||||||
}
|
|
||||||
var out map[string]string
|
|
||||||
if err := json.Unmarshal(got, &out); err != nil {
|
|
||||||
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
|
||||||
t.Helper()
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: eventType,
|
|
||||||
Payload: json.RawMessage(payload),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := process(context.Background(), nil, raw, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process error: %v", err)
|
|
||||||
}
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
|
|
||||||
type VCParticipantMeetingStartedOutput struct {
|
|
||||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
|
|
||||||
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); taken from header.create_time when present" kind:"timestamp_ms"`
|
|
||||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
|
||||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
|
||||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
|
||||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
|
||||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func processVCParticipantMeetingStarted(_ 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"`
|
|
||||||
} `json:"header"`
|
|
||||||
Event struct {
|
|
||||||
Meeting struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
MeetingNo string `json:"meeting_no"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
CalendarEventID string `json:"calendar_event_id"`
|
|
||||||
} `json:"meeting"`
|
|
||||||
} `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
|
|
||||||
}
|
|
||||||
|
|
||||||
meeting := envelope.Event.Meeting
|
|
||||||
out := &VCParticipantMeetingStartedOutput{
|
|
||||||
Type: envelope.Header.EventType,
|
|
||||||
EventID: envelope.Header.EventID,
|
|
||||||
Timestamp: envelope.Header.CreateTime,
|
|
||||||
MeetingID: meeting.ID,
|
|
||||||
Topic: meeting.Topic,
|
|
||||||
MeetingNo: meeting.MeetingNo,
|
|
||||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
|
||||||
CalendarEventID: meeting.CalendarEventID,
|
|
||||||
}
|
|
||||||
if out.Type == "" {
|
|
||||||
out.Type = raw.EventType
|
|
||||||
}
|
|
||||||
return json.Marshal(out)
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
|
|
||||||
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
|
|
||||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||||
@@ -32,38 +30,6 @@ const (
|
|||||||
// Keys returns all VC-domain EventKey definitions.
|
// Keys returns all VC-domain EventKey definitions.
|
||||||
func Keys() []event.KeyDefinition {
|
func Keys() []event.KeyDefinition {
|
||||||
return []event.KeyDefinition{
|
return []event.KeyDefinition{
|
||||||
{
|
|
||||||
Key: eventTypeMeetingStarted,
|
|
||||||
DisplayName: "Participant meeting started",
|
|
||||||
Description: "Triggered when a meeting the current user participates in has started",
|
|
||||||
EventType: eventTypeMeetingStarted,
|
|
||||||
Schema: event.SchemaDef{
|
|
||||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
|
||||||
},
|
|
||||||
Process: processVCParticipantMeetingStarted,
|
|
||||||
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
|
||||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
|
||||||
AuthTypes: []string{
|
|
||||||
"user",
|
|
||||||
},
|
|
||||||
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Key: eventTypeMeetingJoined,
|
|
||||||
DisplayName: "Participant meeting joined",
|
|
||||||
Description: "Triggered when the current user joins a meeting",
|
|
||||||
EventType: eventTypeMeetingJoined,
|
|
||||||
Schema: event.SchemaDef{
|
|
||||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
|
||||||
},
|
|
||||||
Process: processVCParticipantMeetingJoined,
|
|
||||||
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
|
||||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
|
||||||
AuthTypes: []string{
|
|
||||||
"user",
|
|
||||||
},
|
|
||||||
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Key: eventTypeMeetingEnded,
|
Key: eventTypeMeetingEnded,
|
||||||
DisplayName: "Participant meeting ended",
|
DisplayName: "Participant meeting ended",
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
// Package affordance is the lazily-loaded store of usage guidance for
|
|
||||||
// service-API methods. The source of truth is one markdown file per service in
|
|
||||||
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
|
|
||||||
// domain owners maintain it next to skills/ and shortcuts/. A service is read
|
|
||||||
// and parsed at most once, on first access, so normal command execution never
|
|
||||||
// touches it.
|
|
||||||
package affordance
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io/fs"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/apicatalog"
|
|
||||||
"github.com/larksuite/cli/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
byService = map[string]map[string]json.RawMessage{}
|
|
||||||
tried = map[string]bool{}
|
|
||||||
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetSource installs the markdown guidance tree (the top-level affordance/
|
|
||||||
// directory) as the source. Called once at startup before any lookup; clears
|
|
||||||
// the parse cache so re-sourcing (e.g. in tests) takes effect.
|
|
||||||
func SetSource(fsys fs.FS) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
mdSource = fsys
|
|
||||||
byService = map[string]map[string]json.RawMessage{}
|
|
||||||
tried = map[string]bool{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For returns the raw affordance overlay for one method, loading the owning
|
|
||||||
// service on first access. ok is false when there is no entry (absent source,
|
|
||||||
// parse failure, or unknown method all collapse to "no guidance").
|
|
||||||
func For(service, methodID string) (json.RawMessage, bool) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if !tried[service] {
|
|
||||||
tried[service] = true
|
|
||||||
byService[service] = loadService(service)
|
|
||||||
}
|
|
||||||
raw, ok := byService[service][methodID]
|
|
||||||
return raw, ok && len(raw) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadService parses a service's markdown guidance into per-method overlays,
|
|
||||||
// marshalling each to JSON so downstream callers keep the same wire shape.
|
|
||||||
func loadService(service string) map[string]json.RawMessage {
|
|
||||||
if mdSource == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
src, err := fs.ReadFile(mdSource, service+".md")
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m := map[string]json.RawMessage{}
|
|
||||||
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
|
|
||||||
if b, err := json.Marshal(a); err == nil {
|
|
||||||
m[id] = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
|
|
||||||
// list") to its method id ("user_mailbox.message.list") via the registry's
|
|
||||||
// authoritative resource↔id table. Resource names are irregularly pluralised
|
|
||||||
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
|
|
||||||
// space→dot fallback covers domains where the two already coincide.
|
|
||||||
func commandFormResolver(service string) func(string) string {
|
|
||||||
byForm := map[string]string{}
|
|
||||||
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
|
||||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return func(h string) string {
|
|
||||||
h = strings.TrimSpace(h)
|
|
||||||
if id, ok := byForm[h]; ok {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(h, " ", ".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package affordance
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
"testing/fstest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
|
||||||
// paragraph (use_when) and a fenced example.
|
|
||||||
const fixtureMD = "# approval\n" +
|
|
||||||
"> skill: lark-approval\n\n" +
|
|
||||||
"## instances cc\n" +
|
|
||||||
"把一个审批实例抄送给指定用户。\n\n" +
|
|
||||||
"### Examples\n\n" +
|
|
||||||
"**抄送给用户**\n" +
|
|
||||||
"```bash\n" +
|
|
||||||
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
|
|
||||||
"```\n\n" +
|
|
||||||
"## instances get\n" +
|
|
||||||
"查询某审批实例详情。\n\n" +
|
|
||||||
"### Examples\n\n" +
|
|
||||||
"**按 code 查询**\n" +
|
|
||||||
"```bash\n" +
|
|
||||||
"lark-cli approval instances get --instance-code \"x\"\n" +
|
|
||||||
"```\n"
|
|
||||||
|
|
||||||
func TestFor(t *testing.T) {
|
|
||||||
prev := mdSource
|
|
||||||
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
|
|
||||||
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
|
|
||||||
|
|
||||||
// A seeded method in a seeded service resolves to its overlay.
|
|
||||||
raw, ok := For("approval", "instances.cc")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
|
|
||||||
}
|
|
||||||
var a struct {
|
|
||||||
UseWhen []string `json:"use_when"`
|
|
||||||
Examples []struct {
|
|
||||||
Command string `json:"command"`
|
|
||||||
} `json:"examples"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &a); err != nil {
|
|
||||||
t.Fatalf("overlay is not valid affordance JSON: %v", err)
|
|
||||||
}
|
|
||||||
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
|
|
||||||
t.Errorf("overlay missing use_when/examples: %s", raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Misses: unknown method in a known service, and an unknown service, both
|
|
||||||
// resolve to ok=false (no panic, no error) so callers treat them as "no
|
|
||||||
// guidance".
|
|
||||||
if _, ok := For("approval", "instances.no_such_method"); ok {
|
|
||||||
t.Error("unknown method should be ok=false")
|
|
||||||
}
|
|
||||||
if _, ok := For("no_such_service", "x.y"); ok {
|
|
||||||
t.Error("unknown service should be ok=false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second lookup of the same service is served from cache (parsed at most
|
|
||||||
// once) and stays consistent.
|
|
||||||
if _, ok := For("approval", "instances.get"); !ok {
|
|
||||||
t.Error("second lookup in a cached service should still resolve")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-bullet paragraph lines under any section are preserved as items, not
|
|
||||||
// dropped (regression: they previously only updated pending, lost without a fence).
|
|
||||||
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
|
||||||
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
|
|
||||||
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
|
|
||||||
a, ok := got["foo.bar"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("method not parsed")
|
|
||||||
}
|
|
||||||
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
|
|
||||||
t.Errorf("Tips paragraph dropped: %v", a.Tips)
|
|
||||||
}
|
|
||||||
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
|
|
||||||
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package affordance
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/meta"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
|
|
||||||
//
|
|
||||||
// # domain optional `> skill: <name>` applied to every method
|
|
||||||
// ## command e.g. `instances get`
|
|
||||||
// <lead paragraph> -> use_when (when this command is right)
|
|
||||||
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
|
|
||||||
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
|
||||||
// ### Tips -> tips
|
|
||||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
|
||||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
|
||||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
|
||||||
//
|
|
||||||
// Parsing is lazy and cached (see For), so the constrained grammar is read at
|
|
||||||
// most once per domain.
|
|
||||||
|
|
||||||
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
|
|
||||||
|
|
||||||
// standardSection maps a section heading to its typed Affordance field; any
|
|
||||||
// other heading becomes an extension.
|
|
||||||
var standardSection = map[string]string{
|
|
||||||
"Avoid when": "avoid_when",
|
|
||||||
"Prerequisites": "prerequisites",
|
|
||||||
"Tips": "tips",
|
|
||||||
"Examples": "examples",
|
|
||||||
}
|
|
||||||
|
|
||||||
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
|
||||||
|
|
||||||
// headingToKey maps a command heading ("instances get") to its affordance key
|
|
||||||
// ("instances.get"). The space→dot rule holds where the command form matches
|
|
||||||
// the method id; domains whose resource names differ (e.g. plural "messages"
|
|
||||||
// vs id segment "message") need the registry's authoritative resource↔id table.
|
|
||||||
func headingToKey(h string) string {
|
|
||||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
type mdSection struct {
|
|
||||||
label string
|
|
||||||
items []string
|
|
||||||
cases []meta.AffordanceCase
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDomainMD parses one domain's markdown into per-method Affordance values,
|
|
||||||
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
|
|
||||||
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
|
|
||||||
// space→dot rule (valid only where the command form already equals the id).
|
|
||||||
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
|
|
||||||
if resolve == nil {
|
|
||||||
resolve = headingToKey
|
|
||||||
}
|
|
||||||
out := map[string]meta.Affordance{}
|
|
||||||
|
|
||||||
var skill, curKey string
|
|
||||||
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
|
|
||||||
var secs []*mdSection
|
|
||||||
var sec *mdSection
|
|
||||||
var pending string
|
|
||||||
var fence []string
|
|
||||||
inFence := false
|
|
||||||
|
|
||||||
assemble := func() {
|
|
||||||
if curKey == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(para) > 0 {
|
|
||||||
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
|
|
||||||
para = nil
|
|
||||||
}
|
|
||||||
var a meta.Affordance
|
|
||||||
if len(useWhen) > 0 {
|
|
||||||
a.UseWhen = useWhen
|
|
||||||
}
|
|
||||||
for _, s := range secs {
|
|
||||||
switch standardSection[s.label] {
|
|
||||||
case "avoid_when":
|
|
||||||
a.AvoidWhen = s.items
|
|
||||||
case "prerequisites":
|
|
||||||
a.Prerequisites = s.items
|
|
||||||
case "tips":
|
|
||||||
a.Tips = s.items
|
|
||||||
case "examples":
|
|
||||||
a.Examples = s.cases
|
|
||||||
default:
|
|
||||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if skill != "" {
|
|
||||||
a.Skills = []string{skill}
|
|
||||||
}
|
|
||||||
out[curKey] = a
|
|
||||||
}
|
|
||||||
|
|
||||||
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
|
|
||||||
|
|
||||||
// flushPending appends a non-bullet paragraph line that was not consumed as
|
|
||||||
// an example description (i.e. no fence followed) to the current section's
|
|
||||||
// items, so prose under any section is preserved rather than dropped.
|
|
||||||
flushPending := func() {
|
|
||||||
if sec != nil && pending != "" {
|
|
||||||
sec.items = append(sec.items, linkToBacktick(pending))
|
|
||||||
pending = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, raw := range strings.Split(string(src), "\n") {
|
|
||||||
line := strings.TrimRight(raw, "\r")
|
|
||||||
t := strings.TrimSpace(line)
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(line, "## "):
|
|
||||||
flushPending()
|
|
||||||
assemble()
|
|
||||||
curKey = resolve(line[3:])
|
|
||||||
reset()
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(line, "# "):
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(t, "> skill:"):
|
|
||||||
skill = strings.TrimSpace(t[len("> skill:"):])
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(line, "### "):
|
|
||||||
flushPending()
|
|
||||||
sec = &mdSection{label: strings.TrimSpace(line[4:])}
|
|
||||||
secs = append(secs, sec)
|
|
||||||
pending, fence, inFence = "", nil, false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if curKey == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
|
|
||||||
if t == "" {
|
|
||||||
if len(para) > 0 {
|
|
||||||
useWhen = append(useWhen, strings.Join(para, " "))
|
|
||||||
para = nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
para = append(para, t)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// inside a section: a fenced block is an example command; otherwise the
|
|
||||||
// shape follows the writing (bullet item vs **description** before a fence).
|
|
||||||
if strings.HasPrefix(t, "```") {
|
|
||||||
if !inFence {
|
|
||||||
inFence, fence = true, nil
|
|
||||||
} else {
|
|
||||||
inFence = false
|
|
||||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
|
||||||
pending = ""
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inFence {
|
|
||||||
fence = append(fence, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(t, "-") {
|
|
||||||
flushPending()
|
|
||||||
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
|
|
||||||
} else if t != "" {
|
|
||||||
flushPending()
|
|
||||||
pending = strings.Trim(t, "* ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushPending()
|
|
||||||
assemble()
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -18,9 +18,6 @@ type IOStreams struct {
|
|||||||
Out io.Writer
|
Out io.Writer
|
||||||
ErrOut io.Writer
|
ErrOut io.Writer
|
||||||
IsTerminal bool
|
IsTerminal bool
|
||||||
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
|
|
||||||
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
|
|
||||||
OutIsTerminal bool
|
|
||||||
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
||||||
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
||||||
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
||||||
@@ -30,24 +27,19 @@ type IOStreams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||||
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
|
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
|
||||||
// underlying *os.File of in / out / errOut respectively; non-file
|
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
|
||||||
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
|
// false.
|
||||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||||
fileIsTerminal := func(v any) bool {
|
isTerminal := false
|
||||||
if f, ok := v.(*os.File); ok {
|
if f, ok := in.(*os.File); ok {
|
||||||
return term.IsTerminal(int(f.Fd()))
|
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return &IOStreams{
|
stderrIsTerminal := false
|
||||||
In: in,
|
if f, ok := errOut.(*os.File); ok {
|
||||||
Out: out,
|
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
|
||||||
ErrOut: errOut,
|
|
||||||
IsTerminal: fileIsTerminal(in),
|
|
||||||
OutIsTerminal: fileIsTerminal(out),
|
|
||||||
StderrIsTerminal: fileIsTerminal(errOut),
|
|
||||||
}
|
}
|
||||||
|
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package cmdutil
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
|
|
||||||
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
|
||||||
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
|
|
||||||
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
|
|
||||||
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
|
|
||||||
r, w, err := os.Pipe()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer r.Close()
|
|
||||||
defer w.Close()
|
|
||||||
s := NewIOStreams(r, w, w)
|
|
||||||
if s.OutIsTerminal || s.StderrIsTerminal {
|
|
||||||
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,12 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
@@ -38,6 +40,8 @@ const (
|
|||||||
BuildKindUnknown = "unknown"
|
BuildKindUnknown = "unknown"
|
||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
|
agentTraceMaxLen = 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
@@ -45,6 +49,25 @@ func UserAgentValue() string {
|
|||||||
return SourceValue + "/" + build.Version
|
return SourceValue + "/" + build.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentTraceValue returns a header-safe value from the
|
||||||
|
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
||||||
|
// surrounding whitespace, rejects values containing any Unicode
|
||||||
|
// control character or exceeding agentTraceMaxLen, and returns ""
|
||||||
|
// for any invalid or empty value. Callers can use the result
|
||||||
|
// directly in HTTP headers without further sanitisation.
|
||||||
|
func AgentTraceValue() string {
|
||||||
|
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
||||||
|
if v == "" || len(v) > agentTraceMaxLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// BaseSecurityHeaders returns headers that every request must carry.
|
// BaseSecurityHeaders returns headers that every request must carry.
|
||||||
func BaseSecurityHeaders() http.Header {
|
func BaseSecurityHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
@@ -52,7 +75,7 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
h.Set(HeaderVersion, build.Version)
|
h.Set(HeaderVersion, build.Version)
|
||||||
h.Set(HeaderBuild, DetectBuildKind())
|
h.Set(HeaderBuild, DetectBuildKind())
|
||||||
h.Set(HeaderUserAgent, UserAgentValue())
|
h.Set(HeaderUserAgent, UserAgentValue())
|
||||||
if v := envvars.AgentTrace(); v != "" {
|
if v := AgentTraceValue(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
@@ -263,9 +264,88 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
// AgentTraceValue / HeaderAgentTrace
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
||||||
|
if got := AgentTraceValue(); got != "trace-abc-123" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
||||||
|
if got := AgentTraceValue(); got != "trace-trim" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, " ")
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||||
|
t.Setenv(envvars.CliAgentTrace, longVal)
|
||||||
|
if got := AgentTraceValue(); got != "" {
|
||||||
|
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
||||||
|
val := strings.Repeat("a", agentTraceMaxLen)
|
||||||
|
t.Setenv(envvars.CliAgentTrace, val)
|
||||||
|
if got := AgentTraceValue(); got != val {
|
||||||
|
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const (
|
|||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||||
|
|
||||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package envvars
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
agentNameMaxLen = 128
|
|
||||||
agentTraceMaxLen = 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
func AgentName() string {
|
|
||||||
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
|
||||||
}
|
|
||||||
|
|
||||||
func AgentTrace() string {
|
|
||||||
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeSingleLine(raw string, maxLen int) string {
|
|
||||||
v := strings.TrimSpace(raw)
|
|
||||||
if v == "" || len(v) > maxLen {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, r := range v {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package envvars
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentName, "")
|
|
||||||
if got := AgentName(); got != "" {
|
|
||||||
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentName, "claude-code")
|
|
||||||
if got := AgentName(); got != "claude-code" {
|
|
||||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentName, " cursor ")
|
|
||||||
if got := AgentName(); got != "cursor" {
|
|
||||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
|
||||||
if got := AgentName(); got != "" {
|
|
||||||
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentName_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentName, "agent\x01injected")
|
|
||||||
if got := AgentName(); got != "" {
|
|
||||||
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
|
||||||
t.Setenv(CliAgentName, longVal)
|
|
||||||
if got := AgentName(); got != "" {
|
|
||||||
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, "")
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, "trace-abc-123")
|
|
||||||
if got := AgentTrace(); got != "trace-abc-123" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, " trace-trim ")
|
|
||||||
if got := AgentTrace(); got != "trace-trim" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, " ")
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_RejectsLF(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_RejectsTab(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, "val\tinjected")
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, "val\x01injected")
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
|
||||||
t.Setenv(CliAgentTrace, "val\x7finjected")
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
|
||||||
t.Setenv(CliAgentTrace, longVal)
|
|
||||||
if got := AgentTrace(); got != "" {
|
|
||||||
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
|
||||||
val := strings.Repeat("a", agentTraceMaxLen)
|
|
||||||
t.Setenv(CliAgentTrace, val)
|
|
||||||
if got := AgentTrace(); got != val {
|
|
||||||
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||||
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||||
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
// depend on internal/core (which would create an import cycle).
|
||||||
// coupling this contract to core's brand enum.
|
|
||||||
type ClassifyContext struct {
|
type ClassifyContext struct {
|
||||||
Brand string // "feishu" | "lark" — drives console_url host
|
Brand string // "feishu" | "lark" — drives console_url host
|
||||||
AppID string // placed in console_url
|
AppID string // placed in console_url
|
||||||
@@ -446,27 +444,28 @@ func extractMissingScopes(resp map[string]any) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||||
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||||
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||||
// returns the page carrying only clientID; otherwise scopes are joined with
|
// commas in the `q` query parameter so the console can pre-select them.
|
||||||
// commas in the `scopes` query parameter so the console can pre-select them.
|
|
||||||
//
|
//
|
||||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||||
if appID == "" {
|
if appID == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// QueryEscape both values — clientID and scopes both sit in the query
|
host := "open.feishu.cn"
|
||||||
// string, and untrusted content must not be able to inject extra query
|
if brand == "lark" {
|
||||||
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
host = "open.larksuite.com"
|
||||||
// open-platform base URL stays a single source of truth.
|
|
||||||
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
|
||||||
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
|
||||||
if len(scopes) == 0 {
|
|
||||||
return base
|
|
||||||
}
|
}
|
||||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||||
|
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||||
|
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||||
|
pathID := url.PathEscape(appID)
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||||
}
|
}
|
||||||
|
|
||||||
func intFromAny(v any) int {
|
func intFromAny(v any) int {
|
||||||
|
|||||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
|||||||
name: "ampersand in scope smuggles extra param",
|
name: "ampersand in scope smuggles extra param",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope&evil=injected"},
|
scopes: []string{"scope&evil=injected"},
|
||||||
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||||
denyInURL: []string{"scopes=scope&evil=injected"},
|
denyInURL: []string{"q=scope&evil=injected"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in scope splits fragment",
|
name: "hash in scope splits fragment",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope#fragment"},
|
scopes: []string{"scope#fragment"},
|
||||||
wantInURL: []string{"scopes=scope%23fragment"},
|
wantInURL: []string{"q=scope%23fragment"},
|
||||||
denyInURL: []string{"scopes=scope#fragment"},
|
denyInURL: []string{"q=scope#fragment"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "question mark in appID prematurely opens query",
|
name: "question mark in appID prematurely opens query",
|
||||||
appID: "good?q=injected",
|
appID: "good?q=injected",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||||
denyInURL: []string{"clientID=good?q=injected"},
|
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in appID truncates URL",
|
name: "hash in appID truncates URL",
|
||||||
appID: "good#fragment",
|
appID: "good#fragment",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"clientID=good%23fragment"},
|
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||||
denyInURL: []string{"clientID=good#fragment"},
|
denyInURL: []string{"/app/good#fragment/auth"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "slash in appID does not open a new path segment",
|
name: "slash in appID escapes path segment",
|
||||||
appID: "good/extra/segment",
|
appID: "good/extra/segment",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
|||||||
if pe.MissingScopes != nil {
|
if pe.MissingScopes != nil {
|
||||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
|||||||
// at the app level — re-authenticating cannot fix it. The hint must
|
// at the app level — re-authenticating cannot fix it. The hint must
|
||||||
// point to the developer console regardless of caller identity, or
|
// point to the developer console regardless of caller identity, or
|
||||||
// agents will loop on `auth login` forever.
|
// agents will loop on `auth login` forever.
|
||||||
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
||||||
for _, identity := range []string{"user", "bot", ""} {
|
for _, identity := range []string{"user", "bot", ""} {
|
||||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||||
if !strings.Contains(got, "developer console") {
|
if !strings.Contains(got, "developer console") {
|
||||||
|
|||||||
@@ -10,20 +10,8 @@ import "github.com/larksuite/cli/errs"
|
|||||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
var driveCodeMeta = map[int]CodeMeta{
|
var driveCodeMeta = map[int]CodeMeta{
|
||||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
|
||||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
|
||||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
|
||||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
|
||||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
|
||||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
|
||||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
|
||||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
|
||||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
|
||||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
|
||||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
|
||||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
|||||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
// Secure label endpoint codes observed from drive +secure-label-update
|
|
||||||
// failure telemetry.
|
|
||||||
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
|
||||||
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
|
||||||
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
|
|
||||||
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
|
||||||
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
|||||||
@@ -102,35 +102,6 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
code int
|
|
||||||
wantCat errs.Category
|
|
||||||
wantSubtype errs.Subtype
|
|
||||||
wantRetry bool
|
|
||||||
}{
|
|
||||||
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
|
||||||
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
|
||||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
|
||||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
|
||||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
|
||||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
|
||||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
|
||||||
got, ok := LookupCodeMeta(tc.code)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
|
||||||
}
|
|
||||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
|
||||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
|
||||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||||
_, ok := LookupCodeMeta(999999)
|
_, ok := LookupCodeMeta(999999)
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -62,131 +61,12 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
|
|||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
// An external provider mints tokens on demand and blocks interactive auth,
|
|
||||||
// so the built-in keychain heuristics and "auth login" hints don't apply.
|
|
||||||
if provider := activeExternalProvider(ctx, f); provider != "" {
|
|
||||||
return diagnoseExternal(ctx, f, cfg, provider, verify)
|
|
||||||
}
|
|
||||||
return Result{
|
return Result{
|
||||||
Bot: diagnoseBot(ctx, f, cfg, verify),
|
Bot: diagnoseBot(ctx, f, cfg, verify),
|
||||||
User: diagnoseUser(ctx, f, cfg, verify),
|
User: diagnoseUser(ctx, f, cfg, verify),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeExternalProvider returns the active extension provider name, or "".
|
|
||||||
// An error degrades to the built-in path: an unreachable provider would already
|
|
||||||
// have failed the f.Config() that produced cfg.
|
|
||||||
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
|
|
||||||
if f == nil || f.Credential == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
name, err := f.Credential.ActiveExtensionProviderName(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
|
|
||||||
if cfg == nil || cfg.AppID == "" {
|
|
||||||
notConfigured := Identity{
|
|
||||||
Status: StatusNotConfigured,
|
|
||||||
Message: "not configured (missing app config)",
|
|
||||||
Hint: externalCredentialHint(provider),
|
|
||||||
}
|
|
||||||
return Result{Bot: notConfigured, User: notConfigured}
|
|
||||||
}
|
|
||||||
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
|
|
||||||
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
|
|
||||||
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
|
|
||||||
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
|
|
||||||
return Result{
|
|
||||||
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
|
|
||||||
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
|
||||||
if !supported {
|
|
||||||
return notProvidedExternally("Bot", provider)
|
|
||||||
}
|
|
||||||
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
|
|
||||||
if !verify {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
token, err := resolveBotToken(ctx, f, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return externalVerifyFailed(id, "Bot", provider, err)
|
|
||||||
}
|
|
||||||
info, err := fetchBotInfo(ctx, f, cfg, token)
|
|
||||||
if err != nil {
|
|
||||||
return externalVerifyFailed(id, "Bot", provider, err)
|
|
||||||
}
|
|
||||||
id.Verified = boolPtr(true)
|
|
||||||
id.OpenID = info.OpenID
|
|
||||||
id.AppName = info.AppName
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
|
||||||
if !supported {
|
|
||||||
return notProvidedExternally("User", provider)
|
|
||||||
}
|
|
||||||
// enrichUserInfo populates UserOpenId only after the provider returns and
|
|
||||||
// verifies a UAT (and clears it on failure), so a resolved open id is the
|
|
||||||
// external analogue of a keychain token being present.
|
|
||||||
if cfg.UserOpenId == "" {
|
|
||||||
return Identity{
|
|
||||||
Status: StatusMissing,
|
|
||||||
Message: "User identity: not signed in via credential source " + provider,
|
|
||||||
Hint: externalCredentialHint(provider),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
id := Identity{
|
|
||||||
Status: StatusReady,
|
|
||||||
Available: true,
|
|
||||||
TokenStatus: StatusReady,
|
|
||||||
UserName: cfg.UserName,
|
|
||||||
OpenID: cfg.UserOpenId,
|
|
||||||
Message: "User identity: ready (provided by " + provider + ")",
|
|
||||||
}
|
|
||||||
if !verify {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
|
|
||||||
return externalVerifyFailed(id, "User", provider, err)
|
|
||||||
}
|
|
||||||
id.Verified = boolPtr(true)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
func notProvidedExternally(label, provider string) Identity {
|
|
||||||
return Identity{
|
|
||||||
Status: StatusNotConfigured,
|
|
||||||
Message: label + " identity: not provided by credential source " + provider,
|
|
||||||
Hint: externalCredentialHint(provider),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
|
|
||||||
// (open id, user name) already resolved before the probe.
|
|
||||||
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
|
|
||||||
id.Available = false
|
|
||||||
id.Verified = boolPtr(false)
|
|
||||||
id.Status = StatusVerifyFailed
|
|
||||||
id.TokenStatus = ""
|
|
||||||
id.Message = label + " identity: verify failed: " + err.Error()
|
|
||||||
id.Hint = externalCredentialHint(provider)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// externalCredentialHint reports the constraint, not a remediation: the
|
|
||||||
// identity is the provider's to manage, not lark-cli's to fix. What to do about
|
|
||||||
// it is the caller's call — there may be no user to ask.
|
|
||||||
func externalCredentialHint(provider string) string {
|
|
||||||
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||||
if cfg == nil || cfg.AppID == "" {
|
if cfg == nil || cfg.AppID == "" {
|
||||||
return Identity{
|
return Identity{
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/zalando/go-keyring"
|
"github.com/zalando/go-keyring"
|
||||||
)
|
)
|
||||||
@@ -350,136 +348,3 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
|
|||||||
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
|
|
||||||
// external-credential diagnosis path. account makes the provider "active";
|
|
||||||
// token (when set) satisfies ResolveToken during verify.
|
|
||||||
type fakeExtProvider struct {
|
|
||||||
name string
|
|
||||||
account *extcred.Account
|
|
||||||
token *extcred.Token
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *fakeExtProvider) Name() string { return p.name }
|
|
||||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
|
||||||
return p.account, nil
|
|
||||||
}
|
|
||||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
|
||||||
return p.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
|
|
||||||
cred := credential.NewCredentialProvider(
|
|
||||||
[]extcred.Provider{prov}, nil, nil,
|
|
||||||
func() (*http.Client, error) { return nil, nil },
|
|
||||||
)
|
|
||||||
return &cmdutil.Factory{
|
|
||||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
|
||||||
Credential: cred,
|
|
||||||
IOStreams: &cmdutil.IOStreams{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertExternalHint locks the contract that an external-provider hint never
|
|
||||||
// points at interactive commands blocked under an external provider.
|
|
||||||
func assertExternalHint(t *testing.T, hint string) {
|
|
||||||
t.Helper()
|
|
||||||
if hint == "" {
|
|
||||||
t.Fatalf("hint empty, want external guidance")
|
|
||||||
}
|
|
||||||
for _, blocked := range []string{"auth login", "config --help"} {
|
|
||||||
if strings.Contains(hint, blocked) {
|
|
||||||
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !strings.Contains(hint, "external") {
|
|
||||||
t.Fatalf("hint %q should explain credentials are external", hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiagnose_External_UserReady(t *testing.T) {
|
|
||||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
|
|
||||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
|
||||||
|
|
||||||
got := Diagnose(context.Background(), f, cfg, false)
|
|
||||||
// The bug this guards: the built-in path read the keychain (empty under an
|
|
||||||
// external provider) and reported the user as missing. Now availability
|
|
||||||
// follows the resolved account, so a signed-in user reads as ready.
|
|
||||||
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
|
|
||||||
t.Fatalf("user = %#v, want ready/available", got.User)
|
|
||||||
}
|
|
||||||
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
|
|
||||||
t.Fatalf("user identity = %#v", got.User)
|
|
||||||
}
|
|
||||||
if got.User.Hint != "" {
|
|
||||||
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
|
|
||||||
}
|
|
||||||
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
|
||||||
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
|
|
||||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
|
|
||||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
|
||||||
|
|
||||||
got := Diagnose(context.Background(), f, cfg, false)
|
|
||||||
if got.User.Available || got.User.Status != StatusMissing {
|
|
||||||
t.Fatalf("user = %#v, want missing/unavailable", got.User)
|
|
||||||
}
|
|
||||||
assertExternalHint(t, got.User.Hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiagnose_External_BotOnly(t *testing.T) {
|
|
||||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
|
|
||||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
|
||||||
|
|
||||||
got := Diagnose(context.Background(), f, cfg, false)
|
|
||||||
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
|
||||||
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
|
||||||
}
|
|
||||||
// Provider declares bot-only: user is unavailable even though an open id is
|
|
||||||
// present, and the hint is external (not "auth login").
|
|
||||||
if got.User.Available || got.User.Status != StatusNotConfigured {
|
|
||||||
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
|
|
||||||
}
|
|
||||||
assertExternalHint(t, got.User.Hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiagnose_External_UserOnly(t *testing.T) {
|
|
||||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
|
|
||||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
|
||||||
|
|
||||||
got := Diagnose(context.Background(), f, cfg, false)
|
|
||||||
if !got.User.Available || got.User.Status != StatusReady {
|
|
||||||
t.Fatalf("user = %#v, want ready/available", got.User)
|
|
||||||
}
|
|
||||||
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
|
|
||||||
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
|
|
||||||
}
|
|
||||||
assertExternalHint(t, got.Bot.Hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
|
|
||||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
|
|
||||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
|
|
||||||
|
|
||||||
got := Diagnose(context.Background(), f, cfg, true)
|
|
||||||
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
|
|
||||||
t.Fatalf("user = %#v, want available and verified", got.User)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
|
|
||||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
|
|
||||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
|
||||||
|
|
||||||
got := Diagnose(context.Background(), f, cfg, true)
|
|
||||||
if got.User.Available || got.User.Status != StatusVerifyFailed {
|
|
||||||
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
|
|
||||||
}
|
|
||||||
if got.User.Verified == nil || *got.User.Verified {
|
|
||||||
t.Fatalf("verified = %v, want false", got.User.Verified)
|
|
||||||
}
|
|
||||||
assertExternalHint(t, got.User.Hint)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,39 +5,30 @@ package meta
|
|||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
|
|
||||||
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
// Affordance is the hand-authored usage guidance overlaid on a method: when to
|
||||||
// model the envelope renderer and the command help both parse, so the
|
// use it, when not to, prerequisites, few-shot examples, and related methods.
|
||||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
// It is the single typed model of the affordance shape; the envelope renderer
|
||||||
// Skills entries are skill names (or name/path) rendered as runnable
|
// and the command help both parse through ParsedAffordance so the vocabulary
|
||||||
// `lark-cli skills read <entry>` pointers.
|
// is defined once. The JSON tags double as the envelope's wire shape.
|
||||||
type Affordance struct {
|
type Affordance struct {
|
||||||
UseWhen []string `json:"use_when,omitempty"`
|
UseWhen []string `json:"use_when,omitempty"`
|
||||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
||||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||||
Tips []string `json:"tips,omitempty"`
|
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
Related []string `json:"related,omitempty"`
|
||||||
Extensions []AffordanceSection `json:"extensions,omitempty"`
|
|
||||||
Related []string `json:"related,omitempty"`
|
|
||||||
Skills []string `json:"skills,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
|
// AffordanceCase is one few-shot example: a one-line description and a
|
||||||
|
// ready-to-run command.
|
||||||
type AffordanceCase struct {
|
type AffordanceCase struct {
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AffordanceSection is a custom guidance section: any heading beyond the
|
// ParsedAffordance decodes the method's raw affordance overlay into the typed
|
||||||
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
|
// Affordance. ok is false when the method carries no affordance, the JSON is
|
||||||
// here with its label preserved, so authors can add sections without code
|
// malformed, or every section is empty — so callers can treat "no guidance"
|
||||||
// changes.
|
// uniformly.
|
||||||
type AffordanceSection struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Items []string `json:"items,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
|
|
||||||
// malformed, or wholly empty — callers treat all three as "no guidance".
|
|
||||||
func (m Method) ParsedAffordance() (Affordance, bool) {
|
func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||||
if len(m.Affordance) == 0 {
|
if len(m.Affordance) == 0 {
|
||||||
return Affordance{}, false
|
return Affordance{}, false
|
||||||
@@ -46,7 +37,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
|
|||||||
if json.Unmarshal(m.Affordance, &a) != nil {
|
if json.Unmarshal(m.Affordance, &a) != nil {
|
||||||
return Affordance{}, false
|
return Affordance{}, false
|
||||||
}
|
}
|
||||||
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
|
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||||
return Affordance{}, false
|
return Affordance{}, false
|
||||||
}
|
}
|
||||||
return a, true
|
return a, true
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
|||||||
notOK := map[string]string{
|
notOK := map[string]string{
|
||||||
"empty payload": ``,
|
"empty payload": ``,
|
||||||
"empty object": `{}`,
|
"empty object": `{}`,
|
||||||
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
|
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
|
||||||
"malformed string": `"not an object"`,
|
"malformed string": `"not an object"`,
|
||||||
"malformed number": `42`,
|
"malformed number": `42`,
|
||||||
"nested type mismatch": `{"examples":"should be a list"}`,
|
"nested type mismatch": `{"examples":"should be a list"}`,
|
||||||
@@ -35,9 +35,8 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
|||||||
// Populated affordance parses with all fields.
|
// Populated affordance parses with all fields.
|
||||||
raw := `{
|
raw := `{
|
||||||
"use_when": ["需要拿到当前用户的主日历 ID"],
|
"use_when": ["需要拿到当前用户的主日历 ID"],
|
||||||
"avoid_when": ["已知具体 calendar_id"],
|
"do_not_use_when": ["已知具体 calendar_id"],
|
||||||
"prerequisites": ["user 身份登录"],
|
"prerequisites": ["user 身份登录"],
|
||||||
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
|
|
||||||
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
||||||
"related": ["calendars.list"]
|
"related": ["calendars.list"]
|
||||||
}`
|
}`
|
||||||
@@ -48,22 +47,10 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
|||||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||||
}
|
}
|
||||||
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
|
|
||||||
t.Errorf("Tips = %v", a.Tips)
|
|
||||||
}
|
|
||||||
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||||
t.Errorf("Examples = %+v", a.Examples)
|
t.Errorf("Examples = %+v", a.Examples)
|
||||||
}
|
}
|
||||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||||
t.Errorf("Related = %v", a.Related)
|
t.Errorf("Related = %v", a.Related)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A method whose only guidance is Tips still parses as populated.
|
|
||||||
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
|
|
||||||
}
|
|
||||||
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
|
|
||||||
t.Errorf("Tips = %v", tipsOnly.Tips)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ func isPlaceholderValue(value string) bool {
|
|||||||
normalized := strings.ToLower(trimmed)
|
normalized := strings.ToLower(trimmed)
|
||||||
if normalized == "" ||
|
if normalized == "" ||
|
||||||
normalized == "=" ||
|
normalized == "=" ||
|
||||||
printfPlaceholderValue(normalized) ||
|
|
||||||
htmlEntityAnglePlaceholder(normalized) ||
|
|
||||||
starMaskedPlaceholder(normalized) ||
|
|
||||||
percentWrappedPlaceholder(normalized) ||
|
percentWrappedPlaceholder(normalized) ||
|
||||||
angleWrappedPlaceholder(normalized) ||
|
angleWrappedPlaceholder(normalized) ||
|
||||||
urlWithAnglePlaceholder(normalized) ||
|
urlWithAnglePlaceholder(normalized) ||
|
||||||
@@ -64,42 +61,12 @@ func isPlaceholderValue(value string) bool {
|
|||||||
return namedPlaceholderValue(normalized)
|
return namedPlaceholderValue(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func htmlEntityAnglePlaceholder(value string) bool {
|
|
||||||
if !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func starMaskedPlaceholder(value string) bool {
|
|
||||||
var stars int
|
|
||||||
for _, r := range value {
|
|
||||||
if r == '*' {
|
|
||||||
stars++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return stars >= 3
|
|
||||||
}
|
|
||||||
|
|
||||||
func namedPlaceholderValue(value string) bool {
|
func namedPlaceholderValue(value string) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
|
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return strings.Contains(value, "cli_example") ||
|
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
|
||||||
allXPlaceholder(value) ||
|
|
||||||
conventionalNamedPlaceholderValue(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printfPlaceholderValue(value string) bool {
|
|
||||||
switch value {
|
|
||||||
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func allXPlaceholder(value string) bool {
|
func allXPlaceholder(value string) bool {
|
||||||
@@ -114,41 +81,6 @@ func allXPlaceholder(value string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func conventionalNamedPlaceholderValue(value string) bool {
|
|
||||||
if !delimitedPlaceholderIdentifier(value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
normalized := strings.ReplaceAll(value, "-", "_")
|
|
||||||
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
|
|
||||||
return conventionalCredentialPlaceholderName(rest)
|
|
||||||
}
|
|
||||||
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
|
|
||||||
return conventionalCredentialPlaceholderName(rest)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func conventionalCredentialPlaceholderName(value string) bool {
|
|
||||||
switch value {
|
|
||||||
case "api_key",
|
|
||||||
"access_key",
|
|
||||||
"private_key",
|
|
||||||
"secret",
|
|
||||||
"password",
|
|
||||||
"passwd",
|
|
||||||
"token",
|
|
||||||
"webhook",
|
|
||||||
"access_token",
|
|
||||||
"refresh_token",
|
|
||||||
"bearer_token",
|
|
||||||
"session_token",
|
|
||||||
"client_secret":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func urlWithAnglePlaceholder(value string) bool {
|
func urlWithAnglePlaceholder(value string) bool {
|
||||||
if !strings.Contains(value, "://") ||
|
if !strings.Contains(value, "://") ||
|
||||||
!strings.Contains(value, "<") ||
|
!strings.Contains(value, "<") ||
|
||||||
|
|||||||
@@ -4,10 +4,7 @@
|
|||||||
package publiccontent
|
package publiccontent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -55,9 +52,8 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
|||||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||||
if value == "" ||
|
if value == "" ||
|
||||||
isNonSecretLiteralValue(value) ||
|
isNonSecretLiteralValue(value) ||
|
||||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
isBenignCodeCredentialExpression(file, value) ||
|
||||||
isPlaceholderValue(value) ||
|
isPlaceholderValue(value) ||
|
||||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
|
||||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -67,27 +63,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
|||||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||||
}
|
}
|
||||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||||
if !isJWTToken(match) {
|
if isSchemaDottedIdentifier(line, match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
||||||
}
|
}
|
||||||
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
|
for range bearerHeaderRE.FindAllString(line, -1) {
|
||||||
if isPlaceholderBearerHeader(match) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||||
}
|
}
|
||||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||||
if isPlaceholderCredentialURL(file, match) {
|
if isPlaceholderCredentialURL(match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||||
}
|
}
|
||||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||||
if !warnForPrivateIPv4(file) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||||
}
|
}
|
||||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||||
@@ -134,9 +124,6 @@ func isCredentialAssignmentMatch(match string) bool {
|
|||||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return isExplicitCredentialKey(name)
|
return isExplicitCredentialKey(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +261,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
|
|||||||
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
||||||
return true
|
return true
|
||||||
case tokenLikePlaceholderKey(key):
|
case tokenLikePlaceholderKey(key):
|
||||||
return tokenLikePlaceholderValue(key, value)
|
return tokenLikePlaceholderValue(value)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -286,16 +273,12 @@ func tokenLikePlaceholderKey(key string) bool {
|
|||||||
strings.HasSuffix(key, "-token")
|
strings.HasSuffix(key, "-token")
|
||||||
}
|
}
|
||||||
|
|
||||||
func tokenLikePlaceholderValue(key, value string) bool {
|
func tokenLikePlaceholderValue(value string) bool {
|
||||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if authCredentialTokenKey(key) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return resourceTokenPlaceholderValue(value) ||
|
return resourceTokenPlaceholderValue(value) ||
|
||||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
|
||||||
isPlaceholderValue(value) ||
|
isPlaceholderValue(value) ||
|
||||||
normalized == "token" ||
|
normalized == "token" ||
|
||||||
strings.Contains(normalized, "...") ||
|
strings.Contains(normalized, "...") ||
|
||||||
@@ -305,149 +288,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
|||||||
strings.HasPrefix(normalized, ".")
|
strings.HasPrefix(normalized, ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
|
||||||
if authCredentialTokenKey(key) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var stars, alnum int
|
|
||||||
for _, r := range value {
|
|
||||||
switch {
|
|
||||||
case r == '*':
|
|
||||||
stars++
|
|
||||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
|
||||||
alnum++
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stars >= 6 && alnum > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isWeakTokenCredentialKey(key string) bool {
|
|
||||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return key == "token" ||
|
|
||||||
strings.HasSuffix(key, "_token") ||
|
|
||||||
strings.HasSuffix(key, "-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isStrongTokenCredentialKey(key string) bool {
|
|
||||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
|
||||||
for _, phrase := range [][2]string{
|
|
||||||
{"access", "token"},
|
|
||||||
{"refresh", "token"},
|
|
||||||
{"auth", "token"},
|
|
||||||
{"bearer", "token"},
|
|
||||||
{"session", "token"},
|
|
||||||
{"service", "token"},
|
|
||||||
{"bot", "token"},
|
|
||||||
{"api", "token"},
|
|
||||||
{"secret", "token"},
|
|
||||||
} {
|
|
||||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
|
||||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
|
||||||
if normalized == "" ||
|
|
||||||
isNonSecretLiteralValue(value) ||
|
|
||||||
isPlaceholderValue(value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
candidate := unwrapCredentialValue(normalized)
|
|
||||||
return credentialShapedIdentifier(candidate) ||
|
|
||||||
highEntropyCredentialValue(candidate) ||
|
|
||||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
|
||||||
(strings.Contains(normalized, "://") &&
|
|
||||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func unwrapCredentialValue(value string) string {
|
|
||||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
|
||||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
|
||||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
|
||||||
}
|
|
||||||
value = strings.TrimPrefix(value, "$")
|
|
||||||
value = strings.Trim(value, "%")
|
|
||||||
return strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func highEntropyCredentialValue(value string) bool {
|
|
||||||
if len(value) < 32 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var hasLetter, hasDigit bool
|
|
||||||
for _, r := range value {
|
|
||||||
switch {
|
|
||||||
case r >= 'a' && r <= 'z':
|
|
||||||
hasLetter = true
|
|
||||||
case r >= '0' && r <= '9':
|
|
||||||
hasDigit = true
|
|
||||||
case r == '_' || r == '-' || r == '.' || r == '=':
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
|
|
||||||
}
|
|
||||||
|
|
||||||
func shannonEntropy(value string) float64 {
|
|
||||||
if value == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
counts := map[rune]int{}
|
|
||||||
for _, r := range value {
|
|
||||||
counts[r]++
|
|
||||||
}
|
|
||||||
var entropy float64
|
|
||||||
length := float64(len([]rune(value)))
|
|
||||||
for _, count := range counts {
|
|
||||||
p := float64(count) / length
|
|
||||||
entropy -= p * log2(p)
|
|
||||||
}
|
|
||||||
return entropy
|
|
||||||
}
|
|
||||||
|
|
||||||
func log2(value float64) float64 {
|
|
||||||
return math.Log(value) / math.Ln2
|
|
||||||
}
|
|
||||||
|
|
||||||
func authCredentialTokenKey(key string) bool {
|
|
||||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
|
||||||
case "access_token",
|
|
||||||
"api_token",
|
|
||||||
"bot_token",
|
|
||||||
"refresh_token",
|
|
||||||
"secret_token",
|
|
||||||
"session_token",
|
|
||||||
"service_token",
|
|
||||||
"bearer_token",
|
|
||||||
"auth_token",
|
|
||||||
"authorization_token",
|
|
||||||
"id_token":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPermissionScopeIdentifierAssignment(key, value string) bool {
|
|
||||||
if !strings.HasSuffix(key, "_token") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch strings.ToLower(strings.Trim(value, `"',;`)) {
|
|
||||||
case "read", "write", "modify", "readonly", "get_as_user":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func idempotencyTokenPlaceholderValue(value string) bool {
|
func idempotencyTokenPlaceholderValue(value string) bool {
|
||||||
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
||||||
}
|
}
|
||||||
@@ -488,87 +328,20 @@ func numericStringPlaceholderValue(value string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
func isBenignCodeCredentialExpression(file, value string) bool {
|
||||||
normalized := strings.TrimSpace(value)
|
normalized := strings.TrimSpace(value)
|
||||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
|
||||||
return isBenignTypedCredentialRHS(rhs)
|
|
||||||
}
|
|
||||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
|
||||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.Contains(match, "+") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if rawValueQuoted {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if quotedLiteral(value) {
|
|
||||||
return sourceCodeLiteralLooksNonSecret(value, false)
|
|
||||||
}
|
|
||||||
return codeReferenceExpression(normalized)
|
return codeReferenceExpression(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
|
||||||
idx := strings.Index(line, match)
|
|
||||||
if idx < 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
key, ok := credentialAssignmentKey(match)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
rest := strings.TrimSpace(line[idx+len(key):])
|
|
||||||
if !strings.HasPrefix(rest, ":") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
|
||||||
assignmentIdx := strings.Index(typeAndRHS, "=")
|
|
||||||
if assignmentIdx < 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBenignTypedCredentialRHS(value string) bool {
|
|
||||||
value = strings.TrimRight(strings.TrimSpace(value), ",;")
|
|
||||||
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if credentialShapedValue(value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if quotedLiteral(value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return codeReferenceExpression(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func credentialAssignmentRawValueQuoted(match string) bool {
|
|
||||||
key, ok := credentialAssignmentKey(match)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
|
|
||||||
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
|
|
||||||
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeFile(file string) bool {
|
func sourceCodeFile(file string) bool {
|
||||||
switch filepath.Ext(file) {
|
switch filepath.Ext(file) {
|
||||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
case ".go", ".py":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -582,147 +355,7 @@ func quotedLiteral(value string) bool {
|
|||||||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
|
||||||
literal := strings.Trim(strings.TrimSpace(value), `"'`)
|
|
||||||
if strings.HasPrefix(literal, "/") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
|
|
||||||
sourceCodeEnvVarNameLiteral(literal) ||
|
|
||||||
sourceCodeAttributeNameLiteral(literal) ||
|
|
||||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
|
||||||
sourceCodeCredentialTermLiteral(literal) ||
|
|
||||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
|
||||||
sourceCodeVocabularyLiteral(literal) ||
|
|
||||||
sourceCodeSchemaTypeLiteral(literal) ||
|
|
||||||
benignCredentialStatusLiteral(literal)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeFormatArgumentContext(line, match string) bool {
|
|
||||||
idx := strings.Index(line, match)
|
|
||||||
if idx < 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
prefix := line[:idx]
|
|
||||||
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
|
|
||||||
prefix = prefix[semicolon+1:]
|
|
||||||
}
|
|
||||||
return strings.Contains(prefix, "fmt.") ||
|
|
||||||
strings.Contains(prefix, "log.") ||
|
|
||||||
strings.Contains(prefix, "printf(") ||
|
|
||||||
strings.Contains(prefix, "Printf(") ||
|
|
||||||
strings.Contains(prefix, "Errorf(") ||
|
|
||||||
strings.Contains(prefix, "Fprintf(")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeFormatStringLiteral(value string) bool {
|
|
||||||
for i := 0; i < len(value)-1; i++ {
|
|
||||||
if value[i] != '%' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if value[i+1] == '%' {
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
j := i + 1
|
|
||||||
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeEnvVarNameLiteral(value string) bool {
|
|
||||||
if value == "" || !strings.Contains(value, "_") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var hasCredentialMarker bool
|
|
||||||
for _, r := range value {
|
|
||||||
switch {
|
|
||||||
case r >= 'A' && r <= 'Z':
|
|
||||||
case r >= '0' && r <= '9':
|
|
||||||
case r == '_':
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
|
|
||||||
if strings.Contains(value, marker) {
|
|
||||||
hasCredentialMarker = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasCredentialMarker
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeAttributeNameLiteral(value string) bool {
|
|
||||||
normalized := strings.ToLower(value)
|
|
||||||
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
|
|
||||||
normalized := strings.ToLower(value)
|
|
||||||
return strings.HasPrefix(normalized, "fake_") ||
|
|
||||||
strings.HasPrefix(normalized, "fake-") ||
|
|
||||||
strings.Contains(normalized, "placeholder") ||
|
|
||||||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeCredentialTermLiteral(value string) bool {
|
|
||||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
|
||||||
return conventionalCredentialPlaceholderName(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeCredentialPrefixLiteral(value string) bool {
|
|
||||||
switch strings.ToLower(value) {
|
|
||||||
case "appsecret:":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeVocabularyLiteral(value string) bool {
|
|
||||||
switch strings.ToLower(value) {
|
|
||||||
case "bot", "tenant", "user":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceCodeSchemaTypeLiteral(value string) bool {
|
|
||||||
normalized := strings.ToLower(value)
|
|
||||||
return normalized == "string" || strings.HasPrefix(normalized, "string(")
|
|
||||||
}
|
|
||||||
|
|
||||||
func benignCredentialStatusLiteral(value string) bool {
|
|
||||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
|
||||||
if !delimitedPlaceholderIdentifier(normalized) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, marker := range []string{
|
|
||||||
"bad_fmt",
|
|
||||||
"expired",
|
|
||||||
"format",
|
|
||||||
"invalid",
|
|
||||||
"missing",
|
|
||||||
"permission",
|
|
||||||
"status",
|
|
||||||
"type",
|
|
||||||
} {
|
|
||||||
if strings.Contains(normalized, marker) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func codeReferenceExpression(value string) bool {
|
func codeReferenceExpression(value string) bool {
|
||||||
value = strings.TrimRight(strings.TrimSpace(value), ";")
|
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -731,10 +364,7 @@ func codeReferenceExpression(value string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !codeIdentifier(value) {
|
return codeIdentifier(value) && !credentialNameFragment(value)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return codeIdentifier(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func codeIdentifier(value string) bool {
|
func codeIdentifier(value string) bool {
|
||||||
@@ -751,6 +381,20 @@ func codeIdentifier(value string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func credentialNameFragment(value string) bool {
|
||||||
|
normalized := strings.ToLower(value)
|
||||||
|
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
|
||||||
|
if strings.Contains(normalized, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSchemaDottedIdentifier(line, match string) bool {
|
||||||
|
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
|
||||||
|
}
|
||||||
|
|
||||||
func isNonSecretLiteralValue(value string) bool {
|
func isNonSecretLiteralValue(value string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||||
case "true", "false", "null", "nil", "{", "[":
|
case "true", "false", "null", "nil", "{", "[":
|
||||||
@@ -760,40 +404,6 @@ func isNonSecretLiteralValue(value string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isJWTToken(value string) bool {
|
|
||||||
parts := strings.Split(value, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
header, err := decodeBase64URLSegment(parts[0])
|
|
||||||
if err != nil || !json.Valid(header) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var fields map[string]interface{}
|
|
||||||
if err := json.Unmarshal(header, &fields); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
alg, ok := fields["alg"].(string)
|
|
||||||
return ok && alg != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeBase64URLSegment(value string) ([]byte, error) {
|
|
||||||
if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil {
|
|
||||||
return decoded, nil
|
|
||||||
}
|
|
||||||
return base64.URLEncoding.DecodeString(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPlaceholderBearerHeader(match string) bool {
|
|
||||||
normalized := strings.ToLower(match)
|
|
||||||
idx := strings.LastIndex(normalized, "bearer ")
|
|
||||||
if idx < 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
value := strings.TrimSpace(match[idx+len("bearer "):])
|
|
||||||
return isPlaceholderValue(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isWebhookCredentialKey(key string) bool {
|
func isWebhookCredentialKey(key string) bool {
|
||||||
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
|
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
|
||||||
}
|
}
|
||||||
@@ -952,7 +562,7 @@ func looksLikeEqualityComparison(value string) bool {
|
|||||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPlaceholderCredentialURL(file, raw string) bool {
|
func isPlaceholderCredentialURL(raw string) bool {
|
||||||
userInfo, ok := credentialURLUserInfo(raw)
|
userInfo, ok := credentialURLUserInfo(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -961,8 +571,7 @@ func isPlaceholderCredentialURL(file, raw string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return credentialURLPasswordPlaceholder(password) ||
|
return credentialURLPasswordPlaceholder(password)
|
||||||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func credentialURLPasswordPlaceholder(password string) bool {
|
func credentialURLPasswordPlaceholder(password string) bool {
|
||||||
@@ -976,46 +585,6 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
|||||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
func credentialURLPasswordFixture(password string) bool {
|
|
||||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
|
||||||
switch normalized {
|
|
||||||
case "p",
|
|
||||||
"pass",
|
|
||||||
"password",
|
|
||||||
"pat_abc",
|
|
||||||
"pw",
|
|
||||||
"s3cret",
|
|
||||||
"secret",
|
|
||||||
"t":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceOrTestFixtureFile(file string) bool {
|
|
||||||
normalized := filepath.ToSlash(file)
|
|
||||||
return sourceCodeFile(normalized) ||
|
|
||||||
strings.HasPrefix(normalized, "testdata/") ||
|
|
||||||
strings.HasPrefix(normalized, "fixtures/") ||
|
|
||||||
strings.Contains(normalized, "/testdata/") ||
|
|
||||||
strings.Contains(normalized, "/fixtures/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func warnForPrivateIPv4(file string) bool {
|
|
||||||
normalized := filepath.ToSlash(file)
|
|
||||||
if sourceOrTestFixtureFile(normalized) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch filepath.Ext(normalized) {
|
|
||||||
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return strings.HasPrefix(normalized, "docs/") ||
|
|
||||||
strings.HasPrefix(normalized, "skills/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func credentialURLUserInfo(raw string) (string, bool) {
|
func credentialURLUserInfo(raw string) (string, bool) {
|
||||||
schemeIdx := strings.Index(raw, "://")
|
schemeIdx := strings.Index(raw, "://")
|
||||||
if schemeIdx < 0 {
|
if schemeIdx < 0 {
|
||||||
@@ -1172,12 +741,7 @@ func sanitizeSemanticExcerpt(text string) string {
|
|||||||
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
|
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
|
||||||
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
|
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
|
||||||
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
|
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
|
||||||
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
|
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
|
||||||
if isJWTToken(match) {
|
|
||||||
return "<jwt-like-token>"
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
})
|
|
||||||
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
|
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
|
||||||
return strings.Join(strings.Fields(text), " ")
|
return strings.Join(strings.Fields(text), " ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,19 +61,6 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
|
|
||||||
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
|
|
||||||
`proxy := "http://user:pass@10.0.0.1:3128"`,
|
|
||||||
`target := "socks5://admin:secret@172.16.0.1:1080"`,
|
|
||||||
`host := "192.168.0.10"`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_private_ipv4" {
|
|
||||||
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
||||||
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||||
if len(benign) != 0 {
|
if len(benign) != 0 {
|
||||||
@@ -224,7 +211,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
|
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
|
||||||
got := ScanFile("testdata/publiccontent/.gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
|
got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
|
||||||
if !findingRules(got)["public_content_detector_fingerprint"] {
|
if !findingRules(got)["public_content_detector_fingerprint"] {
|
||||||
t.Fatalf("expected detector fingerprint finding, got %#v", got)
|
t.Fatalf("expected detector fingerprint finding, got %#v", got)
|
||||||
}
|
}
|
||||||
@@ -562,7 +549,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
|
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
|
||||||
got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{
|
got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
|
||||||
"inPrivateKey = true",
|
"inPrivateKey = true",
|
||||||
"inPrivateKey = false",
|
"inPrivateKey = false",
|
||||||
"hasPrivateKey: false",
|
"hasPrivateKey: false",
|
||||||
@@ -645,45 +632,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
|
||||||
`proxy := "http://user:pass@proxy:8080"`,
|
|
||||||
`repo := "https://u:t@h/r.git"`,
|
|
||||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
|
||||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
|
||||||
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_credential_url" {
|
|
||||||
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
|
|
||||||
`proxy: http://user:pass@proxy:8080`,
|
|
||||||
`repo: https://u:t@h/r.git`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_credential_url" {
|
|
||||||
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
|
|
||||||
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
|
|
||||||
`endpoint: http://10.0.0.1:8080`,
|
|
||||||
`redis: 192.168.1.10:6379`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_private_ipv4" {
|
|
||||||
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
@@ -700,7 +648,6 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
|||||||
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
||||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||||
"URL=https://<user>:real-secret@example.invalid/path",
|
"URL=https://<user>:real-secret@example.invalid/path",
|
||||||
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
|
||||||
}, "\n")+"\n"))
|
}, "\n")+"\n"))
|
||||||
var count int
|
var count int
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
@@ -714,8 +661,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count != 4 {
|
if count != 3 {
|
||||||
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,70 +724,8 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
|
|
||||||
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
|
|
||||||
`{"token":"img_abc123"}`,
|
|
||||||
`{"token":"img_live_secret"}`,
|
|
||||||
`{"token":"img_prod_key"}`,
|
|
||||||
`token=ab********cd`,
|
|
||||||
`{"image_token":"img_live_secret"}`,
|
|
||||||
`{"data_mail_token":"mail_abc123"}`,
|
|
||||||
`{"whiteboard_token":"board_v3_example"}`,
|
|
||||||
`{"want_token":"token from callback"}`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
|
|
||||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
|
||||||
stripeToken := "sk_" + "live_1234567890abcdef"
|
|
||||||
randomToken := strings.Join([]string{
|
|
||||||
"a1b2c3d4",
|
|
||||||
"e5f6g7h8",
|
|
||||||
"i9j0k1l2",
|
|
||||||
"m3n4p5q6",
|
|
||||||
}, "")
|
|
||||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
|
||||||
`{"token":"` + githubToken + `"}`,
|
|
||||||
`token=` + stripeToken,
|
|
||||||
`{"image_token":"` + githubToken + `"}`,
|
|
||||||
`{"token":"` + randomToken + `"}`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
var count int
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if count != 4 {
|
|
||||||
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
|
||||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
|
||||||
`{"access_token":"img_abc123"}`,
|
|
||||||
`{"api_token":"img_live_secret"}`,
|
|
||||||
`{"service_token":"ab********cd"}`,
|
|
||||||
`{"bot_token":"board_v3_example"}`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
var count int
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if count != 4 {
|
|
||||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
if item.Rule == "public_content_generic_credential" {
|
if item.Rule == "public_content_generic_credential" {
|
||||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||||
@@ -849,7 +734,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
if item.Rule == "public_content_generic_credential" {
|
if item.Rule == "public_content_generic_credential" {
|
||||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||||
@@ -858,7 +743,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
|
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
|
||||||
got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{
|
got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
|
||||||
"AppSecret: stored,",
|
"AppSecret: stored,",
|
||||||
"AccessToken: result.Token.AccessToken,",
|
"AccessToken: result.Token.AccessToken,",
|
||||||
`token := runtime.Str("token")`,
|
`token := runtime.Str("token")`,
|
||||||
@@ -871,7 +756,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||||
got := ScanFile("fixtures/iconpark_tool.py", []byte(strings.Join([]string{
|
got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
|
||||||
"def normalize_token(value: str) -> str:",
|
"def normalize_token(value: str) -> str:",
|
||||||
" token = rest[index]",
|
" token = rest[index]",
|
||||||
" next_token = rest[index + 1] if index + 1 < len(rest) else None",
|
" next_token = rest[index + 1] if index + 1 < len(rest) else None",
|
||||||
@@ -885,174 +770,8 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
|
|
||||||
"class Counter:",
|
|
||||||
" def __init__(self) -> None:",
|
|
||||||
" self._token_kind: TokenKind | None = None",
|
|
||||||
" self.access_token: AccessToken | None = None",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
|
|
||||||
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
|
|
||||||
`return fmt.Errorf("failed to remove token: %v", err)`,
|
|
||||||
`const LarkErrTokenMissing = "token_missing"`,
|
|
||||||
`const LarkErrTokenExpired = 99991677`,
|
|
||||||
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
|
|
||||||
`const LargeAttachmentTokenAttr = "data-mail-token"`,
|
|
||||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
|
||||||
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
|
|
||||||
`tokenTypeHint := "access_token"`,
|
|
||||||
`const TokenTenant Token = "tenant"`,
|
|
||||||
`const secretKeyPrefix = "appsecret:"`,
|
|
||||||
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
|
|
||||||
`return &credential.TokenResult{Token: "test-token"}, nil`,
|
|
||||||
`fmt.Fprintf(w, "password=%s\n", pat)`,
|
|
||||||
`text += "(img_token:" + imgToken + ")"`,
|
|
||||||
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
|
|
||||||
`this.token = token;`,
|
|
||||||
`// AppSecret: "appsecret:<appId>"`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
|
||||||
`app_secret=***`,
|
|
||||||
`{"token":"<wiki_token>"}`,
|
|
||||||
`{"token":"Pgrrwvr***********UnRb"}`,
|
|
||||||
`"scope_name": "auth:user_access_token:read"`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
|
||||||
"client_secret=realprefix***realsuffix",
|
|
||||||
"client_secret=ab********cd",
|
|
||||||
"access_token=ab********cd",
|
|
||||||
"refresh_token=realprefix********realsuffix",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
var count int
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if count != 4 {
|
|
||||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
|
|
||||||
"LARKSUITE_CLI_APP_SECRET=dry-run",
|
|
||||||
"client_secret: dry_run",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
file string
|
|
||||||
text string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "typescript simple secret",
|
|
||||||
file: "fixtures/source_secret.ts",
|
|
||||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "typescript numeric password",
|
|
||||||
file: "fixtures/source_secret.ts",
|
|
||||||
text: `const password: string = "12345678901234567890"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "typescript union secret",
|
|
||||||
file: "fixtures/source_secret.ts",
|
|
||||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "python simple secret",
|
|
||||||
file: "fixtures/source_secret.py",
|
|
||||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "python union secret",
|
|
||||||
file: "fixtures/source_secret.py",
|
|
||||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "python optional secret",
|
|
||||||
file: "fixtures/source_secret.py",
|
|
||||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := ScanFile(tc.file, []byte(tc.text+"\n"))
|
|
||||||
if !findingRules(got)["public_content_generic_credential"] {
|
|
||||||
t.Fatalf("typed credential assignment should be reported: %#v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
|
||||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
|
||||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
|
||||||
`const ClientSecret = "real-client-secret-value"`,
|
|
||||||
`const GithubToken = "` + githubToken + `"`,
|
|
||||||
`const Password = "12345678901234567890"`,
|
|
||||||
`const ClientSecretNumber = "12345678901234567890"`,
|
|
||||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
|
||||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
var count int
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if count != 6 {
|
|
||||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
|
||||||
"client_secret=%s",
|
|
||||||
"access_token=%v",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||||
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
|
got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||||
`<img token="..." url="https://..." width="..." height="..."/>`,
|
`<img token="..." url="https://..." width="..." height="..."/>`,
|
||||||
`<sheet token="..." sheet-id="...">`,
|
`<sheet token="..." sheet-id="...">`,
|
||||||
}, "\n")+"\n"))
|
}, "\n")+"\n"))
|
||||||
@@ -1064,7 +783,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
|
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
|
||||||
got := ScanFile("fixtures/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
|
got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
if item.Rule == "public_content_jwt_like_token" {
|
if item.Rule == "public_content_jwt_like_token" {
|
||||||
t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
|
t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
|
||||||
@@ -1072,38 +791,8 @@ func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsMarkdownDottedAPIIdentifiers(t *testing.T) {
|
|
||||||
got := ScanFile("fixtures/mail_api_table.md", []byte(strings.Join([]string{
|
|
||||||
"| Method | Permission |",
|
|
||||||
"| --- | --- |",
|
|
||||||
"| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |",
|
|
||||||
"| `user_mailbox.allow_sender.batch_create` | `mail:user_mailbox.message:modify` |",
|
|
||||||
"| `user_mailbox.allow_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
|
|
||||||
"| `user_mailbox.blocked_sender.batch_create` | `mail:user_mailbox.message:modify` |",
|
|
||||||
"| `user_mailbox.blocked_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_jwt_like_token" {
|
|
||||||
t.Fatalf("markdown dotted API identifier should not be jwt finding: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsNonJWTDottedTaxonomy(t *testing.T) {
|
|
||||||
got := ScanFile("docs/api.md", []byte(strings.Join([]string{
|
|
||||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
||||||
"corehr:employment.international_assignment.custom_field.apaas_id__c:read",
|
|
||||||
"user_mailbox.sent_messages.get_recall_detail queries recall detail.",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_jwt_like_token" {
|
|
||||||
t.Fatalf("non-JWT dotted taxonomy should not be jwt finding: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
|
||||||
`{"client_token":"1704067200"}`,
|
`{"client_token":"1704067200"}`,
|
||||||
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
|
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
|
||||||
}, "\n")+"\n"))
|
}, "\n")+"\n"))
|
||||||
@@ -1116,7 +805,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
|||||||
|
|
||||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
|
||||||
`{"client_token":"` + stripeLike + `"}`,
|
`{"client_token":"` + stripeLike + `"}`,
|
||||||
`{"client_token":"real-client-secret-value"}`,
|
`{"client_token":"real-client-secret-value"}`,
|
||||||
}, "\n")+"\n"))
|
}, "\n")+"\n"))
|
||||||
@@ -1132,7 +821,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||||
`{ "block_token": "boardXXXX" }`,
|
`{ "block_token": "boardXXXX" }`,
|
||||||
`{ "resource_token": "doc_token_or_url" }`,
|
`{ "resource_token": "doc_token_or_url" }`,
|
||||||
`{ "token": "canonical_token" }`,
|
`{ "token": "canonical_token" }`,
|
||||||
@@ -1152,7 +841,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
|||||||
|
|
||||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||||
`{ "resource_token": "` + stripeLike + `" }`,
|
`{ "resource_token": "` + stripeLike + `" }`,
|
||||||
`{ "block_token": "real-client-secret-value" }`,
|
`{ "block_token": "real-client-secret-value" }`,
|
||||||
}, "\n")+"\n"))
|
}, "\n")+"\n"))
|
||||||
@@ -1167,12 +856,10 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||||
for _, item := range got {
|
if !findingRules(got)["public_content_generic_credential"] {
|
||||||
if item.Rule == "public_content_generic_credential" {
|
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||||
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1271,19 +958,6 @@ func TestScanFileDetectsJSONBearerHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsBearerHeaderPlaceholders(t *testing.T) {
|
|
||||||
got := ScanFile("docs/auth.md", []byte(strings.Join([]string{
|
|
||||||
"Authorization: Bearer YOUR_ACCESS_TOKEN",
|
|
||||||
`{"Authorization":"Bearer ACCESS_TOKEN_HERE"}`,
|
|
||||||
"Authorization: Bearer <access-token>",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_bearer_header" {
|
|
||||||
t.Fatalf("bearer placeholder should not be bearer finding: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
|
func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
|
||||||
token := "abcdefghijklmnopqrstuvwxyz"
|
token := "abcdefghijklmnopqrstuvwxyz"
|
||||||
text := "private launch plan for internal rollout on Friday\n" +
|
text := "private launch plan for internal rollout on Friday\n" +
|
||||||
@@ -1301,22 +975,6 @@ func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSemanticCandidateKeepsNonJWTDottedTaxonomy(t *testing.T) {
|
|
||||||
text := "private launch plan for internal rollout on Friday\n" +
|
|
||||||
"Supported MIME type: application/vnd.openxmlformats-officedocument.presentationml.presentation\n"
|
|
||||||
|
|
||||||
got := semanticCandidate("docs/public.md", "file", text, 1)
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got[0].Excerpt, "<jwt-like-token>") {
|
|
||||||
t.Fatalf("semantic candidate should not redact non-JWT dotted taxonomy: %#v", got[0])
|
|
||||||
}
|
|
||||||
if !strings.Contains(got[0].Excerpt, "application/vnd.openxmlformats-officedocument.presentationml.presentation") {
|
|
||||||
t.Fatalf("semantic candidate should keep non-JWT dotted taxonomy, got %#v", got[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
|
func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
|
||||||
text := strings.Join([]string{
|
text := strings.Join([]string{
|
||||||
"Generated with automated code assistant",
|
"Generated with automated code assistant",
|
||||||
@@ -1354,37 +1012,6 @@ func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
|
||||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
|
||||||
"client_secret: YOUR_CLIENT_SECRET",
|
|
||||||
"api_key: YOUR_API_KEY",
|
|
||||||
"password: YOUR_PASSWORD",
|
|
||||||
"access_token: ACCESS_TOKEN_HERE",
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
t.Fatalf("conventional credential placeholder should not be credential finding: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
|
||||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
|
||||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
|
||||||
"client_secret: " + stripeLike + "_HERE",
|
|
||||||
"api_key: YOUR_" + stripeLike,
|
|
||||||
}, "\n")+"\n"))
|
|
||||||
var count int
|
|
||||||
for _, item := range got {
|
|
||||||
if item.Rule == "public_content_generic_credential" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if count != 2 {
|
|
||||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ package registry
|
|||||||
import "github.com/larksuite/cli/internal/apicatalog"
|
import "github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
|
||||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||||
// metadata — deterministic across machines, for golden tests and schema lint.
|
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||||
|
// and schema lint.
|
||||||
func EmbeddedCatalog() apicatalog.Catalog {
|
func EmbeddedCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||||
}
|
}
|
||||||
@@ -17,14 +18,3 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
|||||||
func RuntimeCatalog() apicatalog.Catalog {
|
func RuntimeCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||||
}
|
}
|
||||||
|
|
||||||
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
|
||||||
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
|
||||||
// embed only the empty meta_data_default.json stub, so the embedded view has
|
|
||||||
// nothing to resolve; the merged view is the only data such binaries have.
|
|
||||||
func SchemaCatalog() apicatalog.Catalog {
|
|
||||||
if len(EmbeddedServicesTyped()) > 0 {
|
|
||||||
return EmbeddedCatalog()
|
|
||||||
}
|
|
||||||
return RuntimeCatalog()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package registry
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/apicatalog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
|
||||||
// restores them (with a full state reset) on cleanup.
|
|
||||||
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
|
||||||
t.Helper()
|
|
||||||
resetInit()
|
|
||||||
orig := embeddedMetaJSON
|
|
||||||
embeddedMetaJSON = data
|
|
||||||
t.Cleanup(func() {
|
|
||||||
waitBackgroundRefresh()
|
|
||||||
embeddedMetaJSON = orig
|
|
||||||
resetInit()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
|
||||||
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
|
||||||
|
|
||||||
c := SchemaCatalog()
|
|
||||||
|
|
||||||
if c.Source() != apicatalog.SourceEmbedded {
|
|
||||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
|
||||||
}
|
|
||||||
if _, ok := c.Service("embedded_svc"); !ok {
|
|
||||||
t.Fatal("expected embedded_svc from embedded metadata")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
|
||||||
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
|
||||||
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
|
||||||
// Init seeds via sync fetch.
|
|
||||||
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
|
||||||
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(200)
|
|
||||||
w.Write(testEnvelopeJSON("remote_svc"))
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
testMetaURL = ts.URL
|
|
||||||
|
|
||||||
c := SchemaCatalog()
|
|
||||||
|
|
||||||
if c.Source() != apicatalog.SourceRuntime {
|
|
||||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
|
||||||
}
|
|
||||||
if _, ok := c.Service("remote_svc"); !ok {
|
|
||||||
t.Fatal("expected remote_svc from runtime fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/larksuite/cli/internal/update"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
@@ -86,9 +85,7 @@ func InitWithBrand(brand core.LarkBrand) {
|
|||||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
// After a CLI upgrade the embedded data can be fresher than an old
|
if cached, err := loadCachedMerged(); err == nil {
|
||||||
// cache; an equal/older cache must not shadow it.
|
|
||||||
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
|
||||||
overlayMergedServices(cached)
|
overlayMergedServices(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package registry
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/meta"
|
|
||||||
)
|
|
||||||
|
|
||||||
// seedCache writes a cache file + cache meta for one service whose Title is
|
|
||||||
// marker, tagged with the given top-level data version and brand.
|
|
||||||
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
|
||||||
t.Helper()
|
|
||||||
cDir := filepath.Join(dir, "cache")
|
|
||||||
if err := os.MkdirAll(cDir, 0700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
reg := MergedRegistry{
|
|
||||||
Version: version,
|
|
||||||
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(reg)
|
|
||||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
|
||||||
mData, _ := json.Marshal(cm)
|
|
||||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
|
||||||
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
|
||||||
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
|
||||||
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
|
||||||
t.Helper()
|
|
||||||
embedded, _ := json.Marshal(MergedRegistry{
|
|
||||||
Version: embeddedVer,
|
|
||||||
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
|
||||||
})
|
|
||||||
swapEmbeddedMeta(t, embedded)
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
|
||||||
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
|
||||||
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
|
||||||
InitWithBrand(core.BrandFeishu)
|
|
||||||
}
|
|
||||||
|
|
||||||
func titleOf(t *testing.T, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
svc, ok := ServiceTyped(name)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("service %q not loaded", name)
|
|
||||||
}
|
|
||||||
return svc.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
|
||||||
initWithCache(t, "1.0.0", "1.0.0")
|
|
||||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
|
||||||
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
|
||||||
initWithCache(t, "2.0.0", "1.0.0")
|
|
||||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
|
||||||
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
|
||||||
initWithCache(t, "1.0.0", "2.0.0")
|
|
||||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
|
||||||
t.Errorf("newer cache: got %q, want CACHE", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
|
||||||
initWithCache(t, "1.0.0", "not-a-semver")
|
|
||||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
|
||||||
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
|
||||||
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
|
||||||
// plugin builds without compiled meta_data.json still get remote data.
|
|
||||||
initWithCache(t, "0.0.0", "1.0.0")
|
|
||||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
|
||||||
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -72,11 +72,9 @@ func hasEmbeddedServices() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// testRegistry returns a minimal MergedRegistry with one service.
|
||||||
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
|
||||||
// so cache overlay passes the version gate in InitWithBrand.
|
|
||||||
func testRegistry(name string) MergedRegistry {
|
func testRegistry(name string) MergedRegistry {
|
||||||
return MergedRegistry{
|
return MergedRegistry{
|
||||||
Version: "1.0.0",
|
Version: "test-1.0",
|
||||||
Services: []meta.Service{
|
Services: []meta.Service{
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -162,7 +160,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
resetInit()
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -199,7 +197,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
resetInit()
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -373,8 +371,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
if reg.Version != "1.0.0" {
|
if reg.Version != "test-1.0" {
|
||||||
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,9 +59,13 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
|||||||
if appID == "" || scope == "" {
|
if appID == "" || scope == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
host := "open.feishu.cn"
|
||||||
|
if brand == core.BrandLark {
|
||||||
|
host = "open.larksuite.com"
|
||||||
|
}
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||||
core.ResolveOpenBaseURL(brand),
|
host,
|
||||||
url.QueryEscape(appID),
|
url.QueryEscape(appID),
|
||||||
url.QueryEscape(scope),
|
url.QueryEscape(scope),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,8 @@
|
|||||||
package schema
|
package schema
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/affordance"
|
|
||||||
"github.com/larksuite/cli/internal/apicatalog"
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
@@ -25,7 +22,7 @@ func Convert(f meta.Field) Property {
|
|||||||
if f.Type == "file" {
|
if f.Type == "file" {
|
||||||
p.Format = "binary"
|
p.Format = "binary"
|
||||||
}
|
}
|
||||||
p.Description = normalizeDesc(f.Description)
|
p.Description = f.Description
|
||||||
p.Default = f.CoercedDefault()
|
p.Default = f.CoercedDefault()
|
||||||
p.Example = f.CoercedExample()
|
p.Example = f.CoercedExample()
|
||||||
p.Minimum = f.MinBound()
|
p.Minimum = f.MinBound()
|
||||||
@@ -55,24 +52,6 @@ func Convert(f meta.Field) Property {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
sepRunRe = regexp.MustCompile(`[;;]{2,}`)
|
|
||||||
spaceRunRe = regexp.MustCompile(`[ \t]{2,}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// normalizeDesc de-crufts a meta_data description for the envelope — strips
|
|
||||||
// markdown emphasis and collapses doubled separators/spaces — but keeps content
|
|
||||||
// (links, newlines, sentences); the compact flag-help has its own stricter pass.
|
|
||||||
func normalizeDesc(s string) string {
|
|
||||||
if s == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s = strings.ReplaceAll(s, "**", "")
|
|
||||||
s = sepRunRe.ReplaceAllString(s, "; ")
|
|
||||||
s = spaceRunRe.ReplaceAllString(s, " ")
|
|
||||||
return strings.TrimRight(s, " ;;。.,,、\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
|
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
|
||||||
// arrays for the envelope. enumDescriptions is nil unless at least one value
|
// arrays for the envelope. enumDescriptions is nil unless at least one value
|
||||||
// carries a description (so the bare-enum form stays values-only), keeping the
|
// carries a description (so the bare-enum form stays values-only), keeping the
|
||||||
@@ -107,18 +86,6 @@ func propsOf(fields []meta.Field) *OrderedProps {
|
|||||||
return op
|
return op
|
||||||
}
|
}
|
||||||
|
|
||||||
// paramPropsOf is propsOf for the params section: each property also carries
|
|
||||||
// its CLI flag (--kebab-name).
|
|
||||||
func paramPropsOf(fields []meta.Field) *OrderedProps {
|
|
||||||
op := &OrderedProps{}
|
|
||||||
for _, f := range fields {
|
|
||||||
p := Convert(f)
|
|
||||||
p.Flag = "--" + f.FlagName()
|
|
||||||
op.Set(f.Name, p)
|
|
||||||
}
|
|
||||||
return op
|
|
||||||
}
|
|
||||||
|
|
||||||
// requiredOf returns the alphabetized names of the required fields.
|
// requiredOf returns the alphabetized names of the required fields.
|
||||||
func requiredOf(fields []meta.Field) []string {
|
func requiredOf(fields []meta.Field) []string {
|
||||||
var required []string
|
var required []string
|
||||||
@@ -141,17 +108,16 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
|||||||
Properties: &OrderedProps{},
|
Properties: &OrderedProps{},
|
||||||
}
|
}
|
||||||
|
|
||||||
addInputObject(is, "params", "", m.Params(), true, "")
|
addInputObject(is, "params", "", m.Params())
|
||||||
addInputObject(is, "data", "", m.Data(), false, "--data")
|
addInputObject(is, "data", "", m.Data())
|
||||||
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files(), false, "--file")
|
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
|
||||||
|
|
||||||
if m.Risk == core.RiskHighRiskWrite {
|
if m.Risk == core.RiskHighRiskWrite {
|
||||||
falseVal := false
|
falseVal := false
|
||||||
is.Properties.Set("yes", Property{
|
is.Properties.Set("yes", Property{
|
||||||
Type: "boolean",
|
Type: "boolean",
|
||||||
Flag: "--yes",
|
|
||||||
Default: falseVal,
|
Default: falseVal,
|
||||||
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.",
|
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,24 +125,20 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
|||||||
return is
|
return is
|
||||||
}
|
}
|
||||||
|
|
||||||
// addInputObject adds one section (params/data/file) when it has fields, marking
|
// addInputObject adds one named sub-object section (params/data/file) to the
|
||||||
// the section required at top level when any field is. asFlags tags each property
|
// input schema when it has fields: its Properties come from the fields, its
|
||||||
// with its --flag (params only); carrier names the section's flag (--data/--file).
|
// Required lists the mandatory keys, and the section itself is required at top
|
||||||
func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) {
|
// level when any field is required. Empty sections are skipped.
|
||||||
|
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
|
||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
props := propsOf(fields)
|
|
||||||
if asFlags {
|
|
||||||
props = paramPropsOf(fields)
|
|
||||||
}
|
|
||||||
req := requiredOf(fields)
|
req := requiredOf(fields)
|
||||||
is.Properties.Set(name, Property{
|
is.Properties.Set(name, Property{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Description: description,
|
Description: description,
|
||||||
Carrier: carrier,
|
|
||||||
Required: req,
|
Required: req,
|
||||||
Properties: props,
|
Properties: propsOf(fields),
|
||||||
})
|
})
|
||||||
if len(req) > 0 {
|
if len(req) > 0 {
|
||||||
is.Required = append(is.Required, name)
|
is.Required = append(is.Required, name)
|
||||||
@@ -217,13 +179,7 @@ func buildMeta(m meta.Method) *Meta {
|
|||||||
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
|
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
|
||||||
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
|
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
|
||||||
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
|
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
|
||||||
m := ref.Method
|
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
|
||||||
// The affordance overlay lives in the CLI, not the metadata; look it up
|
|
||||||
// lazily here (it takes precedence over any affordance the metadata carries).
|
|
||||||
if raw, ok := affordance.For(ref.Service.Name, m.ID); ok {
|
|
||||||
m.Affordance = raw
|
|
||||||
}
|
|
||||||
return assemble(ref.Service.Name, ref.ResourcePath, m)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envelopes renders the given method refs into envelopes, sorted by name. The
|
// Envelopes renders the given method refs into envelopes, sorted by name. The
|
||||||
@@ -249,7 +205,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
|
|||||||
|
|
||||||
return Envelope{
|
return Envelope{
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: normalizeDesc(m.Description),
|
Description: m.Description,
|
||||||
InputSchema: buildInputSchema(m),
|
InputSchema: buildInputSchema(m),
|
||||||
OutputSchema: buildOutputSchema(m),
|
OutputSchema: buildOutputSchema(m),
|
||||||
Meta: buildMeta(m),
|
Meta: buildMeta(m),
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/affordance"
|
|
||||||
"github.com/larksuite/cli/internal/apicatalog"
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
@@ -506,31 +504,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by
|
|
||||||
// service + method id), so a method whose metadata carries none still gets
|
|
||||||
// guidance in its envelope when an overlay entry exists.
|
|
||||||
func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) {
|
|
||||||
// The overlay source is the top-level affordance/ tree, injected at startup;
|
|
||||||
// inject a fixture so this unit test does not depend on the shipped content.
|
|
||||||
// Reset afterwards (this binary installs no source by default) for isolation.
|
|
||||||
t.Cleanup(func() { affordance.SetSource(nil) })
|
|
||||||
affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(
|
|
||||||
"# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}})
|
|
||||||
env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"})
|
|
||||||
if env.Meta == nil || env.Meta.Affordance == nil {
|
|
||||||
t.Fatal("expected affordance from the approval overlay, got none")
|
|
||||||
}
|
|
||||||
if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 {
|
|
||||||
t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A method id with no overlay entry carries no affordance.
|
|
||||||
bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"})
|
|
||||||
if bare.Meta != nil && bare.Meta.Affordance != nil {
|
|
||||||
t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||||
method := map[string]interface{}{
|
method := map[string]interface{}{
|
||||||
"scopes": []interface{}{"x"},
|
"scopes": []interface{}{"x"},
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Envelope is the MCP Tool spec contract for a single API method command.
|
// Envelope is the MCP Tool spec contract for a single API method command.
|
||||||
//
|
|
||||||
// The REST route (httpMethod/path) is deliberately NOT exposed: every
|
|
||||||
// schema-resolvable method already has a typed command, so the raw path would
|
|
||||||
// only tempt an agent toward the `api` escape hatch.
|
|
||||||
type Envelope struct {
|
type Envelope struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -48,15 +44,9 @@ type OutputSchema struct {
|
|||||||
// "params" / "data" sub-objects inside inputSchema): it lists which keys
|
// "params" / "data" sub-objects inside inputSchema): it lists which keys
|
||||||
// inside that object's Properties are mandatory. Leaf fields ignore it.
|
// inside that object's Properties are mandatory. Leaf fields ignore it.
|
||||||
type Property struct {
|
type Property struct {
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
// Flag is the typed CLI flag a params property maps to (e.g. "--folder-id");
|
Enum []interface{} `json:"enum,omitempty"`
|
||||||
// absent on body/file fields, which travel via the section's Carrier.
|
|
||||||
Flag string `json:"flag,omitempty"`
|
|
||||||
// Carrier names the flag a whole inputSchema section travels on ("--data" /
|
|
||||||
// "--file"); empty on the params section, whose properties carry their Flag.
|
|
||||||
Carrier string `json:"carrier,omitempty"`
|
|
||||||
Enum []interface{} `json:"enum,omitempty"`
|
|
||||||
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
|
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
|
||||||
// each allowed value, in the same order. Omitted when no value carries a
|
// each allowed value, in the same order. Omitted when no value carries a
|
||||||
// description. This is the widely-recognized JSON-Schema extension (VS Code,
|
// description. This is the widely-recognized JSON-Schema extension (VS Code,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ type InstallMethod int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
InstallNpm InstallMethod = iota
|
InstallNpm InstallMethod = iota
|
||||||
InstallPnpm
|
|
||||||
InstallManual
|
InstallManual
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,32 +53,22 @@ var (
|
|||||||
|
|
||||||
// DetectResult holds installation detection results.
|
// DetectResult holds installation detection results.
|
||||||
type DetectResult struct {
|
type DetectResult struct {
|
||||||
Method InstallMethod
|
Method InstallMethod
|
||||||
ResolvedPath string
|
ResolvedPath string
|
||||||
NpmAvailable bool
|
NpmAvailable bool
|
||||||
PnpmAvailable bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||||
func (d DetectResult) CanAutoUpdate() bool {
|
func (d DetectResult) CanAutoUpdate() bool {
|
||||||
switch d.Method {
|
return d.Method == InstallNpm && d.NpmAvailable
|
||||||
case InstallNpm:
|
|
||||||
return d.NpmAvailable
|
|
||||||
case InstallPnpm:
|
|
||||||
return d.PnpmAvailable
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||||
func (d DetectResult) ManualReason() string {
|
func (d DetectResult) ManualReason() string {
|
||||||
switch {
|
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||||
case d.Method == InstallNpm && !d.NpmAvailable:
|
|
||||||
return "installed via npm, but npm is not available in PATH"
|
return "installed via npm, but npm is not available in PATH"
|
||||||
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
|
||||||
return "installed via pnpm, but pnpm is not available in PATH"
|
|
||||||
}
|
}
|
||||||
return "not installed via npm or pnpm"
|
return "not installed via npm"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmResult holds the result of an npm install or skills update execution.
|
// NpmResult holds the result of an npm install or skills update execution.
|
||||||
@@ -103,7 +92,6 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
PnpmInstallOverride func(version string) *NpmResult
|
|
||||||
SkillsIndexFetchOverride func() *NpmResult
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
@@ -113,38 +101,17 @@ type Updater struct {
|
|||||||
// running binary is successfully renamed to .old. Used by
|
// running binary is successfully renamed to .old. Used by
|
||||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||||
backupCreated bool
|
backupCreated bool
|
||||||
|
|
||||||
// detectCache memoizes the first real DetectInstallMethod result. How this
|
|
||||||
// binary was installed cannot change during a single process, so caching is
|
|
||||||
// the correct semantics — and it is required for correctness: the update
|
|
||||||
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
|
||||||
// skills, so a re-detection at skills time could resolve a now-stale
|
|
||||||
// os.Executable path and misclassify. Seeded pre-update by the first call
|
|
||||||
// (updateRun), it keeps the post-update skills launcher consistent with the
|
|
||||||
// launcher reported to the user. Not goroutine-safe; the update flow is
|
|
||||||
// sequential.
|
|
||||||
detectCache *DetectResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Updater with default (real) behavior.
|
// New creates an Updater with default (real) behavior.
|
||||||
func New() *Updater { return &Updater{} }
|
func New() *Updater { return &Updater{} }
|
||||||
|
|
||||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
// DetectInstallMethod determines how the CLI was installed and whether
|
||||||
// owning package manager is available for auto-update.
|
// npm is available for auto-update.
|
||||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||||
if u.DetectOverride != nil {
|
if u.DetectOverride != nil {
|
||||||
return u.DetectOverride()
|
return u.DetectOverride()
|
||||||
}
|
}
|
||||||
if u.detectCache != nil {
|
|
||||||
return *u.detectCache
|
|
||||||
}
|
|
||||||
result := u.detectInstallMethod()
|
|
||||||
u.detectCache = &result
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectInstallMethod performs the real (uncached) detection.
|
|
||||||
func (u *Updater) detectInstallMethod() DetectResult {
|
|
||||||
exe, err := vfs.Executable()
|
exe, err := vfs.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual}
|
return DetectResult{Method: InstallManual}
|
||||||
@@ -153,54 +120,24 @@ func (u *Updater) detectInstallMethod() DetectResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||||
}
|
}
|
||||||
_, npmErr := exec.LookPath("npm")
|
|
||||||
_, pnpmErr := exec.LookPath("pnpm")
|
|
||||||
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectFromResolved classifies the resolved binary path into an install
|
|
||||||
// method and records package-manager availability. Split out from
|
|
||||||
// DetectInstallMethod so the classification is unit-testable without touching
|
|
||||||
// the filesystem or PATH.
|
|
||||||
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
|
||||||
method := InstallManual
|
method := InstallManual
|
||||||
if strings.Contains(resolved, "node_modules") {
|
if strings.Contains(resolved, "node_modules") {
|
||||||
if containsPnpmMarker(resolved) {
|
method = InstallNpm
|
||||||
method = InstallPnpm
|
|
||||||
} else {
|
|
||||||
method = InstallNpm
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
d := DetectResult{Method: method, ResolvedPath: resolved}
|
|
||||||
switch method {
|
|
||||||
case InstallNpm:
|
|
||||||
d.NpmAvailable = npmOnPath
|
|
||||||
case InstallPnpm:
|
|
||||||
d.PnpmAvailable = pnpmOnPath
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
npmAvailable := false
|
||||||
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
if method == InstallNpm {
|
||||||
// (a ".pnpm" directory segment) and the global content-addressable store,
|
if _, err := exec.LookPath("npm"); err == nil {
|
||||||
// whose resolved path runs through pnpm's home directory (e.g.
|
npmAvailable = true
|
||||||
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
|
||||||
// followed by "store". Matching only these two shapes (rather than any bare
|
|
||||||
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
|
||||||
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
|
||||||
// classification is OS-independent and unit-testable anywhere.
|
|
||||||
func containsPnpmMarker(p string) bool {
|
|
||||||
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
|
||||||
for i, part := range parts {
|
|
||||||
if part == ".pnpm" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
|
return DetectResult{
|
||||||
|
Method: method,
|
||||||
|
ResolvedPath: resolved,
|
||||||
|
NpmAvailable: npmAvailable,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||||
@@ -226,29 +163,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
|
||||||
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
|
||||||
if u.PnpmInstallOverride != nil {
|
|
||||||
return u.PnpmInstallOverride(version)
|
|
||||||
}
|
|
||||||
r := &NpmResult{}
|
|
||||||
pnpmPath, err := exec.LookPath("pnpm")
|
|
||||||
if err != nil {
|
|
||||||
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
|
||||||
defer cancel()
|
|
||||||
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
|
||||||
cmd.Stdout = &r.Stdout
|
|
||||||
cmd.Stderr = &r.Stderr
|
|
||||||
r.Err = cmd.Run()
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
if u.SkillsIndexFetchOverride != nil {
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
return u.SkillsIndexFetchOverride()
|
return u.SkillsIndexFetchOverride()
|
||||||
@@ -347,40 +261,19 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
|||||||
return u.runSkillsCommand(args...)
|
return u.runSkillsCommand(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
|
||||||
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
|
||||||
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
|
||||||
// putting npm/npx on PATH) can still sync skills after a self-update.
|
|
||||||
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
|
||||||
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
|
||||||
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
|
||||||
// launcher selection is unit-testable on any platform.
|
|
||||||
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
|
||||||
if method == InstallPnpm && pnpmAvailable {
|
|
||||||
r := args
|
|
||||||
if len(r) > 0 && r[0] == "-y" {
|
|
||||||
r = r[1:]
|
|
||||||
}
|
|
||||||
return "pnpm", append([]string{"dlx"}, r...)
|
|
||||||
}
|
|
||||||
return "npx", args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||||
if u.SkillsCommandOverride != nil {
|
if u.SkillsCommandOverride != nil {
|
||||||
return u.SkillsCommandOverride(args...)
|
return u.SkillsCommandOverride(args...)
|
||||||
}
|
}
|
||||||
r := &NpmResult{}
|
r := &NpmResult{}
|
||||||
det := u.DetectInstallMethod()
|
npxPath, err := exec.LookPath("npx")
|
||||||
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
|
||||||
binPath, err := exec.LookPath(launcher)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||||
cmd.Stdout = &r.Stdout
|
cmd.Stdout = &r.Stdout
|
||||||
cmd.Stderr = &r.Stderr
|
cmd.Stderr = &r.Stderr
|
||||||
r.Err = cmd.Run()
|
r.Err = cmd.Run()
|
||||||
|
|||||||
@@ -371,147 +371,3 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContainsPnpmMarker(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
path string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
// Classic virtual-store layout (.pnpm segment).
|
|
||||||
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
|
||||||
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
|
||||||
// Global content-addressable store layout (pnpm 11): resolved path runs
|
|
||||||
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
|
||||||
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
|
||||||
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
|
||||||
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
|
||||||
// npm and non-package installs — no pnpm/.pnpm segment.
|
|
||||||
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
|
||||||
{"/usr/local/bin/lark-cli", false},
|
|
||||||
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
|
||||||
// "pnpm" immediately followed by "store".
|
|
||||||
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
|
||||||
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
|
||||||
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
|
||||||
// living under a dir named pnpm) must not be misclassified as pnpm.
|
|
||||||
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
if got := containsPnpmMarker(c.path); got != c.want {
|
|
||||||
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
|
||||||
u := &Updater{DetectOverride: nil}
|
|
||||||
u.DetectOverride = func() DetectResult {
|
|
||||||
// Exercise the real classification by feeding a resolved path via a small shim.
|
|
||||||
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
|
||||||
}
|
|
||||||
got := u.DetectInstallMethod()
|
|
||||||
if got.Method != InstallPnpm {
|
|
||||||
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
|
||||||
}
|
|
||||||
if !got.PnpmAvailable {
|
|
||||||
t.Errorf("PnpmAvailable = false, want true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
|
||||||
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
|
||||||
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
|
||||||
}
|
|
||||||
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
|
||||||
t.Errorf("manual path Method = %v, want InstallManual", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
|
||||||
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
|
||||||
t.Error("pnpm available should CanAutoUpdate")
|
|
||||||
}
|
|
||||||
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
|
||||||
t.Error("pnpm unavailable should not CanAutoUpdate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManualReason_Pnpm(t *testing.T) {
|
|
||||||
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
|
||||||
t.Errorf("pnpm reason = %q", got)
|
|
||||||
}
|
|
||||||
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
|
||||||
t.Errorf("manual reason = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunPnpmInstall_Override(t *testing.T) {
|
|
||||||
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
|
||||||
r := &NpmResult{}
|
|
||||||
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
|
||||||
return r
|
|
||||||
}}
|
|
||||||
got := u.RunPnpmInstall("2.0.0")
|
|
||||||
if got.Err != nil {
|
|
||||||
t.Fatalf("unexpected err: %v", got.Err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
|
||||||
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunPnpmInstall_Error(t *testing.T) {
|
|
||||||
wantErr := errors.New("boom")
|
|
||||||
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
|
||||||
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
|
||||||
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkillsInvocation(t *testing.T) {
|
|
||||||
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
method InstallMethod
|
|
||||||
pnpmAvailable bool
|
|
||||||
args []string
|
|
||||||
wantLauncher string
|
|
||||||
wantRest []string
|
|
||||||
}{
|
|
||||||
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
|
||||||
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
|
||||||
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
|
||||||
"npx", addArgs},
|
|
||||||
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
|
||||||
"npx", addArgs},
|
|
||||||
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
|
||||||
"npx", []string{"-y", "skills", "ls", "-g"}},
|
|
||||||
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
|
||||||
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
|
||||||
if gotLauncher != c.wantLauncher {
|
|
||||||
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
|
||||||
}
|
|
||||||
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
|
||||||
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
|
||||||
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
|
||||||
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
|
||||||
// stays consistent with what was detected — and reported — before the update.
|
|
||||||
func TestDetectInstallMethod_Caches(t *testing.T) {
|
|
||||||
u := New()
|
|
||||||
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
|
||||||
u.detectCache = &cached
|
|
||||||
got := u.DetectInstallMethod()
|
|
||||||
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
|
||||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,14 +16,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
||||||
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
||||||
|
|
||||||
// EnvNoProxyWarn suppresses the proxy-detected warning when set to any
|
|
||||||
// non-empty value, while leaving proxy behavior unchanged. Unlike
|
|
||||||
// EnvNoProxy (which both silences the warning AND disables the proxy), this
|
|
||||||
// keeps proxy egress active. It exists so agents consuming --format json can
|
|
||||||
// keep using the proxy without the human-oriented warning line landing in
|
|
||||||
// the output stream and breaking JSON parsing.
|
|
||||||
EnvNoProxyWarn = "LARK_CLI_NO_PROXY_WARN"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
||||||
@@ -81,11 +73,6 @@ func redactProxyURL(raw string) string {
|
|||||||
// are redacted. Safe to call multiple times; only the first call prints.
|
// are redacted. Safe to call multiple times; only the first call prints.
|
||||||
func WarnIfProxied(w io.Writer) {
|
func WarnIfProxied(w io.Writer) {
|
||||||
proxyWarningOnce.Do(func() {
|
proxyWarningOnce.Do(func() {
|
||||||
// Explicit opt-out: silence the warning without touching proxy behavior.
|
|
||||||
// Checked before the plugin and env-proxy branches so it suppresses both.
|
|
||||||
if os.Getenv(EnvNoProxyWarn) != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||||
// Shared), so its warning and disable instructions take precedence.
|
// Shared), so its warning and disable instructions take precedence.
|
||||||
// Emitting the env-proxy warning here would be misleading: it tells the
|
// Emitting the env-proxy warning here would be misleading: it tells the
|
||||||
@@ -101,7 +88,7 @@ func WarnIfProxied(w io.Writer) {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy, or %s=1 to keep the proxy and silence this warning.\n",
|
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
|
||||||
key, redactProxyURL(val), EnvNoProxy, EnvNoProxyWarn)
|
key, redactProxyURL(val), EnvNoProxy)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,47 +93,6 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWarnIfProxied_SilentWhenWarnOptOut verifies that LARK_CLI_NO_PROXY_WARN
|
|
||||||
// suppresses the warning while the proxy stays configured (unlike
|
|
||||||
// LARK_CLI_NO_PROXY, which also disables the proxy).
|
|
||||||
func TestWarnIfProxied_SilentWhenWarnOptOut(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
resetProxyPluginState()
|
|
||||||
proxyWarningOnce = sync.Once{}
|
|
||||||
|
|
||||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
|
||||||
t.Setenv(EnvNoProxyWarn, "1")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
WarnIfProxied(&buf)
|
|
||||||
|
|
||||||
if buf.Len() != 0 {
|
|
||||||
t.Errorf("expected no warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWarnIfProxied_WarnOptOutSuppressesPluginWarning verifies that
|
|
||||||
// LARK_CLI_NO_PROXY_WARN also suppresses the proxy-plugin warning.
|
|
||||||
func TestWarnIfProxied_WarnOptOutSuppressesPluginWarning(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
unsetProxyPluginEnv(t)
|
|
||||||
proxyWarningOnce = sync.Once{}
|
|
||||||
|
|
||||||
old := proxyPluginStatus
|
|
||||||
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
|
|
||||||
t.Cleanup(func() { proxyPluginStatus = old })
|
|
||||||
|
|
||||||
t.Setenv(EnvNoProxyWarn, "1")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
WarnIfProxied(&buf)
|
|
||||||
|
|
||||||
if buf.Len() != 0 {
|
|
||||||
t.Errorf("expected no plugin warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
|
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
|
||||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
fetchTimeout = 15 * time.Second
|
fetchTimeout = 5 * time.Second
|
||||||
stateFile = "update-state.json"
|
stateFile = "update-state.json"
|
||||||
maxBody = 256 << 10 // 256 KB
|
maxBody = 256 << 10 // 256 KB
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.65",
|
"version": "1.0.58",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
|
|||||||
@@ -5,12 +5,7 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync, execFile } = require("child_process");
|
const { execFileSync, execFile } = require("child_process");
|
||||||
|
const p = require("@clack/prompts");
|
||||||
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
|
|
||||||
// CommonJS script works on all supported Node versions (require() of an ESM
|
|
||||||
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
|
|
||||||
// point below before main() runs.
|
|
||||||
let p;
|
|
||||||
|
|
||||||
const PKG = "@larksuite/cli";
|
const PKG = "@larksuite/cli";
|
||||||
const SKILLS_REPO = "https://open.feishu.cn";
|
const SKILLS_REPO = "https://open.feishu.cn";
|
||||||
@@ -379,12 +374,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
main().catch((err) => {
|
||||||
p = await import("@clack/prompts");
|
p.cancel("Unexpected error: " + (err.message || err));
|
||||||
await main();
|
|
||||||
})().catch((err) => {
|
|
||||||
const msg = "Unexpected error: " + (err.message || err);
|
|
||||||
if (p) p.cancel(msg);
|
|
||||||
else console.error(msg);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ var AppsDBAuditList = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
||||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "filter by target table"},
|
{Name: "table", Desc: "filter by target table"},
|
||||||
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
||||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
||||||
}
|
}
|
||||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
}
|
}
|
||||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||||
if taskID != "" {
|
if taskID != "" {
|
||||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
func() (map[string]interface{}, error) {
|
||||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ var AppsDBExecute = common.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err)
|
||||||
}
|
}
|
||||||
// 仅本地校验非空;不把文件内容写回公开的 --sql flag(避免 SQL 内容进入
|
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||||
// flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。
|
rctx.Cmd.Flags().Set("sql", string(data))
|
||||||
sql = strings.TrimSpace(string(data))
|
sql = strings.TrimSpace(string(data))
|
||||||
}
|
}
|
||||||
if sql == "" {
|
if sql == "" {
|
||||||
@@ -297,29 +297,10 @@ func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。
|
||||||
// 不被写回公开的 --sql flag(避免泄露进 flag dump / 结构化日志)。优先 --sql(内联或 stdin,
|
|
||||||
// 已由输入框架解析到 flag 值);否则现读 --file。Validate 已先行校验可读且非空。
|
|
||||||
func resolveExecuteSQL(rctx *common.RuntimeContext) (string, error) {
|
|
||||||
if strings.TrimSpace(rctx.Str("sql")) != "" {
|
|
||||||
return rctx.Str("sql"), nil
|
|
||||||
}
|
|
||||||
file := strings.TrimSpace(rctx.Str("file"))
|
|
||||||
if file == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(由 resolveExecuteSQL 在用时解析,--file 不入 flag)。
|
|
||||||
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
sql, _ := resolveExecuteSQL(rctx)
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"sql": sql,
|
"sql": rctx.Str("sql"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
func() (map[string]interface{}, error) {
|
||||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||||
},
|
},
|
||||||
@@ -165,7 +165,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
|||||||
if prid == "" {
|
if prid == "" {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||||
}
|
}
|
||||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
func() (map[string]interface{}, error) {
|
||||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ var AppsEnvPull = common.Shortcut{
|
|||||||
|
|
||||||
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
|
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, envPullAPIErrorHint(err, appID))
|
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||||
}
|
}
|
||||||
|
|
||||||
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
||||||
@@ -126,27 +126,6 @@ func envPullVarsBody() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func envPullAPIErrorHint(err error, appID string) string {
|
|
||||||
if isEnvPullDevDBNotInitializedError(err) {
|
|
||||||
appID = strings.TrimSpace(appID)
|
|
||||||
if appID == "" {
|
|
||||||
appID = "<app_id>"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID)
|
|
||||||
}
|
|
||||||
return appIDListHint
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEnvPullDevDBNotInitializedError(err error) bool {
|
|
||||||
p, ok := errs.ProblemOf(err)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
message := strings.ToLower(p.Message)
|
|
||||||
return strings.Contains(message, "multi-environment database is not initialized") ||
|
|
||||||
(strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||||
if strings.TrimSpace(projectPath) == "" {
|
if strings.TrimSpace(projectPath) == "" {
|
||||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||||
|
|||||||
@@ -592,38 +592,6 @@ func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
|
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
||||||
reg.Register(&httpmock.Stub{
|
|
||||||
Method: "POST",
|
|
||||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": -1,
|
|
||||||
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branch:dev",
|
|
||||||
},
|
|
||||||
OnMatch: func(req *http.Request) {
|
|
||||||
assertEnvPullBody(t, req)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
err := runAppsShortcut(t, AppsEnvPull,
|
|
||||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
|
||||||
factory, stdout,
|
|
||||||
)
|
|
||||||
p := requireAppsAPIProblem(t, err)
|
|
||||||
if p.Code != -1 {
|
|
||||||
t.Fatalf("code = %d, want -1", p.Code)
|
|
||||||
}
|
|
||||||
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
|
|
||||||
if !strings.Contains(p.Hint, want) {
|
|
||||||
t.Fatalf("hint missing %q: %q", want, p.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(p.Hint, "apps +list") {
|
|
||||||
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
projectDir := t.TempDir()
|
projectDir := t.TempDir()
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ var AppsFileDownload = common.Shortcut{
|
|||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := requireFilePath(rctx.Str("path"))
|
_, err := requireFilePath(rctx.Str("path"))
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ var AppsFileList = common.Shortcut{
|
|||||||
{Name: "type", Desc: "filter by MIME type"},
|
{Name: "type", Desc: "filter by MIME type"},
|
||||||
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
||||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,14 +136,7 @@ func putFileBytes(ctx context.Context, url string, content []byte, contentType,
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
req.Header.Set("Content-Type", contentType)
|
req.Header.Set("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
// 用 mime.FormatMediaType 规范生成 Content-Disposition(自动按 RFC 2045 处理引号/转义),
|
req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"")
|
||||||
// 不手工拼接 header,杜绝文件名里的特殊字符破坏 header 结构。filename 已先经 sanitizeUploadFileName
|
|
||||||
// 做 encodeURIComponent(控制字符/分隔符均 %XX 化),此处是第二道防线。
|
|
||||||
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": sanitizeUploadFileName(fileName)})
|
|
||||||
if disposition == "" {
|
|
||||||
disposition = "attachment"
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Disposition", disposition)
|
|
||||||
resp, err := newFileTransferClient().Do(req)
|
resp, err := newFileTransferClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// dial/transport 失败是典型可重试场景。
|
// dial/transport 失败是典型可重试场景。
|
||||||
@@ -177,11 +170,6 @@ func sanitizeUploadFileName(name string) string {
|
|||||||
if enc == "" {
|
if enc == "" {
|
||||||
return "download_file"
|
return "download_file"
|
||||||
}
|
}
|
||||||
// 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh)——下载落地可能覆盖本地隐藏文件,
|
|
||||||
// 前置下划线消除隐藏文件语义。
|
|
||||||
if strings.HasPrefix(enc, ".") {
|
|
||||||
enc = "_" + enc
|
|
||||||
}
|
|
||||||
return enc
|
return enc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -144,10 +143,8 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
|||||||
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
|
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
|
||||||
}
|
}
|
||||||
// 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。
|
// 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。
|
||||||
// 断言按解析结果(format-agnostic):mime.FormatMediaType 对无 tspecial 的名不加引号,
|
if putCD != `attachment; filename="logo.png"` {
|
||||||
// 旧的写死字符串 `filename="logo.png"` 不再成立,但 filename 参数仍须等于原名。
|
t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD)
|
||||||
if disp, params, err := mime.ParseMediaType(putCD); err != nil || disp != "attachment" || params["filename"] != "logo.png" {
|
|
||||||
t.Errorf("PUT Content-Disposition = %q, want disposition=attachment filename=logo.png (parse err=%v)", putCD, err)
|
|
||||||
}
|
}
|
||||||
got := stdout.String()
|
got := stdout.String()
|
||||||
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
|
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
|
||||||
|
|||||||
@@ -46,118 +46,13 @@ func redactKeyInfo(info map[string]interface{}) map[string]interface{} {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedScopeAPIMethods is the HTTP method whitelist for --scope-api / request_scope.
|
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case httpInfo.
|
||||||
var allowedScopeAPIMethods = map[string]struct{}{
|
|
||||||
"GET": {}, "POST": {}, "PUT": {}, "PATCH": {}, "DELETE": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateScopeAPIMethod rejects methods outside the whitelist (e.g. TRACE, CONNECT, empty).
|
|
||||||
func validateScopeAPIMethod(method string) error {
|
|
||||||
if _, ok := allowedScopeAPIMethods[method]; !ok {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"http method %q not allowed; use one of GET, POST, PUT, PATCH, DELETE", method)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateScopeAPIPath enforces basic openapi route hygiene as a first line of defense.
|
|
||||||
func validateScopeAPIPath(p string) error {
|
|
||||||
if p == "" || !strings.HasPrefix(p, "/") {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"http path must start with '/', got %q", p)
|
|
||||||
}
|
|
||||||
if strings.Contains(p, "..") {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"http path must not contain '..': %q", p)
|
|
||||||
}
|
|
||||||
if strings.Contains(p, "//") {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"http path must not contain '//': %q", p)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateRequestScopeFields constrains a request_scope object to the documented
|
|
||||||
// schema: only allow_all (bool) and http_infos ([{http_method, http_path}]). This
|
|
||||||
// closes the raw --scope escape hatch from injecting undocumented fields.
|
|
||||||
func validateRequestScopeFields(rs map[string]interface{}) error {
|
|
||||||
for k := range rs {
|
|
||||||
switch k {
|
|
||||||
case "allow_all", "http_infos":
|
|
||||||
default:
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"unknown field %q; only allow_all and http_infos are allowed", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := rs["allow_all"]; ok {
|
|
||||||
if _, isBool := v.(bool); !isBool {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "allow_all must be a boolean")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := rs["http_infos"]; ok {
|
|
||||||
arr, isArr := v.([]interface{})
|
|
||||||
if !isArr {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "http_infos must be an array")
|
|
||||||
}
|
|
||||||
for _, item := range arr {
|
|
||||||
m, isMap := item.(map[string]interface{})
|
|
||||||
if !isMap {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "each http_infos entry must be an object")
|
|
||||||
}
|
|
||||||
for k := range m {
|
|
||||||
switch k {
|
|
||||||
case "http_method", "http_path":
|
|
||||||
default:
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"unknown field %q in http_infos entry; only http_method and http_path are allowed", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
method, _ := m["http_method"].(string)
|
|
||||||
if err := validateScopeAPIMethod(method); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path, _ := m["http_path"].(string)
|
|
||||||
if err := validateScopeAPIPath(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRawScope parses a raw --scope JSON value: it must be an object that
|
|
||||||
// conforms to the request_scope schema (validated by validateRequestScopeFields).
|
|
||||||
func parseRawScope(scopeRaw string) (map[string]interface{}, error) {
|
|
||||||
var rs interface{}
|
|
||||||
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
obj, ok := rs.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope must be a JSON object")
|
|
||||||
}
|
|
||||||
if err := validateRequestScopeFields(obj); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return obj, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case
|
|
||||||
// httpInfo, validating the method against the whitelist and the path format.
|
|
||||||
func parseScopeAPI(s string) (map[string]interface{}, error) {
|
func parseScopeAPI(s string) (map[string]interface{}, error) {
|
||||||
fields := strings.Fields(strings.TrimSpace(s))
|
fields := strings.Fields(strings.TrimSpace(s))
|
||||||
if len(fields) != 2 {
|
if len(fields) != 2 {
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s)
|
||||||
}
|
}
|
||||||
method := strings.ToUpper(fields[0])
|
return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil
|
||||||
if err := validateScopeAPIMethod(method); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
path := fields[1]
|
|
||||||
if err := validateScopeAPIPath(path); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return map[string]interface{}{"http_method": method, "http_path": path}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestScope assembles config.request_scope (snake_case) from the scope flags.
|
// buildRequestScope assembles config.request_scope (snake_case) from the scope flags.
|
||||||
@@ -170,7 +65,11 @@ func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (inte
|
|||||||
if hasFriendly {
|
if hasFriendly {
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope")
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope")
|
||||||
}
|
}
|
||||||
return parseRawScope(scopeRaw)
|
var rs interface{}
|
||||||
|
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rs, nil
|
||||||
}
|
}
|
||||||
if !hasFriendly {
|
if !hasFriendly {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -212,21 +111,18 @@ func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllow
|
|||||||
// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update).
|
// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update).
|
||||||
func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error {
|
func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error {
|
||||||
scopeRaw := strings.TrimSpace(rctx.Str("scope"))
|
scopeRaw := strings.TrimSpace(rctx.Str("scope"))
|
||||||
scopeAPIs := rctx.StrArray("scope-api")
|
if scopeRaw != "" && (rctx.Bool("scope-all") || len(rctx.StrArray("scope-api")) > 0) {
|
||||||
if scopeRaw != "" && (rctx.Bool("scope-all") || len(scopeAPIs) > 0) {
|
|
||||||
return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api").
|
return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api").
|
||||||
WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both")
|
WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both")
|
||||||
}
|
}
|
||||||
if scopeRaw != "" {
|
if scopeRaw != "" && !json.Valid([]byte(scopeRaw)) {
|
||||||
if _, err := parseRawScope(scopeRaw); err != nil {
|
return appsValidationParamError("--scope", "--scope must be valid JSON").
|
||||||
return appsValidationParamError("--scope", "invalid --scope: %s", err).
|
WithHint("--scope takes raw JSON for config.request_scope; or use --scope-all / --scope-api 'METHOD /openapi/path'")
|
||||||
WithHint("--scope takes a JSON object with only allow_all (bool) and http_infos ([{http_method, http_path}]); methods: GET, POST, PUT, PATCH, DELETE")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, a := range scopeAPIs {
|
for _, a := range rctx.StrArray("scope-api") {
|
||||||
if _, err := parseScopeAPI(a); err != nil {
|
if len(strings.Fields(strings.TrimSpace(a))) != 2 {
|
||||||
return appsValidationParamError("--scope-api", "invalid --scope-api: %s", err).
|
return appsValidationParamError("--scope-api", "--scope-api must be 'METHOD /path', got %q", a).
|
||||||
WithHint("format: 'METHOD /openapi/path'; method one of GET, POST, PUT, PATCH, DELETE; path starts with '/', no '..' or '//'")
|
WithHint("format: --scope-api 'METHOD /openapi/path' (routes come from the app's docs/openapi.json), e.g. --scope-api 'GET /openapi/orders'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -78,108 +78,6 @@ func TestParseScopeAPI(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateScopeAPIMethod(t *testing.T) {
|
|
||||||
for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
|
|
||||||
if err := validateScopeAPIMethod(m); err != nil {
|
|
||||||
t.Errorf("validateScopeAPIMethod(%q) = %v, want nil", m, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range []string{"TRACE", "CONNECT", "OPTIONS", "HEAD", "", "get"} {
|
|
||||||
if err := validateScopeAPIMethod(m); err == nil {
|
|
||||||
t.Errorf("validateScopeAPIMethod(%q) = nil, want error", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateScopeAPIPath(t *testing.T) {
|
|
||||||
for _, p := range []string{"/openapi/orders", "/openapi/v1/x"} {
|
|
||||||
if err := validateScopeAPIPath(p); err != nil {
|
|
||||||
t.Errorf("validateScopeAPIPath(%q) = %v, want nil", p, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, p := range []string{"", "openapi/x", "/openapi/../admin", "/..", "/openapi//x", "//x"} {
|
|
||||||
if err := validateScopeAPIPath(p); err == nil {
|
|
||||||
t.Errorf("validateScopeAPIPath(%q) = nil, want error", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateRequestScopeFields(t *testing.T) {
|
|
||||||
ok := []map[string]interface{}{
|
|
||||||
{"allow_all": true},
|
|
||||||
{"allow_all": false, "http_infos": []interface{}{
|
|
||||||
map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"},
|
|
||||||
}},
|
|
||||||
{},
|
|
||||||
}
|
|
||||||
for _, rs := range ok {
|
|
||||||
if err := validateRequestScopeFields(rs); err != nil {
|
|
||||||
t.Errorf("validateRequestScopeFields(%v) = %v, want nil", rs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bad := []map[string]interface{}{
|
|
||||||
{"foo": 1}, // unknown top-level field
|
|
||||||
{"allow_all": "yes"}, // wrong type
|
|
||||||
{"http_infos": "x"}, // not an array
|
|
||||||
{"http_infos": []interface{}{"x"}}, // entry not an object
|
|
||||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "TRACE", "http_path": "/x"}}}, // bad method
|
|
||||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "../x"}}}, // bad path
|
|
||||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "/x", "extra": 1}}}, // unknown entry field
|
|
||||||
}
|
|
||||||
for _, rs := range bad {
|
|
||||||
if err := validateRequestScopeFields(rs); err == nil {
|
|
||||||
t.Errorf("validateRequestScopeFields(%v) = nil, want error", rs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseRawScope(t *testing.T) {
|
|
||||||
if _, err := parseRawScope(`{"allow_all":true}`); err != nil {
|
|
||||||
t.Errorf("valid object errored: %v", err)
|
|
||||||
}
|
|
||||||
for _, raw := range []string{`["x"]`, `"s"`, `123`, `{"foo":1}`, `{bad`} {
|
|
||||||
if _, err := parseRawScope(raw); err == nil {
|
|
||||||
t.Errorf("parseRawScope(%q) = nil, want error", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseScopeAPI_Rejects(t *testing.T) {
|
|
||||||
bad := []string{"TRACE /openapi/x", "CONNECT /x", "GET ../admin", "GET openapi/x", "GET /a//b"}
|
|
||||||
for _, in := range bad {
|
|
||||||
if _, err := parseScopeAPI(in); err == nil {
|
|
||||||
t.Errorf("parseScopeAPI(%q) = nil, want error", in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// regression: legitimate input still parses (and lowercases the method)
|
|
||||||
info, err := parseScopeAPI("get /openapi/orders")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if info["http_method"] != "GET" || info["http_path"] != "/openapi/orders" {
|
|
||||||
t.Errorf("info = %v", info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildRequestScope_RawValidation(t *testing.T) {
|
|
||||||
// unknown field now rejected (HIGH-2)
|
|
||||||
if _, err := buildRequestScope(false, nil, `{"foo":1}`); err == nil {
|
|
||||||
t.Errorf("raw scope with unknown field must error")
|
|
||||||
}
|
|
||||||
// non-object rejected
|
|
||||||
if _, err := buildRequestScope(false, nil, `["x"]`); err == nil {
|
|
||||||
t.Errorf("non-object raw scope must error")
|
|
||||||
}
|
|
||||||
// nested bad method rejected
|
|
||||||
if _, err := buildRequestScope(false, nil, `{"http_infos":[{"http_method":"TRACE","http_path":"/x"}]}`); err == nil {
|
|
||||||
t.Errorf("raw scope with bad nested method must error")
|
|
||||||
}
|
|
||||||
// regression: documented fields pass
|
|
||||||
if _, err := buildRequestScope(false, nil, `{"allow_all":true}`); err != nil {
|
|
||||||
t.Errorf("valid raw scope errored: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildRequestScope(t *testing.T) {
|
func TestBuildRequestScope(t *testing.T) {
|
||||||
t.Run("nothing set -> nil", func(t *testing.T) {
|
t.Run("nothing set -> nil", func(t *testing.T) {
|
||||||
rs, err := buildRequestScope(false, nil, "")
|
rs, err := buildRequestScope(false, nil, "")
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package apps
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRejectOutputTraversal pins HIGH-3: --output rejects absolute paths and
|
|
||||||
// any .. traversal component; empty and ordinary relative paths pass.
|
|
||||||
func TestRejectOutputTraversal(t *testing.T) {
|
|
||||||
ok := []string{"", "out.csv", "./out.csv", "sub/dir/out.csv", "a..b.csv", "foo..bar/x.csv"}
|
|
||||||
for _, p := range ok {
|
|
||||||
if err := rejectOutputTraversal(p); err != nil {
|
|
||||||
t.Errorf("rejectOutputTraversal(%q) = %v, want nil", p, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bad := []string{"/etc/passwd", "../x", "../../etc/cron.d/evil", "sub/../../x", "./../x"}
|
|
||||||
for _, p := range bad {
|
|
||||||
if err := rejectOutputTraversal(p); err == nil {
|
|
||||||
t.Errorf("rejectOutputTraversal(%q) = nil, want validation error", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSanitizeUploadFileName pins HIGH-4 / LOW-1: control & separator chars are
|
|
||||||
// neutralized (percent-encoded, no raw CR/LF/TAB/NUL/quote) and the result never
|
|
||||||
// starts with a dot (hidden-file overwrite guard).
|
|
||||||
func TestSanitizeUploadFileName(t *testing.T) {
|
|
||||||
// LOW-1: dotfiles get a leading underscore.
|
|
||||||
for _, in := range []string{".bashrc", ".ssh", "..hidden"} {
|
|
||||||
got := sanitizeUploadFileName(in)
|
|
||||||
if strings.HasPrefix(got, ".") {
|
|
||||||
t.Errorf("sanitizeUploadFileName(%q) = %q, must not start with '.'", in, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// HIGH-4: header-breaking / control chars must not survive raw.
|
|
||||||
raw := "a\r\nb\tc\x00d\"e.png"
|
|
||||||
got := sanitizeUploadFileName(raw)
|
|
||||||
for _, bad := range []string{"\r", "\n", "\t", "\x00", "\"", " "} {
|
|
||||||
if strings.Contains(got, bad) {
|
|
||||||
t.Errorf("sanitizeUploadFileName(%q) = %q, still contains raw %q", raw, got, bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got == "" {
|
|
||||||
t.Error("sanitizeUploadFileName returned empty for non-empty input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
package apps
|
package apps
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
@@ -40,28 +39,3 @@ func withAppsHint(err error, hint string) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
|
||||||
// --output path. The authoritative guard is the local FileIO layer
|
|
||||||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
|
||||||
// symlinks), so traversal is already blocked at write time; this gives an
|
|
||||||
// earlier, clearer validation error and pins the contract in the command layer.
|
|
||||||
// Empty (use server-derived default) passes through. Absolute paths and any
|
|
||||||
// ".." path component are rejected.
|
|
||||||
func rejectOutputTraversal(output string) error {
|
|
||||||
o := strings.TrimSpace(output)
|
|
||||||
if o == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if filepath.IsAbs(o) {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"--output must be a relative path within the current directory, got %q", o).WithParam("--output")
|
|
||||||
}
|
|
||||||
for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) {
|
|
||||||
if seg == ".." {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"--output must not contain .. path traversal, got %q", o).WithParam("--output")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
|||||||
// handled locally.
|
// handled locally.
|
||||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
|
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err))
|
return nil, client.WrapDoAPIError(err)
|
||||||
}
|
}
|
||||||
detail := logIDDetail(resp)
|
detail := logIDDetail(resp)
|
||||||
if resp == nil || len(resp.RawBody) == 0 {
|
if resp == nil || len(resp.RawBody) == 0 {
|
||||||
@@ -501,7 +501,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla
|
|||||||
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
|
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
|
||||||
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
|
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
|
||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
return nil, redactGitCredentialIssueError(withAppsHint(cerr, gitCredentialIssueHint))
|
return nil, withAppsHint(cerr, gitCredentialIssueHint)
|
||||||
}
|
}
|
||||||
if data != nil {
|
if data != nil {
|
||||||
result = data
|
result = data
|
||||||
@@ -536,7 +536,6 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
|||||||
if message == "" {
|
if message == "" {
|
||||||
message = "Git credential API returned non-zero BaseResp status"
|
message = "Git credential API returned non-zero BaseResp status"
|
||||||
}
|
}
|
||||||
message = gitcred.RedactCredentialText(message)
|
|
||||||
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
|
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
|
||||||
if logID != "" {
|
if logID != "" {
|
||||||
baseErr = baseErr.WithLogID(logID)
|
baseErr = baseErr.WithLogID(logID)
|
||||||
@@ -546,17 +545,6 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactGitCredentialIssueError(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if p, ok := errs.ProblemOf(err); ok {
|
|
||||||
p.Message = gitcred.RedactCredentialText(p.Message)
|
|
||||||
p.Hint = gitcred.RedactCredentialText(p.Hint)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
|
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
|
||||||
logID := logIDString(resp)
|
logID := logIDString(resp)
|
||||||
if logID == "" {
|
if logID == "" {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -1026,24 +1027,25 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestParseIssueCredentialDataRedactsCredentialErrorMessage verifies that the
|
// TestParseIssueCredentialDataMessageAddsNoExtraSecret verifies the security
|
||||||
// git-credential boundary does not pass server-provided credential details into
|
// condition that apps does not ADDITIONALLY inject any token/secret into the
|
||||||
// the user-visible typed envelope message.
|
// Git-credential error it builds. The server `msg` is passed through verbatim
|
||||||
func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
// into Problem.Message, and the only thing apps adds is the static
|
||||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
// gitCredentialIssueHint — which itself contains no secret. We feed a benign
|
||||||
samplePassword := "sample-password"
|
// server msg and assert (a) Message equals that msg exactly, and (b) neither
|
||||||
serverMsg := "permission denied: " +
|
// Message nor Hint contains any token/secret-shaped string.
|
||||||
testCredentialAssignment("token", samplePAT) + " " +
|
//
|
||||||
testCredentialAssignment("password", samplePassword) + " " +
|
// Note: server msg passthrough is the shared classifier's responsibility;
|
||||||
testCredentialURLWithUserInfo("example.com/repo.git", samplePAT)
|
// apps adds only a static hint. There is no msg redaction in this path, so
|
||||||
|
// this test does not assert a redaction that does not exist — it asserts
|
||||||
|
// that apps injects nothing sensitive of its own.
|
||||||
|
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||||
|
const serverMsg = "permission denied"
|
||||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
resp *larkcore.ApiResp
|
resp *larkcore.ApiResp
|
||||||
wantType errs.Category
|
|
||||||
wantSubtype errs.Subtype
|
|
||||||
wantCode int
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "http error path",
|
name: "http error path",
|
||||||
@@ -1052,9 +1054,6 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
|||||||
RawBody: []byte(`{"msg":"` + serverMsg + `"}`),
|
RawBody: []byte(`{"msg":"` + serverMsg + `"}`),
|
||||||
Header: header,
|
Header: header,
|
||||||
},
|
},
|
||||||
wantType: errs.CategoryAPI,
|
|
||||||
wantSubtype: errs.SubtypeUnknown,
|
|
||||||
wantCode: http.StatusForbidden,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "business code path",
|
name: "business code path",
|
||||||
@@ -1063,9 +1062,6 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
|||||||
RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`),
|
RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`),
|
||||||
Header: header,
|
Header: header,
|
||||||
},
|
},
|
||||||
wantType: errs.CategoryAPI,
|
|
||||||
wantSubtype: errs.SubtypeUnknown,
|
|
||||||
wantCode: 999,
|
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -1077,85 +1073,30 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if p.Category != tc.wantType || p.Subtype != tc.wantSubtype || p.Code != tc.wantCode {
|
// (a) The server msg survives into the message. The business-code
|
||||||
t.Fatalf("problem metadata = %s/%s code=%d, want %s/%s code=%d",
|
// path passes it through verbatim; the HTTP-status path reports
|
||||||
p.Category, p.Subtype, p.Code, tc.wantType, tc.wantSubtype, tc.wantCode)
|
// "HTTP <status>: <body>" via the shared classifier, so assert
|
||||||
}
|
// containment rather than equality.
|
||||||
if !strings.Contains(p.Message, "permission denied") {
|
if !strings.Contains(p.Message, serverMsg) {
|
||||||
t.Fatalf("Message = %q, want it to retain non-secret server context", p.Message)
|
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
|
||||||
}
|
}
|
||||||
|
// apps adds only the static hint — assert that exact static text,
|
||||||
|
// proving apps injects no per-request secret into the hint either.
|
||||||
if p.Hint != gitCredentialIssueHint {
|
if p.Hint != gitCredentialIssueHint {
|
||||||
t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint)
|
t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint)
|
||||||
}
|
}
|
||||||
|
// (b) Neither field may contain a token/secret-shaped string that
|
||||||
|
// apps could have added on top of the framework passthrough.
|
||||||
|
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S|password\s*[=:]\s*\S)`)
|
||||||
for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} {
|
for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} {
|
||||||
for _, leaked := range []string{samplePAT, "user:" + samplePAT + "@", testCredentialAssignment("password", samplePassword)} {
|
if secret.MatchString(val) {
|
||||||
if strings.Contains(val, leaked) {
|
t.Fatalf("%s leaks a token/secret-shaped string: %q", field, val)
|
||||||
t.Fatalf("%s leaks %q: %q", field, leaked, val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, want := range []string{
|
|
||||||
testRedactedAssignment("token"),
|
|
||||||
testRedactedAssignment("password"),
|
|
||||||
"https://***@example.com/repo.git",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(p.Message, want) {
|
|
||||||
t.Fatalf("Message missing %q after redaction: %q", want, p.Message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIssueCredentialDataRedactsSDKErrorPreservesCause(t *testing.T) {
|
|
||||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
|
||||||
cause := errors.New("transport failed with " + testCredentialAssignment("token", samplePAT))
|
|
||||||
|
|
||||||
_, err := parseIssueCredentialData(nil, cause, errclass.ClassifyContext{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected SDK-boundary error, got nil")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, cause) {
|
|
||||||
t.Fatalf("error does not preserve cause: %v", err)
|
|
||||||
}
|
|
||||||
p, ok := errs.ProblemOf(err)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
|
||||||
t.Fatalf("problem metadata = %s/%s, want %s/%s",
|
|
||||||
p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
|
||||||
}
|
|
||||||
if strings.Contains(p.Message, samplePAT) {
|
|
||||||
t.Fatalf("message leaks credential value: %q", p.Message)
|
|
||||||
}
|
|
||||||
if want := testRedactedAssignment("token"); !strings.Contains(p.Message, want) {
|
|
||||||
t.Fatalf("message missing %q after redaction: %q", want, p.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRedactGitCredentialIssueErrorNil(t *testing.T) {
|
|
||||||
if err := redactGitCredentialIssueError(nil); err != nil {
|
|
||||||
t.Fatalf("redactGitCredentialIssueError(nil) = %v, want nil", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPublicSafeJoin(parts ...string) string {
|
|
||||||
return strings.Join(parts, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCredentialAssignment(key, value string) string {
|
|
||||||
return key + "=" + value
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRedactedAssignment(key string) string {
|
|
||||||
return key + "=<redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
|
||||||
return "https://" + "user:" + credential + "@" + hostPath
|
|
||||||
}
|
|
||||||
|
|
||||||
type errorReader struct{}
|
type errorReader struct{}
|
||||||
|
|
||||||
func (errorReader) Read(p []byte) (int, error) {
|
func (errorReader) Read(p []byte) (int, error) {
|
||||||
|
|||||||
@@ -542,15 +542,7 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
|||||||
if err := manager.Store.Upsert(*record); err != nil {
|
if err := manager.Store.Upsert(*record); err != nil {
|
||||||
t.Fatalf("Upsert expired record returned error: %v", err)
|
t.Fatalf("Upsert expired record returned error: %v", err)
|
||||||
}
|
}
|
||||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
issuer.err = errors.New("permission denied")
|
||||||
samplePassword := "sample-password"
|
|
||||||
issuer.err = errs.NewAPIError(
|
|
||||||
errs.SubtypeUnknown,
|
|
||||||
"permission denied: "+
|
|
||||||
testCredentialAssignment("token", samplePAT)+" "+
|
|
||||||
testCredentialAssignment("password", samplePassword)+" "+
|
|
||||||
testCredentialURLWithUserInfo("example.com/git/u/app.git", samplePAT),
|
|
||||||
).WithHint("retry without " + testCredentialAssignment("token", samplePAT)).WithLogID("log_x")
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var errOut bytes.Buffer
|
var errOut bytes.Buffer
|
||||||
@@ -560,22 +552,6 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
|||||||
if out.Len() != 0 {
|
if out.Len() != 0 {
|
||||||
t.Fatalf("stdout = %q, want empty", out.String())
|
t.Fatalf("stdout = %q, want empty", out.String())
|
||||||
}
|
}
|
||||||
stderr := errOut.String()
|
|
||||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("password", samplePassword), "user:" + samplePAT + "@"} {
|
|
||||||
if strings.Contains(stderr, leaked) {
|
|
||||||
t.Fatalf("stderr leaks %q: %s", leaked, stderr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, want := range []string{
|
|
||||||
testRedactedAssignment("token"),
|
|
||||||
testRedactedAssignment("password"),
|
|
||||||
"https://***@example.com/git/u/app.git",
|
|
||||||
"log_id=log_x",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(stderr, want) {
|
|
||||||
t.Fatalf("stderr missing %q in %s", want, stderr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) {
|
if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) {
|
||||||
t.Fatalf("stderr missing actionable hint: %q", errOut.String())
|
t.Fatalf("stderr missing actionable hint: %q", errOut.String())
|
||||||
}
|
}
|
||||||
@@ -1435,36 +1411,10 @@ func TestSecretStoreBranches(t *testing.T) {
|
|||||||
if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil {
|
if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil {
|
||||||
t.Fatal("SecretStore.Set empty ref returned nil error")
|
t.Fatal("SecretStore.Set empty ref returned nil error")
|
||||||
}
|
}
|
||||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
|
||||||
kc.setErr = errors.New("keychain set failed with " + testCredentialAssignment("token", samplePAT))
|
|
||||||
var setCfgErr *errs.ConfigError
|
|
||||||
setErr := NewSecretStore(kc).Set("ref", samplePAT)
|
|
||||||
if setErr == nil || !errors.As(setErr, &setCfgErr) {
|
|
||||||
t.Fatalf("SecretStore.Set keychain error = %T %v, want ConfigError", setErr, setErr)
|
|
||||||
}
|
|
||||||
assertProblem(t, setErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
|
||||||
if setCfgErr.Message != "save local Git credential PAT to keychain failed" {
|
|
||||||
t.Fatalf("ConfigError message = %q, want static keychain failure", setCfgErr.Message)
|
|
||||||
}
|
|
||||||
if strings.Contains(setCfgErr.Message, samplePAT) {
|
|
||||||
t.Fatalf("ConfigError message leaks credential value: %q", setCfgErr.Message)
|
|
||||||
}
|
|
||||||
if !errors.Is(setCfgErr, kc.setErr) {
|
|
||||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
|
||||||
}
|
|
||||||
kc.setErr = nil
|
|
||||||
kc.removeErr = errors.New("keychain remove failed")
|
kc.removeErr = errors.New("keychain remove failed")
|
||||||
var cfgErr *errs.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
removeErr := NewSecretStore(kc).Remove("ref")
|
if err := NewSecretStore(kc).Remove("ref"); err == nil || !errors.As(err, &cfgErr) {
|
||||||
if removeErr == nil || !errors.As(removeErr, &cfgErr) {
|
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", err, err)
|
||||||
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", removeErr, removeErr)
|
|
||||||
}
|
|
||||||
assertProblem(t, removeErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
|
||||||
if cfgErr.Message != "remove local Git credential PAT from keychain failed" {
|
|
||||||
t.Fatalf("ConfigError message = %q, want static keychain failure", cfgErr.Message)
|
|
||||||
}
|
|
||||||
if !errors.Is(cfgErr, kc.removeErr) {
|
|
||||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1546,56 +1496,6 @@ func TestLockAppHeldTimesOut(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerGetPreservesTypedLockAppError(t *testing.T) {
|
|
||||||
now := time.Unix(1780000000, 0)
|
|
||||||
store := NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename))
|
|
||||||
kc := newFakeKeychain()
|
|
||||||
record := CredentialRecord{
|
|
||||||
AppID: "app_xxx",
|
|
||||||
GitHTTPURL: "https://example.com/git/u/app.git",
|
|
||||||
Profile: testProfile().Profile,
|
|
||||||
ProfileAppID: testProfile().ProfileAppID,
|
|
||||||
UserOpenID: testProfile().UserOpenID,
|
|
||||||
Username: "x-access-token",
|
|
||||||
PATRef: "ref",
|
|
||||||
Status: StatusConfirmed,
|
|
||||||
ExpiresAt: now.Add(-time.Minute).Unix(),
|
|
||||||
UpdatedAt: now.Unix(),
|
|
||||||
}
|
|
||||||
if err := store.Upsert(record); err != nil {
|
|
||||||
t.Fatalf("Upsert returned error: %v", err)
|
|
||||||
}
|
|
||||||
kc.values[record.PATRef] = "old-pat"
|
|
||||||
|
|
||||||
blocker := filepath.Join(t.TempDir(), "config-blocker")
|
|
||||||
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
|
|
||||||
t.Fatalf("write config blocker: %v", err)
|
|
||||||
}
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
|
|
||||||
|
|
||||||
manager := NewManager(store, NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
|
|
||||||
GitHTTPURL: record.GitHTTPURL,
|
|
||||||
PAT: "new-pat",
|
|
||||||
ExpiresAt: now.Add(24 * time.Hour).Unix(),
|
|
||||||
}})
|
|
||||||
manager.Now = func() time.Time { return now }
|
|
||||||
var out bytes.Buffer
|
|
||||||
var errOut bytes.Buffer
|
|
||||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
|
||||||
t.Fatalf("Get returned error: %v", err)
|
|
||||||
}
|
|
||||||
if out.Len() != 0 {
|
|
||||||
t.Fatalf("stdout = %q, want empty", out.String())
|
|
||||||
}
|
|
||||||
stderr := errOut.String()
|
|
||||||
if !strings.Contains(stderr, "create Git credential lock dir") {
|
|
||||||
t.Fatalf("stderr = %q, want typed lock-dir setup error", stderr)
|
|
||||||
}
|
|
||||||
if strings.Contains(stderr, "acquire Git credential lock") {
|
|
||||||
t.Fatalf("stderr rewrapped typed lock error: %q", stderr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManagerInitStoreAndSecretReadErrors(t *testing.T) {
|
func TestManagerInitStoreAndSecretReadErrors(t *testing.T) {
|
||||||
now := time.Unix(1780000000, 0)
|
now := time.Unix(1780000000, 0)
|
||||||
path := filepath.Join(t.TempDir(), MetadataFilename)
|
path := filepath.Join(t.TempDir(), MetadataFilename)
|
||||||
@@ -1871,15 +1771,8 @@ func TestManagerGetBranches(t *testing.T) {
|
|||||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
||||||
t.Fatalf("Get keychain set error returned error: %v", err)
|
t.Fatalf("Get keychain set error returned error: %v", err)
|
||||||
}
|
}
|
||||||
stderr := errOut.String()
|
if !strings.Contains(errOut.String(), "keychain locked") {
|
||||||
if !strings.Contains(stderr, "save local Git credential PAT to keychain failed") {
|
t.Fatalf("stderr = %q, want keychain error", errOut.String())
|
||||||
t.Fatalf("stderr = %q, want static keychain error", stderr)
|
|
||||||
}
|
|
||||||
if !strings.Contains(stderr, "lark-cli apps +git-credential-init") {
|
|
||||||
t.Fatalf("stderr = %q, want init retry hint", stderr)
|
|
||||||
}
|
|
||||||
if strings.Contains(stderr, "keychain locked") {
|
|
||||||
t.Fatalf("stderr leaks keychain cause: %q", stderr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kc.setErr = nil
|
kc.setErr = nil
|
||||||
@@ -2272,189 +2165,6 @@ func TestNilManagerUsesTimeNow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRedactCredentialText focuses on the redaction regex, asserting it
|
|
||||||
// covers credential shapes across forms and does not over-match concatenated
|
|
||||||
// words. JSON-quoted forms are common in server-provided error bodies and must
|
|
||||||
// be covered; concatenated words like mytoken must not be treated as token.
|
|
||||||
func TestRedactCredentialText(t *testing.T) {
|
|
||||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
|
||||||
samplePassword := "sample-password"
|
|
||||||
sampleSecret := "sample-secret"
|
|
||||||
githubLikeToken := testPublicSafeJoin("gh", "p_") + strings.Repeat("x", 20)
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "bare assignment",
|
|
||||||
in: "permission denied: " +
|
|
||||||
testCredentialAssignment("token", samplePAT) + " " +
|
|
||||||
testCredentialAssignment("password", samplePassword),
|
|
||||||
want: "permission denied: " +
|
|
||||||
testRedactedAssignment("token") + " " +
|
|
||||||
testRedactedAssignment("password"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json double-quoted value",
|
|
||||||
in: "body={" +
|
|
||||||
testDoubleQuotedAssignment("password", samplePassword) + "," +
|
|
||||||
testDoubleQuotedAssignment("token", samplePAT) +
|
|
||||||
"}",
|
|
||||||
want: "body={" +
|
|
||||||
testDoubleQuotedRedactedAssignment("password") + "," +
|
|
||||||
testDoubleQuotedRedactedAssignment("token") +
|
|
||||||
"}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json single-quoted value",
|
|
||||||
in: "body={" + testSingleQuotedAssignment("secret", sampleSecret) + "}",
|
|
||||||
want: "body={" + testSingleQuotedRedactedAssignment("secret") + "}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "colon separator with quoted value",
|
|
||||||
in: testCredentialColon("token", `"`+samplePAT+`"`),
|
|
||||||
want: testRedactedColon("token"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "url userinfo",
|
|
||||||
in: "clone " + testCredentialURLWithUserInfo("example.com/repo.git", samplePAT),
|
|
||||||
want: "clone https://***@example.com/repo.git",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bearer header",
|
|
||||||
in: testAuthorizationBearer(githubLikeToken),
|
|
||||||
want: testRedactedAuthorizationBearer(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pat-like standalone",
|
|
||||||
in: "issued " + samplePAT + " for app",
|
|
||||||
want: "issued <redacted> for app",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "concatenated key not redacted",
|
|
||||||
in: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
|
||||||
want: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := RedactCredentialText(tc.in); got != tc.want {
|
|
||||||
t.Fatalf("RedactCredentialText(%q) = %q, want %q", tc.in, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSafeCredentialErrorMessageFallbacks(t *testing.T) {
|
|
||||||
if got := safeCredentialErrorMessage(nil); got != "" {
|
|
||||||
t.Fatalf("safeCredentialErrorMessage(nil) = %q, want empty", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := safeCredentialErrorMessage(&errs.ConfigError{Problem: errs.Problem{
|
|
||||||
Category: errs.CategoryConfig,
|
|
||||||
Subtype: errs.SubtypeInvalidConfig,
|
|
||||||
}}); got != "config/invalid_config" {
|
|
||||||
t.Fatalf("safeCredentialErrorMessage typed fallback = %q, want config/invalid_config", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
|
||||||
got := safeCredentialErrorMessage(errors.New("transport failed with " + testCredentialAssignment("token", samplePAT)))
|
|
||||||
if strings.Contains(got, samplePAT) {
|
|
||||||
t.Fatalf("safeCredentialErrorMessage leaks credential value: %q", got)
|
|
||||||
}
|
|
||||||
if want := testRedactedAssignment("token"); !strings.Contains(got, want) {
|
|
||||||
t.Fatalf("safeCredentialErrorMessage missing %q after redaction: %q", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteCredentialErrorRedactsTypedMessageHint(t *testing.T) {
|
|
||||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
|
||||||
err := errs.NewInternalError(errs.SubtypeStorage, "save failed with %s", testCredentialAssignment("token", samplePAT)).
|
|
||||||
WithHint("retry without %s", testCredentialAssignment("password", samplePAT)).
|
|
||||||
WithLogID("log_x")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
writeCredentialError(&buf, "Git credential refresh failed", err)
|
|
||||||
got := buf.String()
|
|
||||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("token", samplePAT), testCredentialAssignment("password", samplePAT)} {
|
|
||||||
if strings.Contains(got, leaked) {
|
|
||||||
t.Fatalf("writeCredentialError leaks credential value %q in %q", leaked, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, want := range []string{
|
|
||||||
"Git credential refresh failed: save failed with " + testRedactedAssignment("token"),
|
|
||||||
"log_id=log_x",
|
|
||||||
"hint: retry without " + testRedactedAssignment("password"),
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Fatalf("writeCredentialError output missing %q: %q", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeCredentialError(nil, "ignored", err)
|
|
||||||
writeCredentialError(&buf, "ignored", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype) {
|
|
||||||
t.Helper()
|
|
||||||
p, ok := errs.ProblemOf(err)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if p.Category != wantCategory || p.Subtype != wantSubtype {
|
|
||||||
t.Fatalf("problem metadata = %s/%s, want %s/%s", p.Category, p.Subtype, wantCategory, wantSubtype)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPublicSafeJoin(parts ...string) string {
|
|
||||||
return strings.Join(parts, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCredentialAssignment(key, value string) string {
|
|
||||||
return key + "=" + value
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRedactedAssignment(key string) string {
|
|
||||||
return key + "=<redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCredentialColon(key, value string) string {
|
|
||||||
return key + ": " + value
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRedactedColon(key string) string {
|
|
||||||
return key + ": <redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDoubleQuotedAssignment(key, value string) string {
|
|
||||||
return `"` + key + `"` + ":" + `"` + value + `"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDoubleQuotedRedactedAssignment(key string) string {
|
|
||||||
return `"` + key + `"` + ":<redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSingleQuotedAssignment(key, value string) string {
|
|
||||||
return `'` + key + `'` + ":" + `'` + value + `'`
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSingleQuotedRedactedAssignment(key string) string {
|
|
||||||
return `'` + key + `'` + ":<redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
|
||||||
return "https://" + "user:" + credential + "@" + hostPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func testAuthorizationBearer(value string) string {
|
|
||||||
return "Authorization" + ": " + "Bearer " + value
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRedactedAuthorizationBearer() string {
|
|
||||||
return "Authorization" + ": " + "Bearer <redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func testProfile() ProfileContext {
|
func testProfile() ProfileContext {
|
||||||
return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"}
|
return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,25 +27,6 @@ type Manager struct {
|
|||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialKeys is the shared list of credential field names to redact; the
|
|
||||||
// bare, double-quoted (JSON), and single-quoted forms all reuse it.
|
|
||||||
const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret`
|
|
||||||
|
|
||||||
var (
|
|
||||||
credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
|
||||||
// credentialAssignmentRE matches credential key assignments, including JSON
|
|
||||||
// quoted forms. Capture group 1 is the key and separator; only the value is
|
|
||||||
// replaced with <redacted>. The key is one of three forms — double-quoted,
|
|
||||||
// single-quoted, or bare with a word boundary — so concatenated words like
|
|
||||||
// mytoken are not matched. Each form wraps the key list in (?:...) so the |
|
|
||||||
// alternation does not bind the quote/boundary to only the first and last key.
|
|
||||||
credentialAssignmentRE = regexp.MustCompile(
|
|
||||||
`(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`,
|
|
||||||
)
|
|
||||||
credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`)
|
|
||||||
credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`)
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
Store: store,
|
Store: store,
|
||||||
@@ -192,12 +172,12 @@ func (m *Manager) List() (*ListResult, error) {
|
|||||||
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
||||||
url, err := NormalizeCredentialInput(input)
|
url, err := NormalizeCredentialInput(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
record, pat, ok, err := m.readConfirmed(url, current)
|
record, pat, ok, err := m.readConfirmed(url, current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -207,28 +187,18 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
|||||||
return writeGitCredential(out, record.Username, pat)
|
return writeGitCredential(out, record.Username, pat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock ordering convention (see lock.go package comment): always acquire
|
unlock := lockURL(url)
|
||||||
// lockApp before lockURL. lockApp is a cross-process file lock with a
|
defer unlock()
|
||||||
// timeout and possible setup failure; acquiring it first avoids holding an
|
|
||||||
// in-process mutex on the failure path, which would risk a deadlock.
|
|
||||||
unlockApp, err := lockApp(record.AppID)
|
unlockApp, err := lockApp(record.AppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// lockApp may already return a typed error, for example when creating
|
fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err)
|
||||||
// the lock directory fails. Preserve those classifications and only wrap
|
|
||||||
// raw lockfile errors to add app context.
|
|
||||||
if _, ok := errs.ProblemOf(err); !ok {
|
|
||||||
err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
|
||||||
}
|
|
||||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer unlockApp()
|
defer unlockApp()
|
||||||
unlockURL := lockURL(url)
|
|
||||||
defer unlockURL()
|
|
||||||
|
|
||||||
record, pat, ok, err = m.readConfirmed(url, current)
|
record, pat, ok, err = m.readConfirmed(url, current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -243,17 +213,16 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
|||||||
}
|
}
|
||||||
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID)
|
||||||
fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||||
if urlErr != nil {
|
if urlErr != nil {
|
||||||
writeCredentialError(errOut, "Git credential refresh failed", urlErr)
|
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
||||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if issuedURL != url {
|
if issuedURL != url {
|
||||||
@@ -263,7 +232,7 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
|||||||
if issued.ExpiresAt < record.ExpiresAt {
|
if issued.ExpiresAt < record.ExpiresAt {
|
||||||
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
writeCredentialError(errOut, "Git credential unavailable", readErr)
|
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if found && m.usable(latest, latestPAT) {
|
if found && m.usable(latest, latestPAT) {
|
||||||
@@ -278,64 +247,17 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
|||||||
record.Status = StatusConfirmed
|
record.Status = StatusConfirmed
|
||||||
oldPAT := pat
|
oldPAT := pat
|
||||||
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
||||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := m.Store.Upsert(record); err != nil {
|
if err := m.Store.Upsert(record); err != nil {
|
||||||
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
||||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return writeGitCredential(out, record.Username, issued.PAT)
|
return writeGitCredential(out, record.Username, issued.PAT)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeCredentialError(w io.Writer, prefix string, err error) {
|
|
||||||
if w == nil || err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeCredentialErrorMessage(err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if p, ok := errs.ProblemOf(err); ok {
|
|
||||||
message := RedactCredentialText(p.Message)
|
|
||||||
if p.LogID != "" {
|
|
||||||
if message != "" {
|
|
||||||
message += "; "
|
|
||||||
}
|
|
||||||
message += "log_id=" + p.LogID
|
|
||||||
}
|
|
||||||
if p.Hint != "" {
|
|
||||||
if message != "" {
|
|
||||||
message += "; "
|
|
||||||
}
|
|
||||||
message += "hint: " + RedactCredentialText(p.Hint)
|
|
||||||
}
|
|
||||||
if message != "" {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
if p.Category != "" || p.Subtype != "" {
|
|
||||||
return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return RedactCredentialText(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// RedactCredentialText masks credential fragments that may appear in free
|
|
||||||
// text, covering URL userinfo, Authorization bearer headers, credential
|
|
||||||
// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by
|
|
||||||
// the gitcred and apps packages so the redaction logic does not fork.
|
|
||||||
func RedactCredentialText(text string) string {
|
|
||||||
text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@")
|
|
||||||
text = credentialBearerRE.ReplaceAllString(text, "${1}<redacted>")
|
|
||||||
text = credentialAssignmentRE.ReplaceAllString(text, "${1}<redacted>")
|
|
||||||
text = credentialPATLikeRE.ReplaceAllString(text, "<redacted>")
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
||||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||||
if err != nil || len(records) == 0 {
|
if err != nil || len(records) == 0 {
|
||||||
|
|||||||
@@ -42,15 +42,7 @@ func (s *SecretStore) Set(ref, pat string) error {
|
|||||||
Message: "keychain PAT reference is empty",
|
Message: "keychain PAT reference is empty",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
if err := s.kc.Set(KeychainService, ref, pat); err != nil {
|
return s.kc.Set(KeychainService, ref, pat)
|
||||||
return &errs.ConfigError{Problem: errs.Problem{
|
|
||||||
Category: errs.CategoryConfig,
|
|
||||||
Subtype: errs.SubtypeInvalidConfig,
|
|
||||||
Message: "save local Git credential PAT to keychain failed",
|
|
||||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
|
||||||
}, Cause: err}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecretStore) Remove(ref string) error {
|
func (s *SecretStore) Remove(ref string) error {
|
||||||
@@ -72,7 +64,7 @@ func (s *SecretStore) Remove(ref string) error {
|
|||||||
return &errs.ConfigError{Problem: errs.Problem{
|
return &errs.ConfigError{Problem: errs.Problem{
|
||||||
Category: errs.CategoryConfig,
|
Category: errs.CategoryConfig,
|
||||||
Subtype: errs.SubtypeInvalidConfig,
|
Subtype: errs.SubtypeInvalidConfig,
|
||||||
Message: "remove local Git credential PAT from keychain failed",
|
Message: "remove local Git credential PAT from keychain failed: " + err.Error(),
|
||||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||||
}, Cause: err}
|
}, Cause: err}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package gitcred manages the lifecycle of app Git credentials.
|
|
||||||
//
|
|
||||||
// Lock ordering convention — read this before adding any new lock acquisition:
|
|
||||||
//
|
|
||||||
// ALWAYS acquire lockApp BEFORE lockURL. Never invert this order.
|
|
||||||
//
|
|
||||||
// Rationale:
|
|
||||||
// - lockApp is a cross-process file lock with bounded timeout (2s) and a
|
|
||||||
// possible setup error; acquiring it first keeps the failure surface
|
|
||||||
// outside any in-process lock and avoids holding the in-process mutex
|
|
||||||
// while waiting on I/O / another process.
|
|
||||||
// - lockURL is an in-process sync.Mutex that never fails and blocks
|
|
||||||
// indefinitely; holding it while waiting on lockApp would risk
|
|
||||||
// deadlocking with a concurrent goroutine that held lockApp first.
|
|
||||||
//
|
|
||||||
// Paths that only manipulate per-app state (Init, Remove, Erase) only need
|
|
||||||
// lockApp. Get() is the only path that touches per-URL state in addition to
|
|
||||||
// per-app state, so it is the only caller that takes both locks.
|
|
||||||
package gitcred
|
package gitcred
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -38,11 +20,6 @@ var urlLocks sync.Map
|
|||||||
|
|
||||||
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||||
|
|
||||||
// lockURL acquires an in-process, per-URL mutex. It never returns an error
|
|
||||||
// and blocks until the mutex is available.
|
|
||||||
//
|
|
||||||
// Lock ordering: lockURL MUST NOT be held while calling lockApp. See package
|
|
||||||
// comment for the full convention.
|
|
||||||
func lockURL(url string) func() {
|
func lockURL(url string) func() {
|
||||||
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
|
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
|
||||||
mu := actual.(*sync.Mutex)
|
mu := actual.(*sync.Mutex)
|
||||||
@@ -50,12 +27,6 @@ func lockURL(url string) func() {
|
|||||||
return mu.Unlock
|
return mu.Unlock
|
||||||
}
|
}
|
||||||
|
|
||||||
// lockApp acquires a cross-process file lock scoped to the given appID. It
|
|
||||||
// returns an unlock function or an error if the lock directory cannot be
|
|
||||||
// created or the lock cannot be acquired within the 2s timeout.
|
|
||||||
//
|
|
||||||
// Lock ordering: when both lockApp and lockURL are needed, lockApp must be
|
|
||||||
// taken FIRST. See package comment for the full convention.
|
|
||||||
func lockApp(appID string) (func(), error) {
|
func lockApp(appID string) (func(), error) {
|
||||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
@@ -51,48 +50,13 @@ func pluginCheckProjectDir(projectPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validatePluginKey validates a plugin key for use in filesystem paths.
|
|
||||||
// Rejects empty, ".", "..", absolute paths, path traversal, and control characters.
|
|
||||||
func validatePluginKey(key string) error {
|
|
||||||
if key == "" || key == "." || key == ".." {
|
|
||||||
return appsValidationError("invalid plugin key: must not be empty, \".\", or \"..\"")
|
|
||||||
}
|
|
||||||
if filepath.IsAbs(key) {
|
|
||||||
return appsValidationError("invalid plugin key: must not be an absolute path: %q", key)
|
|
||||||
}
|
|
||||||
if strings.Contains(key, "..") {
|
|
||||||
return appsValidationError("invalid plugin key: must not contain path traversal: %q", key)
|
|
||||||
}
|
|
||||||
for _, r := range key {
|
|
||||||
if r < 32 || r == 127 {
|
|
||||||
return appsValidationError("invalid plugin key: contains control character (code %d)", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// secureModulePath validates the plugin key and joins it with
|
|
||||||
// projectPath/node_modules, asserting the result stays within node_modules.
|
|
||||||
func secureModulePath(projectPath, key string) (string, error) {
|
|
||||||
if err := validatePluginKey(key); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
nodeModules := filepath.Join(projectPath, "node_modules")
|
|
||||||
resolved := filepath.Clean(filepath.Join(nodeModules, key))
|
|
||||||
expectedPrefix := filepath.Clean(nodeModules) + string(filepath.Separator)
|
|
||||||
if !strings.HasPrefix(resolved+string(filepath.Separator), expectedPrefix) {
|
|
||||||
return "", appsValidationError("plugin key %q resolves outside node_modules", key)
|
|
||||||
}
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
|
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
|
||||||
// 1. MIAODA_CAPABILITIES_DIR env var
|
// 1. MIAODA_CAPABILITIES_DIR env var
|
||||||
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
|
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
|
||||||
// 2.5 Read .env.local for MIAODA_APP_TYPE
|
// 2.5 Read .env.local for MIAODA_APP_TYPE
|
||||||
// 3. Detect by checking which directories exist under projectPath
|
// 3. Detect by checking which directories exist under projectPath
|
||||||
func pluginResolveCapDir(projectPath string) (string, error) {
|
func pluginResolveCapDir(projectPath string) (string, error) {
|
||||||
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" {
|
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional.
|
||||||
if filepath.IsAbs(dir) {
|
if filepath.IsAbs(dir) {
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
@@ -100,16 +64,10 @@ func pluginResolveCapDir(projectPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/
|
// 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/
|
||||||
appType := os.Getenv("MIAODA_APP_TYPE")
|
appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional.
|
||||||
if appType == "" {
|
if appType == "" {
|
||||||
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
|
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
|
||||||
}
|
}
|
||||||
if appType != "" {
|
|
||||||
if _, err := strconv.Atoi(appType); err != nil {
|
|
||||||
return "", appsValidationError("MIAODA_APP_TYPE must be a number, got %q", appType).
|
|
||||||
WithHint("set MIAODA_APP_TYPE to a valid numeric value in .env.local")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if appType == "6" {
|
if appType == "6" {
|
||||||
return filepath.Join(projectPath, "shared", "capabilities"), nil
|
return filepath.Join(projectPath, "shared", "capabilities"), nil
|
||||||
}
|
}
|
||||||
@@ -199,11 +157,13 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
|
|||||||
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
||||||
capDir, err := pluginResolveCapDir(projectPath)
|
capDir, err := pluginResolveCapDir(projectPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict
|
// No capabilities directory → no instances can exist → no conflict.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
caps, err := pluginListCapabilities(capDir)
|
caps, err := pluginListCapabilities(capDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil //nolint:nilerr // best-effort: scan failure should not block uninstall
|
// Cannot scan → best-effort, don't block.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
var deps []string
|
var deps []string
|
||||||
for _, cap := range caps {
|
for _, cap := range caps {
|
||||||
@@ -221,6 +181,26 @@ func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
|||||||
).WithHint("delete these instances first (see <project-path>/.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall")
|
).WithHint("delete these instances first (see <project-path>/.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pluginCheckInstalled verifies that the plugin package is installed in node_modules
|
||||||
|
// with a valid manifest.json.
|
||||||
|
func pluginCheckInstalled(projectPath, pluginKey string) error {
|
||||||
|
pluginDir := filepath.Join(projectPath, "node_modules", pluginKey)
|
||||||
|
manifestPath := filepath.Join(pluginDir, "manifest.json")
|
||||||
|
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check.
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if pluginDirExists(pluginDir) {
|
||||||
|
return appsFailedPreconditionError(
|
||||||
|
"plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey,
|
||||||
|
).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey)
|
||||||
|
}
|
||||||
|
return appsFailedPreconditionError("plugin %q is not installed", pluginKey).
|
||||||
|
WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey)
|
||||||
|
}
|
||||||
|
return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ── package.json helpers ──
|
// ── package.json helpers ──
|
||||||
|
|
||||||
// pluginReadPackageJSON reads and parses the project's package.json.
|
// pluginReadPackageJSON reads and parses the project's package.json.
|
||||||
@@ -344,15 +324,13 @@ func pluginInstalledVersion(projectPath, pluginKey string) string {
|
|||||||
|
|
||||||
// ── tgz extraction ──
|
// ── tgz extraction ──
|
||||||
|
|
||||||
const pluginExtractMaxBytes = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
|
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
|
||||||
// first path component (npm convention: tarballs contain a "package/" prefix).
|
// first path component (npm convention: tarballs contain a "package/" prefix).
|
||||||
// Path traversal entries are silently skipped.
|
// Path traversal entries are silently skipped.
|
||||||
func pluginExtractTGZ(r io.Reader, destDir string) error {
|
func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||||
gz, err := gzip.NewReader(r)
|
gz, err := gzip.NewReader(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("gzip: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
return fmt.Errorf("gzip: %w", err)
|
||||||
}
|
}
|
||||||
defer gz.Close()
|
defer gz.Close()
|
||||||
|
|
||||||
@@ -364,7 +342,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tar: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
return fmt.Errorf("tar: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
name := pluginStripFirstComponent(hdr.Name)
|
name := pluginStripFirstComponent(hdr.Name)
|
||||||
@@ -382,8 +360,6 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch hdr.Typeflag {
|
switch hdr.Typeflag {
|
||||||
case tar.TypeSymlink, tar.TypeLink:
|
|
||||||
continue
|
|
||||||
case tar.TypeDir:
|
case tar.TypeDir:
|
||||||
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
|
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
|
||||||
return err
|
return err
|
||||||
@@ -396,15 +372,11 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(f, io.LimitReader(tr, pluginExtractMaxBytes)); err != nil {
|
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
|
||||||
if cerr := f.Close(); cerr != nil {
|
f.Close()
|
||||||
return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
f.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
cwd, _ := os.Getwd()
|
cwd, _ := os.Getwd() //nolint:forbidigo
|
||||||
if got != cwd {
|
if got != cwd {
|
||||||
t.Errorf("got %q, want cwd %q", got, cwd)
|
t.Errorf("got %q, want cwd %q", got, cwd)
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestPluginCheckProjectDir_OK(t *testing.T) {
|
func TestPluginCheckProjectDir_OK(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := pluginCheckProjectDir(dir); err != nil {
|
if err := pluginCheckProjectDir(dir); err != nil {
|
||||||
@@ -99,7 +99,7 @@ func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) {
|
|||||||
|
|
||||||
func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, err := pluginResolveCapDir(dir)
|
got, err := pluginResolveCapDir(dir)
|
||||||
@@ -113,7 +113,7 @@ func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
|||||||
|
|
||||||
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, err := pluginResolveCapDir(dir)
|
got, err := pluginResolveCapDir(dir)
|
||||||
@@ -127,7 +127,7 @@ func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
|||||||
|
|
||||||
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, err := pluginResolveCapDir(dir)
|
got, err := pluginResolveCapDir(dir)
|
||||||
@@ -141,10 +141,10 @@ func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
|||||||
|
|
||||||
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
|
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := pluginResolveCapDir(dir)
|
_, err := pluginResolveCapDir(dir)
|
||||||
@@ -210,7 +210,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
|||||||
writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"})
|
writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"})
|
||||||
writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"})
|
writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"})
|
||||||
// non-JSON file should be skipped
|
// non-JSON file should be skipped
|
||||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
|||||||
func TestPluginListCapabilities_SkipsMalformed(t *testing.T) {
|
func TestPluginListCapabilities_SkipsMalformed(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"})
|
writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"})
|
||||||
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,13 +31,11 @@ var AppsPluginInstall = common.Shortcut{
|
|||||||
Description: "Install a plugin package (download, extract, update package.json)",
|
Description: "Install a plugin package (download, extract, update package.json)",
|
||||||
Risk: "write",
|
Risk: "write",
|
||||||
ConditionalScopes: []string{"spark:app:read"},
|
ConditionalScopes: []string{"spark:app:read"},
|
||||||
Scopes: []string{},
|
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Run in project root (like npm); does NOT take --app-id",
|
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
|
||||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate (install or update to latest)",
|
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0",
|
||||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0 (install or update to specific version)",
|
"Example: lark-cli apps +plugin-install (install all declared plugins in package.json)",
|
||||||
"Example: lark-cli apps +plugin-install (batch install all declared plugins from package.json actionPlugins)",
|
|
||||||
},
|
},
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"},
|
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"},
|
||||||
@@ -69,11 +67,6 @@ var AppsPluginInstall = common.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if key := strings.TrimSpace(rctx.Str("name")); key != "" {
|
|
||||||
if err := validatePluginKey(key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pluginCheckProjectDir(projectPath)
|
return pluginCheckProjectDir(projectPath)
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -140,10 +133,7 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract to node_modules
|
// Extract to node_modules
|
||||||
destDir, err := secureModulePath(projectPath, key)
|
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
|
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
|
||||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||||
}
|
}
|
||||||
@@ -205,7 +195,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
|
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(err)
|
return fmt.Errorf("install %s: %w", key, err)
|
||||||
}
|
}
|
||||||
installed++
|
installed++
|
||||||
}
|
}
|
||||||
@@ -227,7 +217,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract to a temp dir first to read package.json
|
// Extract to a temp dir first to read package.json
|
||||||
tmpDir, err := os.MkdirTemp(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename
|
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return appsFileIOError(err, "cannot create temp dir")
|
return appsFileIOError(err, "cannot create temp dir")
|
||||||
}
|
}
|
||||||
@@ -256,10 +246,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move to node_modules
|
// Move to node_modules
|
||||||
destDir, err := secureModulePath(projectPath, key)
|
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
|
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
|
||||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||||
}
|
}
|
||||||
@@ -367,9 +354,6 @@ func pluginFindVersionInItems(data map[string]interface{}, key, version string)
|
|||||||
|
|
||||||
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
|
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
|
||||||
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
|
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
|
||||||
|
|
||||||
const pluginDownloadMaxBytes = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
|
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
|
||||||
apiPath := apiBasePath + "/plugin/versions/download_package"
|
apiPath := apiBasePath + "/plugin/versions/download_package"
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
@@ -395,7 +379,7 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
|
|||||||
WithRetryable()
|
WithRetryable()
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
hint := "check plugin key and version spelling"
|
hint := "check plugin key and version spelling"
|
||||||
if resp.StatusCode == 403 {
|
if resp.StatusCode == 403 {
|
||||||
hint = "download token may have expired; retry the install to get a fresh token"
|
hint = "download token may have expired; retry the install to get a fresh token"
|
||||||
@@ -405,16 +389,5 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
|
|||||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)).
|
return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)).
|
||||||
WithHint(hint)
|
WithHint(hint)
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1))
|
return io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
|
||||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err).
|
|
||||||
WithHint("check network connectivity and retry").
|
|
||||||
WithRetryable().
|
|
||||||
WithCause(err)
|
|
||||||
}
|
|
||||||
if len(data) > pluginDownloadMaxBytes {
|
|
||||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)).
|
|
||||||
WithHint("contact plugin maintainer to reduce package size")
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func TestPluginInstall_SinglePlugin(t *testing.T) {
|
|||||||
|
|
||||||
// Verify file extracted
|
// Verify file extracted
|
||||||
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
|
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
|
||||||
if _, err := os.Stat(manifestPath); err != nil {
|
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
|
||||||
t.Fatalf("manifest.json not extracted: %v", err)
|
t.Fatalf("manifest.json not extracted: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +92,8 @@ func TestPluginInstall_AlreadyInstalled(t *testing.T) {
|
|||||||
})
|
})
|
||||||
// Create an existing installed plugin with package.json containing version
|
// Create an existing installed plugin with package.json containing version
|
||||||
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||||
os.MkdirAll(pkgDir, 0o755)
|
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
|
||||||
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||||
chdirTest(t, dir)
|
chdirTest(t, dir)
|
||||||
|
|
||||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
@@ -126,7 +126,7 @@ func TestPluginExtractTGZ(t *testing.T) {
|
|||||||
t.Fatalf("extract error: %v", err)
|
t.Fatalf("extract error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json"))
|
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("manifest.json not extracted: %v", err)
|
t.Fatalf("manifest.json not extracted: %v", err)
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
|
|||||||
if err := pluginExtractTGZ(&buf, destDir); err != nil {
|
if err := pluginExtractTGZ(&buf, destDir); err != nil {
|
||||||
t.Fatalf("extract should not error, but skip bad entries: %v", err)
|
t.Fatalf("extract should not error, but skip bad entries: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil {
|
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
|
||||||
t.Error("path traversal should have been blocked")
|
t.Error("path traversal should have been blocked")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,11 +17,9 @@ import (
|
|||||||
var AppsPluginList = common.Shortcut{
|
var AppsPluginList = common.Shortcut{
|
||||||
Service: appsService,
|
Service: appsService,
|
||||||
Command: "+plugin-list",
|
Command: "+plugin-list",
|
||||||
Description: "List locally installed plugin packages and their installation status",
|
Description: "List declared plugin packages and their installation status",
|
||||||
Risk: "read",
|
Risk: "read",
|
||||||
Scopes: []string{},
|
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Run in project root (like npm); does NOT take --app-id",
|
|
||||||
"Example: lark-cli apps +plugin-list",
|
"Example: lark-cli apps +plugin-list",
|
||||||
"Example: lark-cli apps +plugin-list --format pretty",
|
"Example: lark-cli apps +plugin-list --format pretty",
|
||||||
},
|
},
|
||||||
@@ -40,8 +40,8 @@ func TestPluginList_Installed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||||
os.MkdirAll(manifestDir, 0o755)
|
os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo
|
||||||
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||||
chdirTest(t, dir)
|
chdirTest(t, dir)
|
||||||
|
|
||||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
@@ -99,14 +99,14 @@ func TestPluginList_DeclaredNotInstalled(t *testing.T) {
|
|||||||
|
|
||||||
func chdirTest(t *testing.T, dir string) {
|
func chdirTest(t *testing.T, dir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
prev, err := os.Getwd()
|
prev, err := os.Getwd() //nolint:forbidigo
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.Chdir(dir); err != nil {
|
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint:errcheck
|
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
||||||
@@ -115,7 +115,7 @@ func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user