mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +08:00
Compare commits
17 Commits
feat/lark-
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419d7df266 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a | ||
|
|
a09388d035 | ||
|
|
cdd9d3409b | ||
|
|
06f6b0b18c | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 | ||
|
|
9a6ba41684 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -277,7 +283,28 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -292,15 +319,22 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
@@ -310,16 +344,24 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -2,6 +2,56 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
- minute wait (#1768)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||
|
||||
### Documentation
|
||||
|
||||
- tighten doc creation validation workflow (#1759)
|
||||
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **envvars**: consolidate agent env value access (#1757)
|
||||
|
||||
### Misc
|
||||
|
||||
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
@@ -1371,6 +1421,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[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
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
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>`
|
||||
## <command> the command as typed, minus `lark-cli <domain>`; a
|
||||
+-prefixed heading (## +create) targets that shortcut
|
||||
<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
|
||||
### Skills bullet skill names, or name/relpath references
|
||||
(lark-contact/references/x.md), to read for usage;
|
||||
merged with the domain `> skill:` default (deduped,
|
||||
domain first)
|
||||
### <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".
|
||||
|
||||
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
|
||||
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
|
||||
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
|
||||
(validated against the path); help drops any that don't resolve, so a typo shows
|
||||
nothing. Point a command at its own reference (e.g. `+search-user` →
|
||||
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
|
||||
domain skill, which the `> skill:` default already covers. When a shortcut also
|
||||
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
|
||||
replace the Go tips (not merged), so keep tips in one place.
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
@@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
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.
|
||||
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
|
||||
so the heading must equal the shortcut command exactly (`## +history-revert`).
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## +search-user
|
||||
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-user.md
|
||||
|
||||
### Avoid when
|
||||
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
|
||||
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Find a user by name**
|
||||
```bash
|
||||
lark-cli contact +search-user --query "alice" --as user
|
||||
```
|
||||
|
||||
**Fetch known users by open_id (me = yourself)**
|
||||
```bash
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-get-user.md
|
||||
|
||||
### Avoid when
|
||||
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
|
||||
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
|
||||
|
||||
### Tips
|
||||
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
|
||||
- --user-id-type must match the id you pass (default open_id)
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1069,3 +1073,157 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultipartFilenames drives one api --file upload through the mock
|
||||
// transport and returns a map of field name -> part filename parsed from the
|
||||
// captured multipart body, plus the map of text form fields. It fails the test
|
||||
// if the captured request is not multipart/form-data.
|
||||
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
|
||||
t.Helper()
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||
}
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||
}
|
||||
filenames := map[string]string{}
|
||||
fields := map[string]string{}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if fn := part.FileName(); fn != "" {
|
||||
filenames[part.FormName()] = fn
|
||||
} else {
|
||||
buf := &bytes.Buffer{}
|
||||
_, _ = buf.ReadFrom(part)
|
||||
fields[part.FormName()] = buf.String()
|
||||
}
|
||||
}
|
||||
return filenames, fields
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if _, ok := filenames["upload"]; !ok {
|
||||
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
|
||||
}
|
||||
if got := filenames["upload"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
|
||||
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, fields := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
|
||||
}
|
||||
if got := fields["type"]; got != "attachment" {
|
||||
t.Fatalf("text field type = %q, want %q", got, "attachment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "unknown-file" {
|
||||
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
|
||||
}
|
||||
}
|
||||
|
||||
92
cmd/build.go
92
cmd/build.go
@@ -21,12 +21,16 @@ import (
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -42,6 +46,7 @@ type buildConfig struct {
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
skillsOverlay *platform.SkillsOverlay
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
@@ -59,6 +64,17 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithEmbeddedSkills customizes the CLI's embedded skills for a caller that builds
|
||||
// the command tree directly instead of registering a plugin. It is the
|
||||
// build-option analogue of a plugin's EmbeddedSkills(...): the same single-owner
|
||||
// rule applies, so combining WithEmbeddedSkills with a plugin that also customizes
|
||||
// skills aborts startup. nil is a no-op.
|
||||
func WithEmbeddedSkills(spec *platform.SkillsOverlay) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skillsOverlay = spec
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
@@ -71,6 +87,21 @@ var embeddedSkillContent fs.FS
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// withEmbeddedSkillsOwner labels a WithEmbeddedSkills contribution in the skill resolver so a
|
||||
// WithEmbeddedSkills + plugin-Skills collision names a stable, non-plugin owner.
|
||||
const withEmbeddedSkillsOwner = "cmd.WithEmbeddedSkills"
|
||||
|
||||
// resolveSkillContent composes the effective embedded skill tree from the CLI
|
||||
// default, an optional WithEmbeddedSkills spec, and plugin SkillsOverlays, enforcing the
|
||||
// single-owner rule across all sources.
|
||||
func resolveSkillContent(cfg *buildConfig, pluginSkills []skillpolicy.PluginSkill) (fs.FS, error) {
|
||||
sources := pluginSkills
|
||||
if cfg.skillsOverlay != nil {
|
||||
sources = append([]skillpolicy.PluginSkill{{PluginName: withEmbeddedSkillsOwner, SkillsOverlay: cfg.skillsOverlay}}, sources...)
|
||||
}
|
||||
return skillpolicy.Resolve(embeddedSkillContent, sources)
|
||||
}
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -154,6 +185,14 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
// Reset every process-global snapshot up front, not only inside
|
||||
// applyUserPolicyPruning: the skipPlugins and install-failure paths
|
||||
// return before pruning runs, and a previous build's state must not
|
||||
// leak into this one (long-lived embedders, test sequences).
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
cmdpolicy.SetActive(nil)
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
@@ -175,7 +214,16 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
// The skill-content getter also gates on the skills command domain:
|
||||
// every pointer it feeds renders as `lark-cli skills read ...`, so with
|
||||
// that domain plugin-denied the pointers would all be dead ends even
|
||||
// though the content itself still exists.
|
||||
installTipsHelpFunc(rootCmd, func() fs.FS {
|
||||
if policystate.DomainDeniedByPlugin("skills") {
|
||||
return nil
|
||||
}
|
||||
return f.SkillContent
|
||||
})
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
@@ -223,6 +271,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
installHelpCommand(rootCmd)
|
||||
resolved, err := resolveSkillContent(cfg, nil)
|
||||
if err != nil {
|
||||
installPluginSkillErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
f.SkillContent = resolved
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
@@ -233,23 +288,52 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var pluginRules []cmdpolicy.PluginRule
|
||||
var pluginSkills []skillpolicy.PluginSkill
|
||||
var registry *hook.Registry
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
pluginSkills = installResult.PluginSkills
|
||||
registry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||
denied, policyErr := applyUserPolicyPruning(rootCmd, pluginRules)
|
||||
if policyErr != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, err)
|
||||
installPluginConflictGuard(rootCmd, policyErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
warnPolicyError(cfg.streams.ErrOut, policyErr)
|
||||
}
|
||||
|
||||
// The custom help command attaches AFTER policy evaluation on purpose:
|
||||
// it is a framework meta command, and inside the evaluated tree an
|
||||
// allow-list rule (Allow: ["im/**"]) would deny it as
|
||||
// domain_not_allowed — cobra's stock help command is likewise attached
|
||||
// only at Execute time and never evaluated.
|
||||
installHelpCommand(rootCmd)
|
||||
|
||||
// Presentation passes: a capability an integrator plugin denied
|
||||
// presents as absent — retired global flags (--profile), no skills
|
||||
// footer, diagnostics hidden or retired. Enforcement stays with
|
||||
// cmdpolicy.Apply above; these only shape help and fixed hints.
|
||||
applyPluginPresentation(rootCmd, installResult, denied)
|
||||
|
||||
// Resolve the embedded skill tree BEFORE wiring hooks: an invalid
|
||||
// SkillsOverlay must fail fast, before wireHooks emits Startup, so a Startup
|
||||
// side effect is never stranded without its Shutdown. Both skill readers
|
||||
// -- `skills list`/`read` and the --help guidance -- then read this one
|
||||
// f.SkillContent. Fails closed: never silently ship defaults once a
|
||||
// customization is declared.
|
||||
resolvedSkills, skillErr := resolveSkillContent(cfg, pluginSkills)
|
||||
if skillErr != nil {
|
||||
installPluginSkillErrorGuard(rootCmd, skillErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
f.SkillContent = resolvedSkills
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -564,3 +565,54 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The "no active profile" hint points at `lark-cli profile list`; that pointer
|
||||
// must be gated on the profile domain still being present. When a plugin denies
|
||||
// the profile domain, the hint would be a dead end, so it is omitted — the error
|
||||
// itself is unchanged.
|
||||
func TestConfigShowRun_ProfileHintGatedByPluginDenial(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
|
||||
hintOf := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Fatalf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
return cfgErr.Hint
|
||||
}
|
||||
|
||||
t.Run("profile domain present: hint points at profile list", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
if h := hintOf(t); !strings.Contains(h, "lark-cli profile list") {
|
||||
t.Errorf("hint = %q, want it to reference `lark-cli profile list`", h)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile domain plugin-denied: hint omitted", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"profile": true})
|
||||
if h := hintOf(t); h != "" {
|
||||
t.Errorf("hint = %q, want empty when the profile domain is plugin-denied", h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
if len(p.Rules) > 0 {
|
||||
entry["rules"] = p.Rules
|
||||
}
|
||||
if p.EmbeddedSkills != nil {
|
||||
entry["embedded_skills"] = p.EmbeddedSkills
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
|
||||
93
cmd/config/plugins_test.go
Normal file
93
cmd/config/plugins_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// config plugins show must surface a plugin's EmbeddedSkills contribution in
|
||||
// the rendered JSON, not only in the internal inventory struct: this command is
|
||||
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
|
||||
// Base summary has to reach stdout. Guards the render layer, which asserting the
|
||||
// inventory struct alone does not exercise.
|
||||
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
EmbeddedSkills: &internalplatform.SkillsOverlayView{
|
||||
Allow: []string{"lark-im"},
|
||||
Remove: []string{"lark-a"},
|
||||
Overlay: true,
|
||||
Base: true,
|
||||
},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Plugins []struct {
|
||||
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got.Plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
|
||||
}
|
||||
es := got.Plugins[0].EmbeddedSkills
|
||||
if es == nil {
|
||||
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
|
||||
}
|
||||
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
|
||||
!es.Overlay || !es.Base {
|
||||
t.Errorf("embedded_skills summary mismatch: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that did not customize embedded skills must not emit an
|
||||
// embedded_skills key, so the field's presence is a reliable signal that a fork
|
||||
// trimmed the tree.
|
||||
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("not json: %v", err)
|
||||
}
|
||||
plugins, ok := raw["plugins"].([]any)
|
||||
if !ok || len(plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin in output, got: %s", out.String())
|
||||
}
|
||||
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
|
||||
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -55,7 +56,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
e := errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile")
|
||||
// No recovery hint when the profile domain is absent from this build.
|
||||
if !policystate.DomainDeniedByPlugin("profile") {
|
||||
e = e.WithHint("run: lark-cli profile list")
|
||||
}
|
||||
return e
|
||||
}
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
109
cmd/flag_gate.go
Normal file
109
cmd/flag_gate.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
// globalFlagDomains maps each root persistent flag to the command domain
|
||||
// it belongs to. A new domain-tied global flag must add a row.
|
||||
var globalFlagDomains = map[string]string{
|
||||
"profile": "profile",
|
||||
}
|
||||
|
||||
// flagGateAnnotation distinguishes a policy-retired flag from one hidden
|
||||
// cosmetically (single-app mode force-shows the latter in root help).
|
||||
const flagGateAnnotation = "lark:policy_denied_flag"
|
||||
|
||||
// applyPluginFlagGate hides and rejects the global flags whose whole
|
||||
// domain a plugin denied. yaml denials do not gate flags. Must run after
|
||||
// RegisterGlobalFlags and cmdpolicy.Apply.
|
||||
func applyPluginFlagGate(root *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||
gated := false
|
||||
for flagName, domain := range globalFlagDomains {
|
||||
d, ok := denied[domain]
|
||||
if !ok || !cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
continue
|
||||
}
|
||||
fl := root.PersistentFlags().Lookup(flagName)
|
||||
if fl == nil {
|
||||
continue
|
||||
}
|
||||
fl.Hidden = true
|
||||
if fl.Annotations == nil {
|
||||
fl.Annotations = map[string][]string{}
|
||||
}
|
||||
fl.Annotations[flagGateAnnotation] = []string{"true"}
|
||||
gated = true
|
||||
}
|
||||
if gated {
|
||||
installFlagGateRejection(root)
|
||||
}
|
||||
}
|
||||
|
||||
func isPolicyGatedFlag(fl *pflag.Flag) bool {
|
||||
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
|
||||
}
|
||||
|
||||
// installFlagGateRejection rejects gated flags right after parsing.
|
||||
// pflag has no runtime unregister, so the flag still parses; and cobra
|
||||
// resolves PersistentPreRunE "first non-nil wins" walking up from the
|
||||
// leaf, so every command carrying its own (cmd/auth, cmd/config) must be
|
||||
// wrapped too, not just the root.
|
||||
func installFlagGateRejection(root *cobra.Command) {
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
if prev := c.PersistentPreRunE; prev != nil {
|
||||
c.PersistentPreRunE = func(cc *cobra.Command, args []string) error {
|
||||
if err := rejectGatedFlags(cc); err != nil {
|
||||
return err
|
||||
}
|
||||
return prev(cc, args)
|
||||
}
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
prevRun := root.PersistentPreRun
|
||||
root.PersistentPreRun = nil
|
||||
root.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
||||
if err := rejectGatedFlags(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if prevRun != nil {
|
||||
prevRun(c, args)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, child := range root.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
|
||||
// rejectGatedFlags fails a set policy-retired flag with the same shape an
|
||||
// unregistered flag produces (see flagDidYouMean).
|
||||
//
|
||||
// VisitAll + fl.Changed, not Visit: cobra parses a persistent flag on the leaf
|
||||
// command's merged flagset, so root.PersistentFlags()'s "changed" table stays
|
||||
// empty and Visit (which only walks that table) never fires on the dispatch
|
||||
// path. The flag objects are shared by pointer across the merge, so fl.Changed
|
||||
// reflects a real leaf-level parse.
|
||||
func rejectGatedFlags(c *cobra.Command) error {
|
||||
var rejected error
|
||||
c.Root().PersistentFlags().VisitAll(func(fl *pflag.Flag) {
|
||||
if rejected == nil && fl.Changed && isPolicyGatedFlag(fl) {
|
||||
rejected = errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+fl.Name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + fl.Name, Reason: "unknown flag"}).
|
||||
WithHint("run `%s --help` to see valid flags", c.CommandPath())
|
||||
}
|
||||
})
|
||||
return rejected
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -35,7 +36,15 @@ const userPolicyFileName = "policy.yml"
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
//
|
||||
// The returned denied map (nil when no rule denied anything) feeds the
|
||||
// post-pruning presentation passes in build.go: the global-flag gate and
|
||||
// the fixed-hint filter both key off which domains a plugin denied.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
|
||||
// Reset up front so every early return leaves the process-global
|
||||
// snapshot clean; the success path re-populates it.
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
|
||||
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||
@@ -65,7 +74,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return lerr
|
||||
return nil, lerr
|
||||
}
|
||||
yamlRules = loaded
|
||||
}
|
||||
@@ -77,11 +86,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// RuleName attributes a denial to a specific rule in the envelope.
|
||||
@@ -94,17 +103,39 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
ruleName = rules[0].Name
|
||||
}
|
||||
|
||||
// DeniedMessage is build-level: the first non-empty message across
|
||||
// the single owner's rules speaks for all of them.
|
||||
deniedMessage := ""
|
||||
for _, r := range rules {
|
||||
if r.DeniedMessage != "" {
|
||||
deniedMessage = r.DeniedMessage
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
engine := cmdpolicy.NewSet(rules)
|
||||
decisions := engine.EvaluateAll(rootCmd)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName, deniedMessage)
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
DeniedByPath: denied,
|
||||
})
|
||||
return nil
|
||||
|
||||
// Whole-domain denials surface as aggregate entries keyed by the
|
||||
// bare domain name (no slash); record them for render-time hint
|
||||
// emitters (internal/auth, internal/client, the notice provider).
|
||||
pluginDomains := map[string]bool{}
|
||||
for path, d := range denied {
|
||||
if !strings.Contains(path, "/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
pluginDomains[path] = true
|
||||
}
|
||||
}
|
||||
policystate.SetPluginDeniedDomains(pluginDomains)
|
||||
return denied, nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
@@ -156,7 +187,22 @@ func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
|
||||
for _, ps := range installResult.PluginSkills {
|
||||
if ps.SkillsOverlay == nil {
|
||||
continue
|
||||
}
|
||||
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
|
||||
PluginName: ps.PluginName,
|
||||
View: internalplatform.SkillsOverlayView{
|
||||
Allow: ps.SkillsOverlay.Allow,
|
||||
Remove: ps.SkillsOverlay.Remove,
|
||||
Overlay: ps.SkillsOverlay.Overlay != nil,
|
||||
Base: ps.SkillsOverlay.Base != nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
|
||||
@@ -116,7 +116,7 @@ max_risk: write
|
||||
`)
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("apply policy: %v", err)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
||||
tmpHome(t) // home set but no policy.yml written
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("missing policy should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("malformed yaml should produce an error")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid MaxRisk should produce an error")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
)
|
||||
|
||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||
@@ -110,6 +111,27 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
|
||||
// error before any command runs. Two failure modes, split by reason code:
|
||||
//
|
||||
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
|
||||
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
|
||||
//
|
||||
// The CLI must NOT silently fall back to default skills once an
|
||||
// integrator has declared a customization.
|
||||
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() error {
|
||||
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
|
||||
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
|
||||
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithHint("plugin skill customization is broken (reason_code %s); fix the plugin's SkillsOverlay or remove the conflicting plugin", reasonCode).
|
||||
WithCause(err)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a typed validation error (failed_precondition). The hint's
|
||||
// reason code splits returned-error vs panic so consumers (audit /
|
||||
|
||||
169
cmd/presentation.go
Normal file
169
cmd/presentation.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// applyPluginPresentation makes plugin-denied capabilities present as
|
||||
// absent: retired flags, no skills footer, diagnostics hidden or retired.
|
||||
// Presentation only — enforcement happened in cmdpolicy.Apply.
|
||||
func applyPluginPresentation(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||
applyPluginFlagGate(rootCmd, denied)
|
||||
|
||||
if domainDeniedByPlugin(denied, "skills") {
|
||||
rootCmd.SetUsageTemplate(strings.Replace(rootUsageTemplate, skillsSetupFooter, "", 1))
|
||||
}
|
||||
|
||||
if installResult != nil && hideDiagnosticsOwner(installResult.Plugins) != "" {
|
||||
retireDiagnostics(rootCmd, installResult, denied)
|
||||
} else {
|
||||
concealDiagnostics(rootCmd, denied)
|
||||
}
|
||||
}
|
||||
|
||||
// domainDeniedByPlugin reads the freshly-built denied map, unlike
|
||||
// policystate.DomainDeniedByPlugin which serves render-time consumers.
|
||||
func domainDeniedByPlugin(denied map[string]cmdpolicy.Denial, domain string) bool {
|
||||
d, ok := denied[domain]
|
||||
return ok && cmdpolicy.IsPluginPolicySource(d.PolicySource)
|
||||
}
|
||||
|
||||
// hideDiagnosticsOwner returns the plugin that declared HideDiagnostics,
|
||||
// or "". The host already enforced that it also Restricts.
|
||||
func hideDiagnosticsOwner(plugins []internalplatform.PluginInfo) string {
|
||||
for _, p := range plugins {
|
||||
if p.Capabilities.HideDiagnostics {
|
||||
return p.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// retireDiagnostics installs the unavailable presentation on the
|
||||
// diagnostic exemptions, same as any other plugin-denied command.
|
||||
func retireDiagnostics(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||
source := "plugin:" + hideDiagnosticsOwner(installResult.Plugins)
|
||||
message := ""
|
||||
for _, d := range denied {
|
||||
if cmdpolicy.IsPluginPolicySource(d.PolicySource) && d.DeniedMessage != "" {
|
||||
message = d.DeniedMessage
|
||||
break
|
||||
}
|
||||
}
|
||||
diag := map[string]cmdpolicy.Denial{}
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
diag[path] = cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: source,
|
||||
ReasonCode: "diagnostics_hidden",
|
||||
Reason: "policy self-inspection hidden by the integrator",
|
||||
DeniedMessage: message,
|
||||
}
|
||||
}
|
||||
cmdpolicy.Apply(rootCmd, diag)
|
||||
}
|
||||
|
||||
// concealDiagnostics hides the diagnostic exemptions from help — without
|
||||
// touching their RunE, so they stay dispatchable — once every non-exempt
|
||||
// sibling in their domain is already hidden. The group itself then gets
|
||||
// the unavailable stub: it escaped denial aggregation only because the
|
||||
// exemptions kept a runnable descendant alive, and its unknown-subcommand
|
||||
// guard RunE would otherwise keep it listed in help.
|
||||
func concealDiagnostics(rootCmd *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||
for _, group := range diagnosticDomainGroups(rootCmd) {
|
||||
if !pluginDeniedUnder(denied, cmdpolicy.CanonicalPath(group)) {
|
||||
continue
|
||||
}
|
||||
exemptAncestors := map[*cobra.Command]bool{}
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
for c := findByPath(rootCmd, path); c != nil && c != group; c = c.Parent() {
|
||||
exemptAncestors[c] = true
|
||||
}
|
||||
}
|
||||
allOthersHidden := true
|
||||
for _, child := range group.Commands() {
|
||||
if child.Name() == "help" || exemptAncestors[child] {
|
||||
continue
|
||||
}
|
||||
if !child.Hidden {
|
||||
allOthersHidden = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allOthersHidden {
|
||||
continue
|
||||
}
|
||||
for c := range exemptAncestors {
|
||||
c.Hidden = true
|
||||
}
|
||||
var sample cmdpolicy.Denial
|
||||
for path, d := range denied {
|
||||
if strings.HasPrefix(path, cmdpolicy.CanonicalPath(group)+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
sample = d
|
||||
break
|
||||
}
|
||||
}
|
||||
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
|
||||
cmdpolicy.CanonicalPath(group): {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: sample.PolicySource,
|
||||
RuleName: sample.RuleName,
|
||||
ReasonCode: "all_children_denied",
|
||||
Reason: "all child commands are denied",
|
||||
DeniedMessage: sample.DeniedMessage,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// diagnosticDomainGroups returns the top-level groups containing a
|
||||
// diagnostic exemption (today just `config`).
|
||||
func diagnosticDomainGroups(rootCmd *cobra.Command) []*cobra.Command {
|
||||
seen := map[*cobra.Command]bool{}
|
||||
var out []*cobra.Command
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
top, _, _ := strings.Cut(path, "/")
|
||||
if c := findByPath(rootCmd, top); c != nil && !seen[c] {
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginDeniedUnder(denied map[string]cmdpolicy.Denial, prefix string) bool {
|
||||
for path, d := range denied {
|
||||
if strings.HasPrefix(path, prefix+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findByPath resolves a canonical slash path (e.g. "config/policy/show")
|
||||
// to the command node, or nil.
|
||||
func findByPath(rootCmd *cobra.Command, path string) *cobra.Command {
|
||||
cur := rootCmd
|
||||
for _, seg := range strings.Split(path, "/") {
|
||||
var next *cobra.Command
|
||||
for _, child := range cur.Commands() {
|
||||
if child.Name() == seg {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
397
cmd/presentation_test.go
Normal file
397
cmd/presentation_test.go
Normal file
@@ -0,0 +1,397 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
// restrictingPlugin registers a plugin that denies the given globs; extra
|
||||
// customizes the builder further (nil for none).
|
||||
func restrictingPlugin(t *testing.T, deny []string, extra func(*platform.Builder) *platform.Builder) {
|
||||
t.Helper()
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
b := platform.NewPlugin("acme", "1.0").Restrict(&platform.Rule{Deny: deny})
|
||||
if extra != nil {
|
||||
b = extra(b)
|
||||
}
|
||||
platform.Register(b.MustBuild())
|
||||
}
|
||||
|
||||
// runnableUnder returns the first runnable non-exempt descendant under
|
||||
// the named top-level group of the real command tree (the diagnostic
|
||||
// exemptions keep their original RunE and would not exercise the stub).
|
||||
func runnableUnder(t *testing.T, root *cobra.Command, group string) *cobra.Command {
|
||||
t.Helper()
|
||||
g := findByPath(root, group)
|
||||
if g == nil {
|
||||
t.Fatalf("group %q not found in command tree", group)
|
||||
}
|
||||
var find func(c *cobra.Command) *cobra.Command
|
||||
find = func(c *cobra.Command) *cobra.Command {
|
||||
if c.RunE != nil && len(c.Commands()) == 0 && !cmdpolicy.IsDiagnosticPath(cmdpolicy.CanonicalPath(c)) {
|
||||
return c
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
if leaf := find(child); leaf != nil {
|
||||
return leaf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
leaf := find(g)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable non-exempt leaf under %q", group)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
// A plugin-denied command answers with command_unavailable: default
|
||||
// message, no hint, no policy vocabulary.
|
||||
func TestBuildInternal_pluginDenyPresentsUnavailable(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := runnableUnder(t, root, "config")
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("subtype = %q, want command_unavailable", ve.Subtype)
|
||||
}
|
||||
if ve.Message != cmdpolicy.DefaultUnavailableMessage || ve.Hint != "" {
|
||||
t.Errorf("message=%q hint=%q, want default message and empty hint", ve.Message, ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule.DeniedMessage speaks in the integrator's product voice.
|
||||
func TestBuildInternal_deniedMessageOverride(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
Restrict(&platform.Rule{Deny: []string{"config/**"}, DeniedMessage: "not part of acme cli"}).
|
||||
MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := runnableUnder(t, root, "config")
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Message != "not part of acme cli" {
|
||||
t.Errorf("message = %q, want the integrator's DeniedMessage", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit help on a plugin-denied command must not render the original
|
||||
// usage; it answers with the same unavailable envelope.
|
||||
func TestBuildInternal_pluginDenyHelpIntercepted(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := runnableUnder(t, root, "config")
|
||||
|
||||
var buf bytes.Buffer
|
||||
leaf.SetOut(&buf)
|
||||
leaf.SetErr(&buf)
|
||||
root.HelpFunc()(leaf, nil)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "command_unavailable") {
|
||||
t.Errorf("explicit help must answer command_unavailable, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "Usage:") {
|
||||
t.Errorf("explicit help must not render the original usage, got:\n%s", out)
|
||||
}
|
||||
|
||||
// yaml-source presentation is untouched: a command outside the denied
|
||||
// domain still renders normal help.
|
||||
var normal bytes.Buffer
|
||||
alive := findByPath(root, "skills")
|
||||
alive.SetOut(&normal)
|
||||
alive.SetErr(&normal)
|
||||
root.HelpFunc()(alive, nil)
|
||||
if strings.Contains(normal.String(), "command_unavailable") {
|
||||
t.Errorf("non-denied command help must render normally, got:\n%s", normal.String())
|
||||
}
|
||||
}
|
||||
|
||||
// `lark-cli help <plugin-restricted-cmd>` must fail with the typed
|
||||
// unavailable error (exit non-zero), not print an envelope and exit 0.
|
||||
func TestBuildInternal_helpCommandReturnsUnavailable(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
helpCmd := findByPath(root, "help")
|
||||
if helpCmd == nil || helpCmd.RunE == nil {
|
||||
t.Fatal("custom help command not installed")
|
||||
}
|
||||
|
||||
err := helpCmd.RunE(helpCmd, []string{"config"})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("help on a restricted command must return command_unavailable, got %v", err)
|
||||
}
|
||||
|
||||
// A live target renders normally and returns nil.
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(&buf)
|
||||
if err := helpCmd.RunE(helpCmd, []string{"skills"}); err != nil {
|
||||
t.Errorf("help on a live command must succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// help is a framework meta command: an allow-list rule must not deny it.
|
||||
// `help <live-cmd>` renders; `help <denied-cmd>` returns unavailable.
|
||||
func TestBuildInternal_helpSurvivesAllowList(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
Restrict(&platform.Rule{Allow: []string{"im/**"}, AllowUnannotated: true}).
|
||||
MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
helpCmd := findByPath(root, "help")
|
||||
if helpCmd == nil || helpCmd.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
|
||||
t.Fatalf("help must not be policy-denied under an allow-list; annotations=%v", helpCmd.Annotations)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(&buf)
|
||||
if err := helpCmd.RunE(helpCmd, []string{"im"}); err != nil {
|
||||
t.Errorf("help on an allowed domain must render, got %v", err)
|
||||
}
|
||||
|
||||
err := helpCmd.RunE(helpCmd, []string{"config"})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("help on a denied domain must return command_unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin inventory surfaces the new contributions so `config plugins
|
||||
// show` can answer "what did this build customize".
|
||||
func TestBuildInternal_inventoryCoversNewCapabilities(t *testing.T) {
|
||||
tmpHome(t)
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
restrictingPlugin(t, []string{"profile/**"}, func(b *platform.Builder) *platform.Builder {
|
||||
return b.HideDiagnostics().
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
|
||||
})
|
||||
|
||||
buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
inv := internalplatform.GetActiveInventory()
|
||||
if inv == nil || len(inv.Plugins) != 1 {
|
||||
t.Fatalf("inventory = %+v, want 1 plugin", inv)
|
||||
}
|
||||
p := inv.Plugins[0]
|
||||
if !p.Capabilities.HideDiagnostics {
|
||||
t.Error("inventory must surface HideDiagnostics")
|
||||
}
|
||||
if p.EmbeddedSkills == nil || len(p.EmbeddedSkills.Remove) != 1 || p.EmbeddedSkills.Remove[0] != "lark-a" {
|
||||
t.Errorf("inventory must summarise EmbeddedSkills, got %+v", p.EmbeddedSkills)
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the whole profile domain retires --profile: hidden from help,
|
||||
// and setting it fails like an unknown flag.
|
||||
func TestBuildInternal_profileFlagGate(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
fl := root.PersistentFlags().Lookup("profile")
|
||||
if fl == nil {
|
||||
t.Fatal("--profile not registered")
|
||||
}
|
||||
if !fl.Hidden || !isPolicyGatedFlag(fl) {
|
||||
t.Errorf("flag should be hidden and policy-gated; hidden=%v gated=%v", fl.Hidden, isPolicyGatedFlag(fl))
|
||||
}
|
||||
|
||||
// Setting the flag (as cobra's parse would) must be rejected as unknown.
|
||||
if err := root.PersistentFlags().Set("profile", "prod"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
err := rejectGatedFlags(root)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||
t.Errorf("setting a gated flag must fail as unknown flag, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must also fire on the real dispatch path. cobra parses a persistent
|
||||
// flag on the leaf command's merged flagset, so a gate that walks
|
||||
// root.PersistentFlags()'s changed table is a no-op once the flag is passed to
|
||||
// a subcommand. Drive root.Execute end to end and confirm the gated --profile
|
||||
// is rejected before the command body runs.
|
||||
func TestBuildInternal_profileFlagGate_RejectsOnDispatch(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
ranBody := false
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "gateprobe",
|
||||
RunE: func(*cobra.Command, []string) error { ranBody = true; return nil },
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(&buf)
|
||||
root.SetArgs([]string{"--profile", "prod", "gateprobe"})
|
||||
|
||||
err := root.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||
t.Errorf("a gated --profile passed on the dispatch path must fail as unknown flag, got %v", err)
|
||||
}
|
||||
if ranBody {
|
||||
t.Error("gated --profile must be rejected before the command body runs")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the profile domain denied, the gate stays inert.
|
||||
func TestBuildInternal_profileFlagUntouchedWithoutDenial(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if fl := root.PersistentFlags().Lookup("profile"); isPolicyGatedFlag(fl) {
|
||||
t.Error("--profile must not be gated when its domain is not denied")
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the skills domain drops the root-help skills-setup footer.
|
||||
func TestBuildInternal_skillsFooterSuppressed(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if strings.Contains(root.UsageTemplate(), "Skills setup") {
|
||||
t.Error("skills-setup footer must be dropped when the skills domain is denied")
|
||||
}
|
||||
|
||||
// Control: without the denial the footer stays.
|
||||
platform.ResetForTesting()
|
||||
policystate.ResetForTesting()
|
||||
_, root2, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if !strings.Contains(root2.UsageTemplate(), "Skills setup") {
|
||||
t.Error("skills-setup footer must stay in the default build")
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the skills command domain kills every `skills read` pointer in
|
||||
// domain help, even when the skill content itself is still embedded — the
|
||||
// command the pointers name is absent, so they would all be dead ends.
|
||||
func TestBuildInternal_skillsDomainDenyKillsHelpPointers(t *testing.T) {
|
||||
tmpHome(t)
|
||||
withBaseSkills(t, map[string]string{"lark-im/SKILL.md": "---\ndescription: im\n---\n"})
|
||||
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
im := findByPath(root, "im")
|
||||
if im == nil {
|
||||
t.Fatal("im domain not in tree")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
im.SetOut(&buf)
|
||||
im.SetErr(&buf)
|
||||
root.HelpFunc()(im, nil)
|
||||
if strings.Contains(buf.String(), "skills read") {
|
||||
t.Errorf("domain help must not point at the denied skills command, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the update domain silences the _notice providers that would
|
||||
// steer the caller to `lark-cli update`.
|
||||
func TestComposePendingNotice_updateDomainDenied(t *testing.T) {
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "1.0.1"})
|
||||
t.Cleanup(func() { update.SetPending(nil) })
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"update": true})
|
||||
if got := composePendingNotice(); got != nil {
|
||||
t.Errorf("notices must be silenced with the update domain denied, got %+v", got)
|
||||
}
|
||||
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
if got := composePendingNotice(); got == nil {
|
||||
t.Error("notices must render without the denial")
|
||||
}
|
||||
}
|
||||
|
||||
// Default: diagnostics stay executable but leave help when their whole
|
||||
// domain is denied — cobra then drops the empty config group entirely.
|
||||
func TestBuildInternal_concealDiagnostics(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
show := findByPath(root, "config/policy/show")
|
||||
if show == nil {
|
||||
t.Fatal("config policy show not in tree")
|
||||
}
|
||||
if !show.Hidden {
|
||||
t.Error("exempt diagnostic should be hidden from help when its domain is denied")
|
||||
}
|
||||
// Still dispatchable: its RunE is the original, not an unavailable stub.
|
||||
if show.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
|
||||
t.Error("exempt diagnostic must not carry a denial stub by default")
|
||||
}
|
||||
if cfg := findByPath(root, "config"); cfg.IsAvailableCommand() {
|
||||
t.Error("config group with no visible children must drop from help")
|
||||
}
|
||||
}
|
||||
|
||||
// HideDiagnostics retires the exemptions like any other denied command.
|
||||
func TestBuildInternal_hideDiagnostics(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
|
||||
return b.HideDiagnostics()
|
||||
})
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
show := findByPath(root, "config/policy/show")
|
||||
if show == nil {
|
||||
t.Fatal("config policy show not in tree")
|
||||
}
|
||||
err := show.RunE(show, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("hidden diagnostic must answer command_unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
@@ -105,8 +106,14 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
hint := fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)
|
||||
// The switch-policy pointer names `config strict-mode`; with
|
||||
// the config domain plugin-denied it would be a dead end.
|
||||
if !policystate.DomainDeniedByPlugin("config") {
|
||||
hint += "; " + stubHint
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
||||
WithHint("%s", hint).
|
||||
WithCause(cd)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -379,3 +380,41 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
t.Errorf("denial annotation overwritten or missing")
|
||||
}
|
||||
}
|
||||
|
||||
// The strict-mode stub's RunE appends a `config strict-mode` switch-policy
|
||||
// pointer to its hint — but only when the config domain is still present. With
|
||||
// the config domain plugin-denied that pointer is a dead end, so it is omitted;
|
||||
// the denial itself (message, subtype) is unchanged.
|
||||
func TestStrictModeStub_ConfigHintGatedByPluginDenial(t *testing.T) {
|
||||
hintOf := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
stub := strictModeStubFrom(child, core.StrictModeBot)
|
||||
err := stub.RunE(stub, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
return verr.Hint
|
||||
}
|
||||
|
||||
t.Run("config domain present: switch-policy pointer included", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
if h := hintOf(t); !strings.Contains(h, "config strict-mode") {
|
||||
t.Errorf("hint = %q, want it to reference `config strict-mode`", h)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config domain plugin-denied: switch-policy pointer omitted", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"config": true})
|
||||
if h := hintOf(t); strings.Contains(h, "config strict-mode") {
|
||||
t.Errorf("hint = %q, want no `config strict-mode` pointer when config is plugin-denied", h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
93
cmd/root.go
93
cmd/root.go
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -80,11 +82,16 @@ Global Flags:
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}` + skillsSetupFooter + `
|
||||
`
|
||||
|
||||
// skillsSetupFooter is the root-help pointer at the human one-time skills
|
||||
// setup. Split out so the presentation pass can drop it from the template
|
||||
// when an integrator plugin denies the skills domain.
|
||||
const skillsSetupFooter = `{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
@@ -167,6 +174,10 @@ func setupNotices() {
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
// All three notices steer the caller to `lark-cli update`.
|
||||
if policystate.DomainDeniedByPlugin("update") {
|
||||
return nil
|
||||
}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
@@ -658,16 +669,80 @@ func visibleFlagNames(c *cobra.Command) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// installHelpCommand replaces cobra's default help command so that
|
||||
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
|
||||
// instead of printing an envelope and exiting 0 — cobra's stock help
|
||||
// command has no error channel.
|
||||
func installHelpCommand(root *cobra.Command) {
|
||||
helpCmd := &cobra.Command{
|
||||
Use: "help [command]",
|
||||
Short: "Help about any command",
|
||||
Long: "Help provides help for any command in the application.",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
target, _, err := root.Find(args)
|
||||
if err != nil || target == nil {
|
||||
c.Printf("Unknown help topic %#q\n", args)
|
||||
return root.Usage()
|
||||
}
|
||||
if msg, ok := unavailableHelpMessage(target); ok {
|
||||
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
|
||||
}
|
||||
target.InitDefaultHelpFlag()
|
||||
return target.Help()
|
||||
},
|
||||
}
|
||||
// help attaches after policy evaluation (framework meta command, never
|
||||
// policy-evaluated); the read annotation is defensive in case a future
|
||||
// pass re-evaluates the finished tree.
|
||||
cmdutil.SetRisk(helpCmd, "read")
|
||||
cmdutil.DisableAuthCheck(helpCmd)
|
||||
root.SetHelpCommand(helpCmd)
|
||||
// SetHelpCommand alone defers attachment to Execute's
|
||||
// InitDefaultHelpCmd; add it now so the built tree is complete
|
||||
// (InitDefaultHelpCmd re-adds idempotently).
|
||||
root.AddCommand(helpCmd)
|
||||
}
|
||||
|
||||
// unavailableHelpMessage returns the message to render in place of help
|
||||
// for a plugin-restricted command. yaml-source denials keep original help.
|
||||
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerPolicy ||
|
||||
!cmdpolicy.IsPluginPolicySource(cmd.Annotations[cmdpolicy.AnnotationDenialSource]) {
|
||||
return "", false
|
||||
}
|
||||
if msg := cmd.Annotations[cmdpolicy.AnnotationDenialMessage]; msg != "" {
|
||||
return msg, true
|
||||
}
|
||||
return cmdpolicy.DefaultUnavailableMessage, true
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
// when rendering the root command's own help, so users discovering the CLI
|
||||
// still see them at `lark-cli --help`.
|
||||
func installTipsHelpFunc(root *cobra.Command) {
|
||||
//
|
||||
// skillContent is read lazily at help-render time (not captured up front) so
|
||||
// the domain-guide pointer reflects the resolved skill tree -- the same
|
||||
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
|
||||
// customization is applied after this help func is installed.
|
||||
func installTipsHelpFunc(root *cobra.Command, skillContent func() fs.FS) {
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
// Explicit help on a plugin-restricted command answers the same
|
||||
// unavailable envelope as its RunE stub, not the original usage.
|
||||
if msg, ok := unavailableHelpMessage(cmd); ok {
|
||||
output.WriteTypedErrorEnvelope(cmd.ErrOrStderr(),
|
||||
errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg), "")
|
||||
return
|
||||
}
|
||||
if cmd == root {
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
||||
// Force-show flags hidden by single-app mode; never a
|
||||
// policy-retired one.
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
|
||||
f.Hidden = false
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
@@ -675,11 +750,15 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
// 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) {
|
||||
if service.PrepareDomainHelp(cmd, skillContent()) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
if service.PrepareMethodHelp(cmd, skillContent()) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, skillContent()) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||
{
|
||||
Name: "fixture",
|
||||
ServicePath: "/open-apis/fixture/v1",
|
||||
Resources: map[string]meta.Resource{
|
||||
"things": {
|
||||
Methods: map[string]meta.Method{
|
||||
"create": {
|
||||
Path: "things",
|
||||
HTTPMethod: "POST",
|
||||
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||
RequestBody: map[string]meta.Field{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -12,6 +13,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// nilSkills is the skill-content getter used by help-func tests that do
|
||||
// not exercise the domain-guide pointer.
|
||||
func nilSkills() fs.FS { return nil }
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
@@ -24,7 +29,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
@@ -38,7 +43,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
@@ -51,7 +56,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
|
||||
@@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// hand-authored Long when present, else the Short.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return captureHelpBase(cmd, domainBaseAnnotation)
|
||||
}
|
||||
|
||||
// captureHelpBase records a command's pristine lead text once — its
|
||||
// hand-authored Long, or Short when Long is empty — into the given annotation,
|
||||
// so lazy re-renders compose onto the original text instead of onto an
|
||||
// already-augmented Long. This is what lets a shortcut's PostMount-authored
|
||||
// Long survive: it becomes the base the affordance block is appended below.
|
||||
func captureHelpBase(cmd *cobra.Command, key string) string {
|
||||
if base, ok := cmd.Annotations[key]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
cmd.Annotations[key] = base
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
|
||||
}
|
||||
|
||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
@@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmdmeta.SetAffordanceRef(cmd, service, methodID)
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
@@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
// 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 {
|
||||
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
|
||||
// pointers: each is emitted only when it resolves in the skill tree (see
|
||||
// affordance.SkillStatPath), so a typo or a build without embedded skills never
|
||||
// prints a `skills read` that cannot be opened.
|
||||
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
@@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
writeRisk(&b, cmd)
|
||||
|
||||
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 {
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
@@ -164,17 +164,95 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
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)
|
||||
}
|
||||
}
|
||||
writeRelatedSkills(&b, skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||
// overlay — the same top layout as method help (description, Risk, guidance
|
||||
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
// read the skill" directive) keeps it — the affordance block is appended below,
|
||||
// never clobbering it.
|
||||
//
|
||||
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
|
||||
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
|
||||
// the overlay declares none; when the overlay has tips, the Go tips are dropped
|
||||
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
|
||||
// therefore silently retires that shortcut's Go Tips — consolidate into one.
|
||||
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
a.Tips = cmdutil.GetTips(cmd)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
|
||||
writeRisk(&b, cmd)
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
|
||||
// high-risk-write commands. A no-op when the command has no risk annotation.
|
||||
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
|
||||
level, ok := cmdutil.GetRisk(cmd)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// --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)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRelatedSkills appends the "Related skills" block for the entries that
|
||||
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
|
||||
// so help never prints a `skills read` pointer that cannot be opened.
|
||||
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
|
||||
if skillFS == nil || len(skills) == 0 {
|
||||
return
|
||||
}
|
||||
var avail []string
|
||||
for _, s := range skills {
|
||||
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
|
||||
avail = append(avail, s)
|
||||
}
|
||||
}
|
||||
if len(avail) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
|
||||
for _, s := range avail {
|
||||
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return renderAffordanceValue(a)
|
||||
}
|
||||
|
||||
// renderAffordanceValue renders an already-parsed affordance. Split from
|
||||
// renderAffordance so callers can render a value they have adjusted first (e.g.
|
||||
// a shortcut folding its declarative tips into an overlay that has none).
|
||||
func renderAffordanceValue(a meta.Affordance) string {
|
||||
var sections []string
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -70,8 +71,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\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" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
|
||||
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
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) {
|
||||
if !PrepareMethodHelp(cmd, nil) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
@@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
|
||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
|
||||
// top layout as method help (no schema pointer), folding declarative tips when
|
||||
// the overlay declares none, and leaves shortcuts without an overlay entry (and
|
||||
// non-shortcut commands) for the default help path.
|
||||
func TestPrepareShortcutHelp(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||
if service == "calendar" && methodID == "+create" {
|
||||
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||
cmdutil.SetRisk(sc, "write")
|
||||
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
|
||||
|
||||
if !PrepareShortcutHelp(sc, nil) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||
}
|
||||
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
|
||||
if !strings.Contains(sc.Long, want) {
|
||||
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sc.Long, "Full parameter schema:") {
|
||||
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
|
||||
}
|
||||
|
||||
// No overlay entry -> leave it for the default help path.
|
||||
bare := &cobra.Command{Use: "+bare", Short: "x"}
|
||||
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
|
||||
if PrepareShortcutHelp(bare, nil) {
|
||||
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
|
||||
}
|
||||
|
||||
// Non-shortcut source is ignored even with a ref.
|
||||
notSc := &cobra.Command{Use: "create", Short: "x"}
|
||||
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
|
||||
if PrepareShortcutHelp(notSc, nil) {
|
||||
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
|
||||
}
|
||||
}
|
||||
|
||||
// Related-skill pointers are gated on existence: a skill that resolves in the
|
||||
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
|
||||
// and a nil skill FS suppresses the whole block.
|
||||
func TestRelatedSkillsStatGating(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
|
||||
}
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-real/SKILL.md": {Data: []byte("# real")},
|
||||
"lark-real/references/deep.md": {Data: []byte("# deep")},
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
|
||||
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
if !PrepareMethodHelp(cmd, skillFS) {
|
||||
t.Fatal("PrepareMethodHelp returned false")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
|
||||
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "lark-typo") {
|
||||
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
|
||||
}
|
||||
// A name/relpath reference to an existing file renders; a missing one drops.
|
||||
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
|
||||
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "references/missing.md") {
|
||||
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
|
||||
}
|
||||
|
||||
// nil skill FS: the whole Related-skills block is suppressed.
|
||||
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
PrepareMethodHelp(bare, nil)
|
||||
if strings.Contains(bare.Long, "Related skills") {
|
||||
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
|
||||
// PostMount) keeps it as the lead: the affordance block is appended below, not
|
||||
// clobbered, and re-rendering does not double-append.
|
||||
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
|
||||
}
|
||||
|
||||
const authored = "Custom docs help. AI agents MUST read the skill first."
|
||||
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||
|
||||
if !PrepareShortcutHelp(sc, nil) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||
}
|
||||
if !strings.HasPrefix(sc.Long, authored) {
|
||||
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
|
||||
}
|
||||
if !strings.Contains(sc.Long, "When to use:") {
|
||||
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
|
||||
}
|
||||
// Re-render must reuse the captured base, not append the block twice.
|
||||
PrepareShortcutHelp(sc, nil)
|
||||
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
|
||||
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
@@ -174,6 +297,26 @@ func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||
}
|
||||
|
||||
// A service domain carries only a Short at help time; it seeds the base.
|
||||
// The domain-guide pointer is likewise gated: removing the domain's skill
|
||||
// drops the pointer instead of leaving it dangling.
|
||||
func TestPrepareDomainHelp_GatesGuidePointerOnFS(t *testing.T) {
|
||||
present := domainCmd("Consume and manage real-time events", "")
|
||||
if !PrepareDomainHelp(present, fstest.MapFS{"lark-event/SKILL.md": &fstest.MapFile{Data: []byte("x")}}) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.Contains(present.Long, "lark-cli skills read lark-event") {
|
||||
t.Errorf("skill present should emit the domain-guide pointer; got:\n%s", present.Long)
|
||||
}
|
||||
|
||||
removed := domainCmd("Consume and manage real-time events", "")
|
||||
if !PrepareDomainHelp(removed, fstest.MapFS{}) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if strings.Contains(removed.Long, "skills read lark-event") {
|
||||
t.Errorf("removed skill must leave no domain-guide pointer; got:\n%s", removed.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
dom := domainCmd("Message and group chat management", "")
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1132,6 +1136,63 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultipartFilenames drives one service-method --file upload through the
|
||||
// mock transport and returns a map of field name -> part filename parsed from
|
||||
// the captured multipart body. Mirrors cmd/api's helper of the same name
|
||||
// (inlined here rather than shared, since the two live in different packages)
|
||||
// to give BuildFormdata's shared local-file fix a second real entry-point
|
||||
// covering it.
|
||||
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
|
||||
t.Helper()
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||
}
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||
}
|
||||
filenames := map[string]string{}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if fn := part.FileName(); fn != "" {
|
||||
filenames[part.FormName()] = fn
|
||||
}
|
||||
}
|
||||
return filenames
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["image"]; got != "photo.jpg" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
|
||||
227
cmd/skill_customization_test.go
Normal file
227
cmd/skill_customization_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/skillcontent"
|
||||
)
|
||||
|
||||
// withBaseSkills swaps the process-global embedded skill tree for the
|
||||
// duration of a test, restoring it afterward.
|
||||
func withBaseSkills(t *testing.T, files map[string]string) {
|
||||
t.Helper()
|
||||
base := fstest.MapFS{}
|
||||
for p, content := range files {
|
||||
base[p] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
saved := embeddedSkillContent
|
||||
t.Cleanup(func() { embeddedSkillContent = saved })
|
||||
embeddedSkillContent = base
|
||||
}
|
||||
|
||||
// A plugin's SkillsOverlay must reshape the tree the factory serves: skills
|
||||
// list/read read f.SkillContent, so a resolved removal/overlay shows up here.
|
||||
// (The --help pointers are gated on the same f.SkillContent; that gating is
|
||||
// covered by the PrepareDomainHelp/PrepareMethodHelp tests in cmd/service.)
|
||||
func TestBuildInternal_appliesPluginSkillsOverlay(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
|
||||
})
|
||||
|
||||
overlay := fstest.MapFS{
|
||||
"lark-new/SKILL.md": &fstest.MapFile{Data: []byte("---\ndescription: new\n---\n")},
|
||||
}
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{
|
||||
Remove: []string{"lark-shared"},
|
||||
Overlay: overlay,
|
||||
}).MustBuild())
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if f.SkillContent == nil {
|
||||
t.Fatal("f.SkillContent is nil after skill resolution")
|
||||
}
|
||||
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a,lark-b,lark-new" {
|
||||
t.Errorf("skills = %q, want lark-a,lark-b,lark-new (shared removed, new added)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two plugins each customizing skills must abort at dispatch with a
|
||||
// structured envelope carrying reason_code multiple_skills_overlay_plugins, not
|
||||
// silently fall back to the default tree.
|
||||
func TestBuildInternal_multipleSkillPluginsGuard(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
platform.Register(platform.NewPlugin("globex", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("skill conflict guard path should yield nil registry")
|
||||
}
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
|
||||
t.Errorf("hint should surface reason_code multiple_skills_overlay_plugins, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow keeps only the listed skills from the base — a CLI upgrade adding
|
||||
// new embedded skills cannot widen an allow-listed build.
|
||||
func TestBuildInternal_appliesAllowList(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||
"lark-c/SKILL.md": "---\ndescription: c\n---\n",
|
||||
})
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Allow: []string{"lark-a", "lark-c"}}).
|
||||
MustBuild())
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a,lark-c" {
|
||||
t.Errorf("skills = %q, want lark-a,lark-c (allow-list)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin whose SkillsOverlay cannot compose (Remove naming a skill absent
|
||||
// from the base) must abort with reason_code invalid_skills_overlay.
|
||||
func TestBuildInternal_invalidSkillsOverlayGuard(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-does-not-exist"}}).MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
|
||||
t.Errorf("hint should surface reason_code invalid_skills_overlay, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// WithEmbeddedSkills customizes skills for a caller that builds the tree directly
|
||||
// (no plugin) — the build-option analogue of a plugin's EmbeddedSkills(...).
|
||||
func TestBuildInternal_withSkillsOption(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
|
||||
})
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t),
|
||||
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}}))
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a" {
|
||||
t.Errorf("skills = %q, want lark-a (shared removed via WithEmbeddedSkills)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// WithEmbeddedSkills and a plugin's EmbeddedSkills() are two owners of skill content; the
|
||||
// single-owner rule aborts startup.
|
||||
func TestBuildInternal_withSkillsPlusPluginConflicts(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t),
|
||||
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}))
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
|
||||
t.Errorf("WithEmbeddedSkills + plugin should conflict; hint = %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -86,13 +86,10 @@ func symArrow() string {
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
SkillsLayout string
|
||||
FlatSkills string
|
||||
FlatSet bool
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
@@ -112,7 +109,6 @@ Detects the installation method automatically:
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.FlatSet = cmd.Flags().Changed("flat-skills")
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
@@ -120,8 +116,6 @@ Use --check to only check for updates without installing.`,
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate or hybrid")
|
||||
cmd.Flags().StringVar(&opts.FlatSkills, "flat-skills", "", "comma-separated skills kept as top-level skills when the effective layout is hybrid")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
@@ -129,9 +123,6 @@ Use --check to only check for updates without installing.`,
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
if err := validateSkillsLayoutOptions(opts); err != nil {
|
||||
return reportError(opts, io, "validation_error", err)
|
||||
}
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
@@ -157,7 +148,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -218,7 +209,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -237,9 +228,9 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\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", selfupdate.NpmPackage, latest)
|
||||
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)
|
||||
return nil
|
||||
@@ -308,7 +299,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -350,25 +341,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) str
|
||||
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, 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): 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, 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))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout, requestedFlat string, flatSet bool) *skillscheck.SyncResult {
|
||||
layout, flat := resolveSkillsSyncOptions(requestedLayout, requestedFlat, flatSet)
|
||||
layoutExplicit := strings.TrimSpace(requestedLayout) != ""
|
||||
if !force && !layoutExplicit && !flatSet {
|
||||
if existing, existingLayout, ok := skillscheck.ReadSyncedVersionAndLayout(); ok && existingLayout != "" && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Layout: layout,
|
||||
FlatSkills: flat,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
@@ -376,38 +363,6 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
return result
|
||||
}
|
||||
|
||||
func validateSkillsLayoutOptions(opts *UpdateOptions) errs.TypedError {
|
||||
if _, ok := skillscheck.NormalizeLayout(opts.SkillsLayout); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or hybrid").WithParam("--skills-layout")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSkillsSyncOptions(requestedLayout, requestedFlat string, flatSet bool) (string, []string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil {
|
||||
readable = false
|
||||
state = nil
|
||||
}
|
||||
|
||||
layout := skillscheck.LayoutSeparate
|
||||
if strings.TrimSpace(requestedLayout) != "" {
|
||||
layout, _ = skillscheck.NormalizeLayout(requestedLayout)
|
||||
} else if readable && state != nil {
|
||||
if stateLayout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" {
|
||||
layout = stateLayout
|
||||
}
|
||||
}
|
||||
|
||||
if flatSet {
|
||||
return layout, skillscheck.ParseFlatSkills(requestedFlat)
|
||||
}
|
||||
if readable && state != nil {
|
||||
return layout, state.FlatSkills
|
||||
}
|
||||
return layout, []string{}
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
@@ -454,12 +409,6 @@ func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
if state.Layout != "" {
|
||||
status["layout"] = state.Layout
|
||||
}
|
||||
if len(state.FlatSkills) > 0 {
|
||||
status["flat_skills"] = state.FlatSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
@@ -470,7 +419,6 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
env["skills_hint"] = skillsFailureHint()
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
@@ -484,17 +432,10 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
"layout": r.Layout,
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
if len(r.Collected) > 0 {
|
||||
summary["collected"] = r.Collected
|
||||
}
|
||||
if len(r.Flat) > 0 {
|
||||
summary["flat"] = r.Flat
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -506,17 +447,13 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", skillsFailureHint())
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills (%s layout)\n", symOK(), len(r.Official), r.Layout)
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally (%s layout)\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted), r.Layout)
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsFailureHint() string {
|
||||
return "Retry: lark-cli update --force. To switch to separate top-level skills: lark-cli update --skills-layout separate (this saves layout=separate and clears saved flat_skills)."
|
||||
}
|
||||
|
||||
@@ -204,9 +204,6 @@ func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||
if !strings.Contains(out, "pnpm add -g") {
|
||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "pnpm dlx skills add larksuite/cli") {
|
||||
t.Errorf("should not suggest standalone pnpm skills sync command, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
@@ -731,8 +728,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
if !strings.Contains(out, "skills will not be synced") {
|
||||
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "npx skills add larksuite/cli -y -g") {
|
||||
t.Errorf("should not suggest standalone skills sync command, got: %s", out)
|
||||
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
|
||||
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,7 +1133,7 @@ func newTestIO() *cmdutil.IOStreams {
|
||||
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
@@ -1146,7 +1143,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
@@ -1155,27 +1152,6 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_MissingLayoutDoesNotDedup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want successful sync when state lacks layout", got)
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsCommandOverride not called, want resync when state lacks layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
@@ -1188,7 +1164,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
@@ -1200,7 +1176,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
@@ -1211,12 +1187,6 @@ func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
if state.Layout != skillscheck.LayoutSeparate {
|
||||
t.Errorf("state.Layout = %q, want %q", state.Layout, skillscheck.LayoutSeparate)
|
||||
}
|
||||
if len(state.FlatSkills) != 0 {
|
||||
t.Errorf("state.FlatSkills = %#v, want empty", state.FlatSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
@@ -1231,7 +1201,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
@@ -1244,50 +1214,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillsSyncOptions_UsesStateAsFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.21",
|
||||
Layout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: []string{"lark-doc"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
layout, flat := resolveSkillsSyncOptions("", "", false)
|
||||
if layout != skillscheck.LayoutHybrid {
|
||||
t.Fatalf("layout = %q, want %q", layout, skillscheck.LayoutHybrid)
|
||||
}
|
||||
if len(flat) != 1 || flat[0] != "lark-doc" {
|
||||
t.Fatalf("flat = %#v, want [lark-doc]", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsRejectsSuiteMode(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{SkillsLayout: "suite"})
|
||||
if err == nil {
|
||||
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
|
||||
}
|
||||
if validation.Param != "--skills-layout" {
|
||||
t.Fatalf("validation.Param = %q, want --skills-layout", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsAllowsFlatSkillsFilteringAfterOfficialDiscovery(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{
|
||||
SkillsLayout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: "lark-shared,lark-unknown",
|
||||
FlatSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validateSkillsLayoutOptions() err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
@@ -1568,7 +1494,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeCommandUnavailable Subtype = "command_unavailable" // command not included in this build (integrator-restricted distribution); absent, not gated
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
@@ -60,6 +60,7 @@ You should see `audit` in the plugin list.
|
||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
|
||||
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | No (customizes embedded skills) |
|
||||
|
||||
### Plugin lifecycle
|
||||
|
||||
@@ -79,7 +80,7 @@ sequenceDiagram
|
||||
Host->>SDK: InstallAll()
|
||||
SDK->>Plugin: Capabilities()
|
||||
SDK->>Plugin: Install(Registrar)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
|
||||
SDK->>Plugin: On(Startup) fire
|
||||
|
||||
Note over Host,Plugin: Each command dispatch
|
||||
@@ -113,6 +114,35 @@ the rejected dispatch.
|
||||
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
|
||||
may itself list several rules under `rules:`) is shadowed by any plugin
|
||||
Restrict.
|
||||
- A plugin may call `EmbeddedSkills()` at most once to customize the embedded
|
||||
skill tree — `Allow` keeps only the listed skills (the allow-list
|
||||
counterpart of `Rule.Allow`, so a CLI upgrade cannot widen the build;
|
||||
`Remove` wins over `Allow`, and `Overlay` entries are exempt), `Remove`
|
||||
drops skills, `Overlay` adds/replaces ones, or swap the whole `Base` —
|
||||
layered over the CLI default. Unlike `Restrict()`
|
||||
it is NOT a security boundary and does not imply `FailClosed`: it
|
||||
shapes fail-open guidance content. Removing a skill only drops its
|
||||
`skills read` / `--help` guidance — it does NOT disable the matching
|
||||
commands (use `Restrict()` for that). Only ONE plugin per binary may
|
||||
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
|
||||
`multiple_skills_overlay_plugins` error.
|
||||
- A command denied by a **plugin** Rule presents as absent, not as
|
||||
forbidden: it leaves `--help` and completion, explicit help on it is
|
||||
intercepted, and invoking it answers `subtype=command_unavailable`
|
||||
with no policy vocabulary and no recovery hint. Set
|
||||
`Rule.DeniedMessage` to replace the default
|
||||
"command not included in this build" with your product's own wording.
|
||||
yaml-source denials keep the classic `command_denied` presentation —
|
||||
the user owns that policy and needs to see how to adjust it. Denying a
|
||||
whole domain also retires what points at it: `--profile` (profile
|
||||
domain) hides and rejects use, the root-help skills footer (skills),
|
||||
update notices (update), and the `auth login` recovery hint (auth)
|
||||
stop rendering.
|
||||
- `config policy show` / `config plugins show` stay executable under any
|
||||
plugin policy (hidden from help when their domain is denied) so an
|
||||
operator can still inspect the rule that locked the build. An
|
||||
integrator shipping a fully-managed distribution opts out with
|
||||
`HideDiagnostics()` — requires `Restrict()` on the same plugin.
|
||||
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||
install time. Long-lived state (clients, caches, metrics counters)
|
||||
must live on the Plugin struct or in package-level variables.
|
||||
@@ -153,6 +183,8 @@ messages are localised and may change between releases.
|
||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
||||
| `invalid_skills_overlay` | Staging fault (`EmbeddedSkills(nil)` / second call in one plugin) honours FailurePolicy; a `SkillsOverlay` that can't compose (`Remove` names a skill absent from the base, `Overlay` entry lacks `SKILL.md`) always aborts via a fatal dispatch guard | Mixed — see left |
|
||||
| `multiple_skills_overlay_plugins` | Two or more DISTINCT plugins each contributed a `SkillsOverlay` (only one may own skill content) | No — always aborts (dispatch guard) |
|
||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||
| `install_panic` | `Plugin.Install` panicked | Yes |
|
||||
|
||||
@@ -187,8 +219,8 @@ should additionally check `detail.layer == "policy"`.
|
||||
- [Runnable example: audit observer](./examples/audit-observer/)
|
||||
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
||||
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
|
||||
`MustBuild`).
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
|
||||
`HideDiagnostics`, `FailOpen`/`FailClosed`, `MustBuild`).
|
||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||
installing your plugin to see hooks/rules attributed to your plugin
|
||||
name.
|
||||
|
||||
@@ -36,8 +36,9 @@ type Builder struct {
|
||||
version string
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
@@ -81,6 +82,15 @@ func (b *Builder) FailClosed() *Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
// HideDiagnostics retires the policy self-inspection commands
|
||||
// (`config policy show`, `config plugins show`), which otherwise stay
|
||||
// executable under a plugin policy as the operator's escape hatch.
|
||||
// Requires Restrict() on the same plugin; Build fails otherwise.
|
||||
func (b *Builder) HideDiagnostics() *Builder {
|
||||
b.caps.HideDiagnostics = true
|
||||
return b
|
||||
}
|
||||
|
||||
// Observer registers an Observer. Multiple calls accumulate.
|
||||
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
||||
if !b.validateHookName(hookName, "observer") {
|
||||
@@ -145,6 +155,35 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay)
|
||||
// customizing the CLI's embedded skill content. Unlike Restrict it does
|
||||
// NOT imply FailClosed: skill customization is guidance content, not a
|
||||
// security boundary. A plugin owns at most one SkillsOverlay, so calling
|
||||
// EmbeddedSkills more than once is a build error.
|
||||
func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder {
|
||||
if spec == nil {
|
||||
b.errs = append(b.errs, errors.New("EmbeddedSkills(nil): spec must not be nil"))
|
||||
return b
|
||||
}
|
||||
if b.skillsOverlay != nil {
|
||||
b.errs = append(b.errs, errors.New("EmbeddedSkills() called more than once; a plugin owns at most one SkillsOverlay"))
|
||||
return b
|
||||
}
|
||||
b.skillsOverlay = cloneSkillsOverlay(spec)
|
||||
return b
|
||||
}
|
||||
|
||||
// cloneSkillsOverlay snapshots the caller's spec so a later mutation of the
|
||||
// same *SkillsOverlay cannot alter the staged copy. The Remove slice is
|
||||
// copied; Overlay/Base are fs.FS handles retained by reference (an fs.FS
|
||||
// is a read-only view, not caller-mutable state).
|
||||
func cloneSkillsOverlay(spec *SkillsOverlay) *SkillsOverlay {
|
||||
cp := *spec
|
||||
cp.Allow = append([]string(nil), spec.Allow...)
|
||||
cp.Remove = append([]string(nil), spec.Remove...)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Build returns the configured Plugin, or an error if any builder
|
||||
// step found a fault. MustBuild panics on the same error.
|
||||
//
|
||||
@@ -155,15 +194,20 @@ func (b *Builder) Build() (Plugin, error) {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||
}
|
||||
if b.caps.HideDiagnostics && len(b.rules) == 0 {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"HideDiagnostics() requires Restrict(): there is no integrator policy to hide"))
|
||||
}
|
||||
if len(b.errs) > 0 {
|
||||
return nil, errors.Join(b.errs...)
|
||||
}
|
||||
return &builtPlugin{
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
skillsOverlay: b.skillsOverlay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -202,11 +246,12 @@ func (b *Builder) validateHookName(hookName, kind string) bool {
|
||||
|
||||
// builtPlugin is the Plugin implementation the builder emits.
|
||||
type builtPlugin struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
@@ -216,6 +261,9 @@ func (p *builtPlugin) Install(r Registrar) error {
|
||||
for _, rule := range p.rules {
|
||||
r.Restrict(rule)
|
||||
}
|
||||
if p.skillsOverlay != nil {
|
||||
r.EmbeddedSkills(p.skillsOverlay)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
// recorder Registrar captures everything a builder schedules so the
|
||||
// test can assert what Install produced without involving the host.
|
||||
type recorder struct {
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
skillsOverlay *platform.SkillsOverlay
|
||||
skillCalls int
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
@@ -30,6 +32,10 @@ func (r *recorder) Restrict(rule *platform.Rule) {
|
||||
r.rule = rule
|
||||
r.rules = append(r.rules, rule)
|
||||
}
|
||||
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
||||
r.skillsOverlay = spec
|
||||
r.skillCalls++
|
||||
}
|
||||
|
||||
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
||||
// same *Rule object across two Restrict calls must still get two distinct
|
||||
@@ -211,3 +217,78 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddedSkills() must snapshot Remove: a caller that mutates the same slice
|
||||
// after the call must still get the value staged at call time.
|
||||
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
|
||||
remove := []string{"lark-shared"}
|
||||
b := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: remove})
|
||||
remove[0] = "mutated"
|
||||
|
||||
p, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.skillCalls != 1 {
|
||||
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
|
||||
}
|
||||
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
|
||||
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_skillsNilRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
|
||||
if err == nil {
|
||||
t.Fatal("EmbeddedSkills(nil) must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_skillsTwiceRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatal("calling EmbeddedSkills() twice must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddedSkills() customizes guidance content, not a security boundary, so
|
||||
// unlike Restrict() it must NOT force FailClosed.
|
||||
func TestBuilder_skillsDoesNotForceFailClosed(t *testing.T) {
|
||||
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if caps.Restricts {
|
||||
t.Error("EmbeddedSkills() must not set Restricts")
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailOpen {
|
||||
t.Errorf("FailurePolicy = %v, want FailOpen (default)", caps.FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// HideDiagnostics only makes sense alongside Restrict: without a rule
|
||||
// there is no integrator policy whose inspection could be hidden.
|
||||
func TestBuilder_hideDiagnosticsRequiresRestrict(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").HideDiagnostics().Build()
|
||||
if err == nil {
|
||||
t.Fatal("HideDiagnostics() without Restrict() must fail Build")
|
||||
}
|
||||
p, err := platform.NewPlugin("p", "0").
|
||||
Restrict(&platform.Rule{Deny: []string{"config/**"}}).
|
||||
HideDiagnostics().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("HideDiagnostics()+Restrict() must build: %v", err)
|
||||
}
|
||||
if !p.Capabilities().HideDiagnostics {
|
||||
t.Error("Capabilities.HideDiagnostics must be set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +47,9 @@ type Capabilities struct {
|
||||
// constants above; the framework requires FailClosed whenever
|
||||
// Restricts=true.
|
||||
FailurePolicy FailurePolicy
|
||||
|
||||
// HideDiagnostics retires the policy self-inspection commands
|
||||
// (`config policy show`, `config plugins show`) from the build.
|
||||
// Requires Restricts=true; the install fails otherwise.
|
||||
HideDiagnostics bool
|
||||
}
|
||||
|
||||
@@ -35,4 +35,18 @@ type Registrar interface {
|
||||
// Plugin rules take precedence over the yaml source; two distinct
|
||||
// plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
|
||||
// Skills contributes a SkillsOverlay customizing the CLI's embedded skill
|
||||
// content (see SkillsOverlay). Skill content has a single owner: a second
|
||||
// customizing plugin, or a SkillsOverlay that cannot compose, aborts
|
||||
// startup unconditionally. A malformed call inside one plugin --
|
||||
// EmbeddedSkills(nil) or a second EmbeddedSkills() -- is a staging fault handled like
|
||||
// any Install error (FailClosed aborts, FailOpen skips); the Builder
|
||||
// rejects it earlier, at MustBuild.
|
||||
//
|
||||
// Unlike Restrict, EmbeddedSkills is not a security boundary -- it shapes
|
||||
// fail-open guidance content. Removing a skill drops its guidance but
|
||||
// does not disable any command; use Restrict to actually block a
|
||||
// command.
|
||||
EmbeddedSkills(spec *SkillsOverlay)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ package platform
|
||||
// Rule is the declarative policy rule data structure. yaml files and
|
||||
// Plugin.Restrict() both produce the same Rule.
|
||||
//
|
||||
// At any moment there is at most one effective Rule -- the resolver decides
|
||||
// which source wins (Plugin > yaml > none). This package only defines the
|
||||
// At any moment there is at most one effective SOURCE of rules -- the
|
||||
// resolver decides which wins (Plugin > yaml > none); the winning source
|
||||
// may contribute several scoped rules. This package only defines the
|
||||
// shape; selection lives in internal/cmdpolicy.
|
||||
//
|
||||
// The four filter fields are joined by AND. See the engine's Evaluate for
|
||||
@@ -57,4 +58,11 @@ type Rule struct {
|
||||
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
|
||||
// platform stays free of a yaml library dependency.
|
||||
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
|
||||
|
||||
// DeniedMessage replaces the default "command not included in this
|
||||
// build" message for plugin-denied commands. The message is
|
||||
// build-level: the first non-empty DeniedMessage across the owning
|
||||
// plugin's rules applies to every denial, so declare it once. yaml
|
||||
// rules ignore it (they keep the command-denied presentation).
|
||||
DeniedMessage string `json:"denied_message,omitempty"`
|
||||
}
|
||||
|
||||
49
extension/platform/skillsoverlay.go
Normal file
49
extension/platform/skillsoverlay.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// SkillsOverlay declares how a plugin customizes the CLI's embedded
|
||||
// skill content, contributed via Builder.EmbeddedSkills. At most one
|
||||
// source may own skill content; two customizing plugins abort startup.
|
||||
//
|
||||
// Allow / Remove mirror Rule's Allow / Deny: an allow-list keeps only
|
||||
// what it names, a remove-list drops what it names, and Remove wins
|
||||
// over Allow. Composition order is fixed: Base (or the CLI default) ->
|
||||
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
|
||||
//
|
||||
// Skills are addressed by exact name (a directory carrying SKILL.md,
|
||||
// e.g. "lark-doc"), not by command path and not by glob — the skill
|
||||
// list is flat, so misspellings abort startup instead of silently
|
||||
// matching nothing. Removing a skill only drops its guidance; it does
|
||||
// not disable any command (use Restrict for that).
|
||||
type SkillsOverlay struct {
|
||||
// Allow, when non-empty, keeps only these skills (by name) from the
|
||||
// base tree — the allow-list counterpart of Rule.Allow. Skills the
|
||||
// CLI adds in future versions stay out of the build until listed
|
||||
// here, which a Remove-only spec cannot guarantee. A name not
|
||||
// present in the base aborts startup. Overlay entries are exempt:
|
||||
// content the integrator explicitly ships needs no allow-listing.
|
||||
Allow []string
|
||||
|
||||
// Remove hides these skills, by name (e.g. "lark-shared"), from the
|
||||
// base tree; it wins over Allow, mirroring Rule's Deny-over-Allow. A
|
||||
// name not present in the base aborts startup rather than being
|
||||
// silently ignored.
|
||||
Remove []string
|
||||
|
||||
// Overlay contributes skills laid over the base: a same-named skill
|
||||
// replaces the base's entirely, a new name adds one. It is rooted at
|
||||
// the skill list (entries like "my-skill/SKILL.md"); each top-level
|
||||
// entry must be a "<name>/" directory containing SKILL.md. Any fs.FS
|
||||
// works (embed.FS, os.DirFS, fstest.MapFS); embed.FS is not required.
|
||||
Overlay fs.FS
|
||||
|
||||
// Base replaces the entire base skill tree instead of layering over
|
||||
// the CLI default. nil keeps the CLI default. Rare: most integrators
|
||||
// leave it nil and use Remove/Overlay so unchanged skills follow the
|
||||
// CLI version with no copy to maintain.
|
||||
Base fs.FS
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -10,7 +10,7 @@ require (
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
@@ -83,10 +83,9 @@ func commandFormResolver(service string) func(string) string {
|
||||
}
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if id, ok := byForm[h]; ok {
|
||||
if id, ok := byForm[strings.TrimSpace(h)]; ok {
|
||||
return id
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
return headingToKey(h) // one home for the shortcut/method key convention
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||
@@ -84,3 +86,38 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
||||
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
||||
}
|
||||
}
|
||||
|
||||
// The ### Skills section merges with the domain `> skill:` default: domain
|
||||
// first, then per-command entries, de-duplicated. A command with no ### Skills
|
||||
// still inherits the domain default.
|
||||
func TestParseDomainMD_SkillsMerge(t *testing.T) {
|
||||
md := "# d\n> skill: lark-d\n\n" +
|
||||
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
|
||||
"## bar\ndoes bar.\n"
|
||||
got := parseDomainMD([]byte(md), nil)
|
||||
|
||||
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
|
||||
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
|
||||
}
|
||||
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
|
||||
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
|
||||
// matches the shortcut command as mounted.
|
||||
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
|
||||
md := "# d\n\n## +create\ncreate via shortcut.\n"
|
||||
got := parseDomainMD([]byte(md), nil)
|
||||
if _, ok := got["+create"]; !ok {
|
||||
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]meta.Affordance) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
||||
// ### Tips -> tips
|
||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||
// ### Skills -> skills: bullet skill names, added to the domain default
|
||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||
//
|
||||
@@ -34,16 +35,56 @@ var standardSection = map[string]string{
|
||||
"Prerequisites": "prerequisites",
|
||||
"Tips": "tips",
|
||||
"Examples": "examples",
|
||||
"Skills": "skills",
|
||||
}
|
||||
|
||||
// mergeSkills returns the domain-default skill followed by a command's own skill
|
||||
// entries, de-duplicated in author order and empties dropped. Backticks (left by
|
||||
// the shared bullet parse) are stripped so each entry is a bare skill name.
|
||||
func mergeSkills(domain string, extra []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
add := func(s string) {
|
||||
s = strings.Trim(strings.TrimSpace(s), "`")
|
||||
if s == "" || seen[s] {
|
||||
return
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
add(domain)
|
||||
for _, s := range extra {
|
||||
add(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
||||
|
||||
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
|
||||
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
|
||||
// while an entry containing a slash is a name/relative-path reference (e.g.
|
||||
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
|
||||
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
|
||||
// skills read already accepts — so a per-command entry can point at that
|
||||
// command's own reference file, not just re-point the domain skill.
|
||||
func SkillStatPath(entry string) string {
|
||||
if strings.Contains(entry, "/") {
|
||||
return entry
|
||||
}
|
||||
return entry + "/SKILL.md"
|
||||
}
|
||||
|
||||
// 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), " ", ".")
|
||||
h = strings.TrimSpace(h)
|
||||
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
|
||||
return h
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
}
|
||||
|
||||
type mdSection struct {
|
||||
@@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
if len(useWhen) > 0 {
|
||||
a.UseWhen = useWhen
|
||||
}
|
||||
var perCmdSkills []string
|
||||
for _, s := range secs {
|
||||
switch standardSection[s.label] {
|
||||
case "avoid_when":
|
||||
@@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
a.Tips = s.items
|
||||
case "examples":
|
||||
a.Examples = s.cases
|
||||
case "skills":
|
||||
perCmdSkills = s.items
|
||||
default:
|
||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||
}
|
||||
}
|
||||
if skill != "" {
|
||||
a.Skills = []string{skill}
|
||||
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
|
||||
a.Skills = s
|
||||
}
|
||||
out[curKey] = a
|
||||
}
|
||||
@@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
inFence, fence = true, nil
|
||||
} else {
|
||||
inFence = false
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
|
||||
pending = ""
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -41,11 +42,15 @@ func (e *NeedAuthorizationError) Error() string {
|
||||
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
|
||||
// errors.As / errors.Is traversal.
|
||||
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
|
||||
WithUserOpenID(userOpenID).
|
||||
WithHint("run: lark-cli auth login to re-authorize").
|
||||
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
|
||||
// No recovery hint when the auth domain is absent from this build.
|
||||
if !policystate.DomainDeniedByPlugin("auth") {
|
||||
e = e.WithHint("run: lark-cli auth login to re-authorize")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
|
||||
|
||||
28
internal/auth/hint_gate_test.go
Normal file
28
internal/auth/hint_gate_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
// The auth-login recovery hint points into the auth domain; when an
|
||||
// integrator plugin denied that whole domain the hint would be a dead
|
||||
// end, so it stays off the error.
|
||||
func TestNeedUserAuthorization_hintFollowsAuthDomain(t *testing.T) {
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
if e := NewNeedUserAuthorizationError("ou_x"); !strings.Contains(e.Hint, "auth login") {
|
||||
t.Errorf("default build must keep the auth login hint, got %q", e.Hint)
|
||||
}
|
||||
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
|
||||
if e := NewNeedUserAuthorizationError("ou_x"); e.Hint != "" {
|
||||
t.Errorf("auth-denied build must not steer to auth login, got %q", e.Hint)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -71,10 +72,14 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
|
||||
// for the defensive empty-token branch) and is preserved for errors.Is /
|
||||
// errors.Unwrap traversal without being serialized on the wire.
|
||||
func newTokenMissingError(as core.Identity, cause error) error {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no access token available for %s", as).
|
||||
WithHint("run: lark-cli auth login to re-authorize").
|
||||
WithCause(cause)
|
||||
// No recovery hint when the auth domain is absent from this build.
|
||||
if !policystate.DomainDeniedByPlugin("auth") {
|
||||
e = e.WithHint("run: lark-cli auth login to re-authorize")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// buildApiReq converts a RawApiRequest into SDK types and collects
|
||||
|
||||
31
internal/client/hint_gate_test.go
Normal file
31
internal/client/hint_gate_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
// Same gate as internal/auth: the token-missing recovery hint points into
|
||||
// the auth domain and stays off the error when a plugin denied it.
|
||||
func TestTokenMissing_hintFollowsAuthDomain(t *testing.T) {
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
var ae *errs.AuthenticationError
|
||||
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || !strings.Contains(ae.Hint, "auth login") {
|
||||
t.Errorf("default build must keep the auth login hint, got %v", err)
|
||||
}
|
||||
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
|
||||
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || ae.Hint != "" {
|
||||
t.Errorf("auth-denied build must not steer to auth login, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package cmdmeta is the single source of truth for command metadata that the
|
||||
// policy engine and the hook selector both consume. It wraps the existing
|
||||
// cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need.
|
||||
// policy engine, the hook selector, and help rendering consume. It wraps the
|
||||
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need, plus the
|
||||
// affordance ref (service, method id) that lets service-method and shortcut
|
||||
// help share one usage-guidance lookup path.
|
||||
//
|
||||
// Three axes:
|
||||
//
|
||||
@@ -51,6 +53,12 @@ const (
|
||||
|
||||
sourceAnnotationKey = "cmdmeta.source"
|
||||
generatedAnnotationKey = "cmdmeta.generated"
|
||||
|
||||
// affordance{Service,Method}Key locate the command's usage-guidance overlay
|
||||
// entry (see internal/affordance). Both service-method commands and
|
||||
// +-prefixed shortcuts set these so help rendering shares one lookup path.
|
||||
affordanceServiceKey = "cmdmeta.affordance.service"
|
||||
affordanceMethodKey = "cmdmeta.affordance.method"
|
||||
)
|
||||
|
||||
// Meta groups the three command-level metadata axes consumed by the policy
|
||||
@@ -125,6 +133,35 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetAffordanceRef records which affordance overlay entry (service, method id)
|
||||
// a command maps to, so help rendering can look up its usage guidance. Stored
|
||||
// on the command itself (no inheritance): each method / shortcut owns its ref.
|
||||
// A no-op if either coordinate is empty.
|
||||
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
|
||||
if service == "" || method == "" {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[affordanceServiceKey] = service
|
||||
cmd.Annotations[affordanceMethodKey] = method
|
||||
}
|
||||
|
||||
// AffordanceRef returns the command's own affordance overlay coordinates.
|
||||
// ok is false when the command carries no ref.
|
||||
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return "", "", false
|
||||
}
|
||||
service = cmd.Annotations[affordanceServiceKey]
|
||||
method = cmd.Annotations[affordanceMethodKey]
|
||||
if service == "" || method == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return service, method, true
|
||||
}
|
||||
|
||||
// Domain returns the nearest-ancestor domain for the command. Empty string
|
||||
// when no ancestor has the annotation -- this is the "unknown" state the
|
||||
// policy engine must treat as ALLOW.
|
||||
|
||||
@@ -23,6 +23,9 @@ type ActivePolicy struct {
|
||||
Rules []*platform.Rule
|
||||
Source ResolveSource
|
||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||
|
||||
// DeniedByPath is the full post-aggregation denial map.
|
||||
DeniedByPath map[string]Denial
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -81,6 +84,12 @@ func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
||||
cp.Rules[i] = &rule
|
||||
}
|
||||
}
|
||||
if in.DeniedByPath != nil {
|
||||
cp.DeniedByPath = make(map[string]Denial, len(in.DeniedByPath))
|
||||
for k, v := range in.DeniedByPath {
|
||||
cp.DeniedByPath[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
|
||||
}
|
||||
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent", "")
|
||||
|
||||
// Both leaves denied.
|
||||
if _, ok := denied["im/+send"]; !ok {
|
||||
@@ -110,7 +110,7 @@ func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
|
||||
Deny: []string{"docs/+delete"},
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy", "")
|
||||
|
||||
if _, ok := denied["docs"]; ok {
|
||||
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
|
||||
@@ -129,7 +129,7 @@ func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "", "")
|
||||
|
||||
// Every leaf should be denied. We do not assert on the root entry
|
||||
// because Apply skips the root regardless; the contract is "root
|
||||
@@ -156,7 +156,7 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
||||
Allow: []string{"docs"},
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "", "")
|
||||
|
||||
// docs/+delete denied (path doesn't match Allow=["docs"]).
|
||||
if _, ok := denied["docs/+delete"]; !ok {
|
||||
@@ -170,13 +170,13 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
||||
|
||||
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
|
||||
// consumers rely on:
|
||||
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
|
||||
// subtype + exit code 2)
|
||||
// 1. cmd/root.go's envelope writer (errs.ProblemOf; plugin-source
|
||||
// denials use subtype command_unavailable + exit code 2)
|
||||
// 2. in-process consumers extracting the platform.CommandDeniedError as
|
||||
// the typed error's Cause via errors.As
|
||||
//
|
||||
// The policy metadata (layer / policy_source / rule_name / reason_code)
|
||||
// is folded into the Hint text rather than a separate detail map.
|
||||
// Plugin-source denials keep the policy metadata OFF the wire (no hint,
|
||||
// no source / rule vocabulary); it stays reachable on the Cause only.
|
||||
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||
root := buildTree()
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
@@ -196,29 +196,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||
t.Fatalf("denied command should return error")
|
||||
}
|
||||
|
||||
// Path 1: typed-envelope view. The denial is a failed_precondition
|
||||
// ValidationError so cmd/root.go renders the structured envelope and
|
||||
// the process exits 2 (ExitValidation).
|
||||
// Path 1: typed-envelope view. A plugin-source denial presents as
|
||||
// "command unavailable": the capability is absent from this build, so
|
||||
// the envelope carries no policy metadata and no recovery hint.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
|
||||
}
|
||||
// The policy metadata is folded into the Hint text: reason_code,
|
||||
// policy_source, and rule_name must all be discoverable there.
|
||||
if !strings.Contains(ve.Hint, "write_not_allowed") {
|
||||
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
|
||||
if ve.Message != cmdpolicy.DefaultUnavailableMessage {
|
||||
t.Errorf("message = %q, want default unavailable message", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "plugin:secaudit") {
|
||||
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
|
||||
// No hint, no policy vocabulary: the wire must not steer the caller
|
||||
// toward a policy the integrator locked into the build.
|
||||
if ve.Hint != "" {
|
||||
t.Errorf("plugin-source denial must carry no hint, got %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "secaudit-policy") {
|
||||
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
|
||||
for _, leak := range []string{"policy", "plugin:secaudit", "secaudit-policy", "write_not_allowed"} {
|
||||
if strings.Contains(ve.Message, leak) {
|
||||
t.Errorf("message leaks %q: %q", leak, ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
|
||||
@@ -301,7 +303,7 @@ func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) {
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
decisions := e.EvaluateAll(root)
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
|
||||
|
||||
if _, ok := denied["docs"]; !ok {
|
||||
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
|
||||
@@ -332,7 +334,7 @@ func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) {
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
decisions := e.EvaluateAll(root)
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
|
||||
|
||||
if _, ok := denied["drive"]; !ok {
|
||||
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -73,6 +75,10 @@ const (
|
||||
AnnotationDenialLayer = "lark:policy_denied_layer"
|
||||
AnnotationDenialSource = "lark:policy_denied_source"
|
||||
|
||||
// AnnotationDenialMessage carries the resolved unavailable message
|
||||
// for the cmd layer's help interceptor (plugin-source denials only).
|
||||
AnnotationDenialMessage = "lark:policy_denied_message"
|
||||
|
||||
// AnnotationPureGroup marks a cobra.Command that is logically a
|
||||
// parent-only group but had a RunE attached by the bootstrap-time
|
||||
// unknown-subcommand guard. The engine treats annotated commands
|
||||
@@ -124,6 +130,37 @@ func BuildDenialError(path string, d Denial) *errs.ValidationError {
|
||||
WithCause(cd)
|
||||
}
|
||||
|
||||
// IsPluginPolicySource reports whether a policy source names a plugin.
|
||||
// Plugin sources select the "command unavailable" presentation.
|
||||
func IsPluginPolicySource(source string) bool {
|
||||
return strings.HasPrefix(source, "plugin:")
|
||||
}
|
||||
|
||||
// DefaultUnavailableMessage is the message shown when a plugin-restricted
|
||||
// command is invoked and the integrator supplied no Rule.DeniedMessage.
|
||||
const DefaultUnavailableMessage = "command not included in this build"
|
||||
|
||||
// messageOf resolves the effective unavailable message for a denial.
|
||||
func messageOf(d Denial) string {
|
||||
if d.DeniedMessage != "" {
|
||||
return d.DeniedMessage
|
||||
}
|
||||
return DefaultUnavailableMessage
|
||||
}
|
||||
|
||||
// BuildUnavailableError is the plugin-source counterpart of
|
||||
// BuildDenialError: no hint, no policy vocabulary on the wire. The
|
||||
// *platform.CommandDeniedError stays reachable as the Cause for
|
||||
// in-process consumers; Cause is never serialized.
|
||||
func BuildUnavailableError(path string, d Denial) *errs.ValidationError {
|
||||
msg := d.DeniedMessage
|
||||
if msg == "" {
|
||||
msg = DefaultUnavailableMessage
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg).
|
||||
WithCause(CommandDeniedFromDenial(path, d))
|
||||
}
|
||||
|
||||
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
|
||||
// which does RemoveCommand+AddCommand (changing the pointer), we modify
|
||||
// the existing node so any external reference (snapshots, alias targets)
|
||||
@@ -194,11 +231,19 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
|
||||
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
|
||||
|
||||
denial := d // capture by value for the closure
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
// The typed message carries the user-facing semantic ("a command
|
||||
// was denied"); the hint carries the layer / source / rule
|
||||
// distinction ("policy" vs "strict_mode") for debugging.
|
||||
return BuildDenialError(path, denial)
|
||||
if IsPluginPolicySource(d.PolicySource) {
|
||||
// The message annotation feeds the cmd layer's help interceptor.
|
||||
cmd.Annotations[AnnotationDenialMessage] = messageOf(d)
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
return BuildUnavailableError(path, denial)
|
||||
}
|
||||
} else {
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
// The typed message carries the user-facing semantic ("a command
|
||||
// was denied"); the hint carries the layer / source / rule
|
||||
// distinction ("policy" vs "strict_mode") for debugging.
|
||||
return BuildDenialError(path, denial)
|
||||
}
|
||||
}
|
||||
// Clear any pre-existing Run hook: cobra prefers RunE when both are
|
||||
// set, but leaving a stale Run around is a foot-gun for future
|
||||
|
||||
@@ -25,6 +25,10 @@ type Denial struct {
|
||||
RuleName string // matched Rule.Name (if any)
|
||||
ReasonCode string // closed enum, see docs/extension/reason-codes.md
|
||||
Reason string // human-readable
|
||||
|
||||
// DeniedMessage is Rule.DeniedMessage for the plugin-source
|
||||
// unavailable presentation; empty means the default message.
|
||||
DeniedMessage string
|
||||
}
|
||||
|
||||
// ChildDenial is what AggregateChildren consumes — it pairs a Denial
|
||||
|
||||
@@ -27,3 +27,15 @@ var diagnosticPaths = map[string]bool{
|
||||
func IsDiagnosticPath(path string) bool {
|
||||
return diagnosticPaths[path]
|
||||
}
|
||||
|
||||
// DiagnosticPaths returns the exempt self-inspection command paths, for
|
||||
// the presentation layer: an integrator's HideDiagnostics retires exactly
|
||||
// this set, and the help-concealment pass hides it from listings when the
|
||||
// surrounding domain is plugin-denied.
|
||||
func DiagnosticPaths() []string {
|
||||
out := make([]string, 0, len(diagnosticPaths))
|
||||
for p := range diagnosticPaths {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -269,8 +269,9 @@ func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
|
||||
// so `--help` and similar remain available.
|
||||
//
|
||||
// source / ruleName populate PolicySource and RuleName on the produced
|
||||
// Denial values, so envelope output can attribute denials.
|
||||
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
|
||||
// Denial values, so envelope output can attribute denials. deniedMessage
|
||||
// is build-level and applies uniformly, aggregates included.
|
||||
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string, deniedMessage string) map[string]Denial {
|
||||
out := map[string]Denial{}
|
||||
|
||||
sourceLabel := policySourceLabel(source)
|
||||
@@ -287,6 +288,13 @@ func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, sourc
|
||||
}
|
||||
|
||||
aggregateParents(root, out)
|
||||
|
||||
if deniedMessage != "" {
|
||||
for path, d := range out {
|
||||
d.DeniedMessage = deniedMessage
|
||||
out[path] = d
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
|
||||
cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
|
||||
}, "my-readonly-rule")
|
||||
}, "my-readonly-rule", "")
|
||||
|
||||
cmdpolicy.Apply(root, denied)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
@@ -77,7 +77,7 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
|
||||
"secaudit-policy")
|
||||
"secaudit-policy", "")
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
@@ -85,10 +85,17 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
// The plugin name IS surfaced (in-binary, part of the contract): it
|
||||
// must appear in the Hint so an integrator debugging a denial knows
|
||||
// which plugin fired.
|
||||
if !strings.Contains(ve.Hint, "plugin:secaudit") {
|
||||
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
|
||||
// A plugin-source denial presents as absent: no hint, no plugin name
|
||||
// on the wire. Plugin attribution moves to the in-process Cause
|
||||
// (*platform.CommandDeniedError) for integrators debugging a denial.
|
||||
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
|
||||
}
|
||||
if ve.Hint != "" || strings.Contains(ve.Message, "plugin:secaudit") {
|
||||
t.Errorf("wire must not expose the plugin source; hint=%q message=%q", ve.Hint, ve.Message)
|
||||
}
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) || cd.PolicySource != "plugin:secaudit" {
|
||||
t.Errorf("in-process Cause must carry plugin:secaudit, got %+v", cd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,7 +129,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// Add top-level JSON keys as text form fields.
|
||||
|
||||
@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
|
||||
@@ -10,20 +10,23 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
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
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
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)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||
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
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{233523001, 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_WikiCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
17
internal/errclass/codemeta_wiki.go
Normal file
17
internal/errclass/codemeta_wiki.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
@@ -8,8 +8,11 @@ import "encoding/json"
|
||||
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
||||
// model the envelope renderer and the command help both parse, so the
|
||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||
// Skills entries are skill names (or name/path) rendered as runnable
|
||||
// `lark-cli skills read <entry>` pointers.
|
||||
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
|
||||
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
|
||||
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
|
||||
// each against the embedded skill tree (a name → its SKILL.md, a reference →
|
||||
// that path) and drops any that do not resolve.
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
|
||||
@@ -53,4 +53,12 @@ const (
|
||||
ReasonInstallPanic = "install_panic"
|
||||
ReasonDuplicatePluginName = "duplicate_plugin_name"
|
||||
ReasonMultipleRestricts = "multiple_restrict_plugins"
|
||||
// ReasonInvalidSkillsOverlay flags a plugin's SkillsOverlay that cannot
|
||||
// compose -- EmbeddedSkills() called twice, a Remove naming a skill absent
|
||||
// from the base, or an Overlay entry missing SKILL.md.
|
||||
ReasonInvalidSkillsOverlay = "invalid_skills_overlay"
|
||||
// ReasonMultipleSkillsOverlays flags two or more plugins each contributing
|
||||
// a SkillsOverlay; only one may own skill content (mirrors
|
||||
// ReasonMultipleRestricts).
|
||||
ReasonMultipleSkillsOverlays = "multiple_skills_overlay_plugins"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
)
|
||||
|
||||
// PluginInfo is the metadata of a successfully-installed plugin,
|
||||
@@ -29,9 +30,10 @@ type PluginInfo struct {
|
||||
// every plugin that committed successfully (FailOpen-skipped plugins
|
||||
// are absent), for downstream diagnostics.
|
||||
type InstallResult struct {
|
||||
Registry *hook.Registry
|
||||
PluginRules []cmdpolicy.PluginRule
|
||||
Plugins []PluginInfo
|
||||
Registry *hook.Registry
|
||||
PluginRules []cmdpolicy.PluginRule
|
||||
PluginSkills []skillpolicy.PluginSkill
|
||||
Plugins []PluginInfo
|
||||
}
|
||||
|
||||
// InstallAll runs every registered plugin through the staging
|
||||
@@ -142,6 +144,16 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
||||
}
|
||||
}
|
||||
|
||||
// The Builder rejects this at Build time; hand-written plugins are
|
||||
// caught here (authoring error, aborts unconditionally).
|
||||
if caps.HideDiagnostics && !caps.Restricts {
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonInvalidCapability,
|
||||
Reason: "HideDiagnostics=true requires Restricts=true",
|
||||
}
|
||||
}
|
||||
|
||||
// Version compatibility check. Two distinct failure modes:
|
||||
//
|
||||
// 1. Parse error (constraint is malformed, e.g. ">=abc")
|
||||
@@ -207,6 +219,12 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
||||
Rule: rule,
|
||||
})
|
||||
}
|
||||
if staging.skillsOverlay != nil {
|
||||
result.PluginSkills = append(result.PluginSkills, skillpolicy.PluginSkill{
|
||||
PluginName: name,
|
||||
SkillsOverlay: staging.skillsOverlay,
|
||||
})
|
||||
}
|
||||
|
||||
// Record the plugin in the inventory. Version is fetched here under
|
||||
// a recover-wrapped helper so a plugin's Version() panic does not
|
||||
|
||||
@@ -431,3 +431,79 @@ func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
|
||||
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// skillPlugin contributes a SkillsOverlay via r.EmbeddedSkills; the install pipeline
|
||||
// must capture it into PluginSkills for the skill resolver.
|
||||
type skillPlugin struct{}
|
||||
|
||||
func (skillPlugin) Name() string { return "skiller" }
|
||||
func (skillPlugin) Version() string { return "1.0.0" }
|
||||
func (skillPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (skillPlugin) Install(r platform.Registrar) error {
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_skillsCommitted(t *testing.T) {
|
||||
result, err := internalplatform.InstallAll([]platform.Plugin{skillPlugin{}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
if len(result.PluginSkills) != 1 {
|
||||
t.Fatalf("PluginSkills = %d, want 1", len(result.PluginSkills))
|
||||
}
|
||||
ps := result.PluginSkills[0]
|
||||
if ps.PluginName != "skiller" {
|
||||
t.Errorf("PluginName = %q, want skiller", ps.PluginName)
|
||||
}
|
||||
if ps.SkillsOverlay == nil || len(ps.SkillsOverlay.Remove) != 1 || ps.SkillsOverlay.Remove[0] != "lark-shared" {
|
||||
t.Errorf("Spec = %+v, want Remove=[lark-shared]", ps.SkillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
// doubleSkillPlugin calls r.EmbeddedSkills twice; staging must reject it. Declared
|
||||
// FailClosed so the staging error aborts InstallAll deterministically.
|
||||
type doubleSkillPlugin struct{}
|
||||
|
||||
func (doubleSkillPlugin) Name() string { return "double-skiller" }
|
||||
func (doubleSkillPlugin) Version() string { return "1.0.0" }
|
||||
func (doubleSkillPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (doubleSkillPlugin) Install(r platform.Registrar) error {
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_skillsCalledTwice_aborts(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{doubleSkillPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("calling r.EmbeddedSkills twice must abort a FailClosed plugin")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidSkillsOverlay {
|
||||
t.Errorf("err = %v, want PluginInstallError reason_code %s", err, internalplatform.ReasonInvalidSkillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
// hideDiagnosticsOnlyPlugin declares HideDiagnostics without Restricts —
|
||||
// an authoring error the host rejects unconditionally.
|
||||
type hideDiagnosticsOnlyPlugin struct{}
|
||||
|
||||
func (hideDiagnosticsOnlyPlugin) Name() string { return "hider" }
|
||||
func (hideDiagnosticsOnlyPlugin) Version() string { return "1.0.0" }
|
||||
func (hideDiagnosticsOnlyPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{HideDiagnostics: true, FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (hideDiagnosticsOnlyPlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
func TestInstallAll_hideDiagnosticsRequiresRestricts(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{hideDiagnosticsOnlyPlugin{}}, nil)
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidCapability {
|
||||
t.Fatalf("HideDiagnostics without Restricts must abort with invalid_capability, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ type PluginEntry struct {
|
||||
// plugin did not call r.Restrict.
|
||||
Rules []*RuleView
|
||||
|
||||
// EmbeddedSkills summarises the plugin's EmbeddedSkills contribution;
|
||||
// nil when the plugin did not customize embedded skills.
|
||||
EmbeddedSkills *SkillsOverlayView `json:"embedded_skills,omitempty"`
|
||||
|
||||
Observers []HookEntry
|
||||
Wrappers []HookEntry
|
||||
Lifecycles []HookEntry
|
||||
@@ -41,6 +45,7 @@ type CapabilitiesView struct {
|
||||
Restricts bool `json:"restricts"`
|
||||
FailurePolicy string `json:"failure_policy"`
|
||||
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
|
||||
HideDiagnostics bool `json:"hide_diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
// NewCapabilitiesView converts a platform.Capabilities value into the
|
||||
@@ -50,6 +55,7 @@ func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
|
||||
Restricts: c.Restricts,
|
||||
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
|
||||
RequiredCLIVersion: c.RequiredCLIVersion,
|
||||
HideDiagnostics: c.HideDiagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +80,22 @@ type RuleView struct {
|
||||
AllowUnannotated bool `json:"allow_unannotated"`
|
||||
}
|
||||
|
||||
// SkillsOverlayView is the displayable summary of an EmbeddedSkills
|
||||
// contribution. Overlay / Base report presence only (an fs.FS has no
|
||||
// display form).
|
||||
type SkillsOverlayView struct {
|
||||
Allow []string `json:"allow,omitempty"`
|
||||
Remove []string `json:"remove,omitempty"`
|
||||
Overlay bool `json:"overlay"`
|
||||
Base bool `json:"base"`
|
||||
}
|
||||
|
||||
// SkillsInventorySource pairs a plugin name with its overlay summary.
|
||||
type SkillsInventorySource struct {
|
||||
PluginName string
|
||||
View SkillsOverlayView
|
||||
}
|
||||
|
||||
// Inventory is the full snapshot.
|
||||
type Inventory struct {
|
||||
Plugins []PluginEntry
|
||||
@@ -108,7 +130,7 @@ type RuleInventorySource struct {
|
||||
// Hooks are attributed to plugins by the namespaced name convention:
|
||||
// each entry's Name starts with "<plugin>.", and we group by the
|
||||
// leading segment up to the first dot.
|
||||
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
|
||||
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource, skills []SkillsInventorySource) *Inventory {
|
||||
byPlugin := make(map[string]*PluginEntry, len(plugins))
|
||||
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
|
||||
for _, p := range plugins {
|
||||
@@ -162,6 +184,15 @@ func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, ru
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, sk := range skills {
|
||||
if entry := byPlugin[sk.PluginName]; entry != nil {
|
||||
v := sk.View
|
||||
v.Allow = append([]string(nil), sk.View.Allow...)
|
||||
v.Remove = append([]string(nil), sk.View.Remove...)
|
||||
entry.EmbeddedSkills = &v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -263,6 +294,12 @@ func cloneInventory(in *Inventory) *Inventory {
|
||||
entry.Rules[j] = &rv
|
||||
}
|
||||
}
|
||||
if p.EmbeddedSkills != nil {
|
||||
sv := *p.EmbeddedSkills
|
||||
sv.Allow = append([]string(nil), p.EmbeddedSkills.Allow...)
|
||||
sv.Remove = append([]string(nil), p.EmbeddedSkills.Remove...)
|
||||
entry.EmbeddedSkills = &sv
|
||||
}
|
||||
entry.Observers = append([]HookEntry(nil), p.Observers...)
|
||||
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
|
||||
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestBuildInventory_groupsByPluginName(t *testing.T) {
|
||||
{PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, r, rules)
|
||||
inv := internalplatform.BuildInventory(plugins, r, rules, nil)
|
||||
|
||||
if got := len(inv.Plugins); got != 2 {
|
||||
t.Fatalf("Plugins len = %d, want 2", got)
|
||||
@@ -89,7 +89,7 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
|
||||
{PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, nil, rules)
|
||||
inv := internalplatform.BuildInventory(plugins, nil, rules, nil)
|
||||
a := findPlugin(inv, "a")
|
||||
if a == nil {
|
||||
t.Fatalf("missing entry a")
|
||||
@@ -103,12 +103,45 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInventory_empty(t *testing.T) {
|
||||
inv := internalplatform.BuildInventory(nil, nil, nil)
|
||||
inv := internalplatform.BuildInventory(nil, nil, nil, nil)
|
||||
if got := len(inv.Plugins); got != 0 {
|
||||
t.Errorf("Plugins len = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-nil SkillsInventorySource must surface on the owning plugin's entry as
|
||||
// an EmbeddedSkills summary, and BuildInventory must clone the Allow/Remove
|
||||
// slices so a later mutation of the source cannot corrupt the recorded view.
|
||||
func TestBuildInventory_populatesAndClonesEmbeddedSkills(t *testing.T) {
|
||||
plugins := []internalplatform.PluginInventorySource{{Name: "acme", Version: "1.0"}}
|
||||
allow := []string{"lark-im"}
|
||||
remove := []string{"lark-shared"}
|
||||
skills := []internalplatform.SkillsInventorySource{
|
||||
{PluginName: "acme", View: internalplatform.SkillsOverlayView{
|
||||
Allow: allow, Remove: remove, Overlay: true, Base: false,
|
||||
}},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, nil, nil, skills)
|
||||
entry := findPlugin(inv, "acme")
|
||||
if entry == nil || entry.EmbeddedSkills == nil {
|
||||
t.Fatalf("acme entry missing EmbeddedSkills: %+v", entry)
|
||||
}
|
||||
es := entry.EmbeddedSkills
|
||||
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||
len(es.Remove) != 1 || es.Remove[0] != "lark-shared" ||
|
||||
!es.Overlay || es.Base {
|
||||
t.Errorf("EmbeddedSkills summary mismatch: %+v", es)
|
||||
}
|
||||
|
||||
// Mutating the source slices must not leak into the recorded view.
|
||||
allow[0] = "MUTATED"
|
||||
remove[0] = "MUTATED"
|
||||
if es.Allow[0] != "lark-im" || es.Remove[0] != "lark-shared" {
|
||||
t.Errorf("EmbeddedSkills slices not cloned; source mutation leaked: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry {
|
||||
for i := range inv.Plugins {
|
||||
if inv.Plugins[i].Name == name {
|
||||
|
||||
@@ -41,6 +41,13 @@ type stagingRegistrar struct {
|
||||
// can detect the call.
|
||||
actuallyRestricted bool
|
||||
|
||||
// skillsOverlay holds the staged Skills contribution, captured for the
|
||||
// host to feed into the skill resolver later. nil means the plugin
|
||||
// did not call r.EmbeddedSkills. overlaySet records that the call happened so a
|
||||
// second call in the same plugin can be rejected.
|
||||
skillsOverlay *platform.SkillsOverlay
|
||||
overlaySet bool
|
||||
|
||||
// seenHookNames detects duplicate hookName within this plugin's
|
||||
// Install call.
|
||||
seenHookNames map[string]bool
|
||||
@@ -144,6 +151,25 @@ func (r *stagingRegistrar) Restrict(rule *platform.Rule) {
|
||||
r.rules = append(r.rules, &cp)
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
||||
if r.overlaySet {
|
||||
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills() called more than once in the same plugin")
|
||||
return
|
||||
}
|
||||
r.overlaySet = true
|
||||
if spec == nil {
|
||||
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills(nil)")
|
||||
return
|
||||
}
|
||||
// Defensive clone: freeze Remove so a plugin cannot mutate it after
|
||||
// Install returns. Overlay/Base are read-only fs.FS views retained by
|
||||
// reference.
|
||||
cp := *spec
|
||||
cp.Allow = append([]string(nil), spec.Allow...)
|
||||
cp.Remove = append([]string(nil), spec.Remove...)
|
||||
r.skillsOverlay = &cp
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func (r *stagingRegistrar) namespaced(name string) string {
|
||||
|
||||
47
internal/policystate/policystate.go
Normal file
47
internal/policystate/policystate.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package policystate answers "did an integrator plugin deny this whole
|
||||
// command domain?" for render-time hint emitters. It is dependency-free
|
||||
// because internal/auth and internal/client sit below internal/cmdpolicy
|
||||
// in the import graph and cannot ask it directly. Written once by the
|
||||
// bootstrap after policy pruning; read-only thereafter.
|
||||
package policystate
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
pluginDeniedDomains map[string]bool
|
||||
)
|
||||
|
||||
// SetPluginDeniedDomains records the plugin-denied top-level domains.
|
||||
// nil clears.
|
||||
func SetPluginDeniedDomains(domains map[string]bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if domains == nil {
|
||||
pluginDeniedDomains = nil
|
||||
return
|
||||
}
|
||||
cp := make(map[string]bool, len(domains))
|
||||
for d, v := range domains {
|
||||
cp[d] = v
|
||||
}
|
||||
pluginDeniedDomains = cp
|
||||
}
|
||||
|
||||
// DomainDeniedByPlugin reports whether the whole top-level domain was
|
||||
// denied by an integrator plugin. yaml denials never register here.
|
||||
func DomainDeniedByPlugin(domain string) bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return pluginDeniedDomains[domain]
|
||||
}
|
||||
|
||||
// ResetForTesting clears the recorded state.
|
||||
func ResetForTesting() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
pluginDeniedDomains = nil
|
||||
}
|
||||
@@ -50,8 +50,6 @@ const (
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
isolatedSkillsSourceURL = "https://open.feishu.cn/lark-cli/isolated-skills"
|
||||
isolatedSkillsFallback = "larksuite/cli/isolated-skills"
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -330,14 +328,6 @@ func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSuiteSkill() *NpmResult {
|
||||
r := u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall(isolatedSkillsFallback, []string{"lark-suite"})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
@@ -208,13 +208,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
{
|
||||
name: "install isolated suite skill",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn/lark-cli/isolated-skills -s lark-suite -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -245,76 +238,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) {
|
||||
called := []string{}
|
||||
u := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), isolatedSkillsSourceURL) {
|
||||
r.Err = fmt.Errorf("isolated source unavailable")
|
||||
}
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := u.InstallSuiteSkill()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("calls = %#v, want primary and fallback", called)
|
||||
}
|
||||
if !strings.Contains(called[0], isolatedSkillsSourceURL) {
|
||||
t.Fatalf("primary call = %q, want isolated source", called[0])
|
||||
}
|
||||
if !strings.Contains(called[1], isolatedSkillsFallback) {
|
||||
t.Fatalf("fallback call = %q, want isolated GitHub fallback", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSuiteSkillUsesPnpmDlxForIsolatedSources(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "pnpm.log")
|
||||
script := filepath.Join(dir, "pnpm")
|
||||
scriptContent := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" >> %q
|
||||
case "$*" in
|
||||
*%q*) exit 1 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
`, logPath, isolatedSkillsSourceURL)
|
||||
if err := os.WriteFile(script, []byte(scriptContent), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
u := New()
|
||||
u.DetectOverride = func() DetectResult {
|
||||
return DetectResult{Method: InstallPnpm, PnpmAvailable: true}
|
||||
}
|
||||
|
||||
result := u.InstallSuiteSkill()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
want := []string{
|
||||
"dlx skills add " + isolatedSkillsSourceURL + " -s lark-suite -g -y",
|
||||
"dlx skills add " + isolatedSkillsFallback + " -s lark-suite -g -y",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("pnpm calls = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
|
||||
209
internal/skillpolicy/overlay.go
Normal file
209
internal/skillpolicy/overlay.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillpolicy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// overlayFS layers an upper skill tree over a lower one at skill (top
|
||||
// path segment) granularity: a skill present in upper is served wholly
|
||||
// from upper, a skill named in removed is hidden, and everything else
|
||||
// falls through to lower. It is the runtime form of a SkillsOverlay's
|
||||
// Base -> Remove -> Overlay composition.
|
||||
//
|
||||
// It implements the io/fs fast-path interfaces (ReadDir/Stat/ReadFile)
|
||||
// so the io/fs helpers route through the merge instead of hitting a
|
||||
// single underlying tree.
|
||||
type overlayFS struct {
|
||||
lower fs.FS // base (or SkillsOverlay.Base); may be nil
|
||||
upper fs.FS // SkillsOverlay.Overlay; may be nil
|
||||
removed map[string]bool // skill names whited out from lower
|
||||
upperNames map[string]bool // skill names owned by upper
|
||||
}
|
||||
|
||||
var (
|
||||
_ fs.FS = (*overlayFS)(nil)
|
||||
_ fs.ReadDirFS = (*overlayFS)(nil)
|
||||
_ fs.StatFS = (*overlayFS)(nil)
|
||||
_ fs.ReadFileFS = (*overlayFS)(nil)
|
||||
)
|
||||
|
||||
func newOverlayFS(lower, upper fs.FS, remove []string) *overlayFS {
|
||||
o := &overlayFS{
|
||||
lower: lower,
|
||||
upper: upper,
|
||||
removed: make(map[string]bool, len(remove)),
|
||||
upperNames: map[string]bool{},
|
||||
}
|
||||
for _, r := range remove {
|
||||
o.removed[r] = true
|
||||
}
|
||||
if upper != nil {
|
||||
if entries, err := fs.ReadDir(upper, "."); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
o.upperNames[e.Name()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// route picks the tree owning name by its top path segment. whiteout is
|
||||
// true when name belongs to a removed skill (present in neither tree).
|
||||
func (o *overlayFS) route(name string) (target fs.FS, whiteout bool) {
|
||||
top := name
|
||||
if i := strings.IndexByte(name, '/'); i >= 0 {
|
||||
top = name[:i]
|
||||
}
|
||||
switch {
|
||||
case o.upperNames[top]:
|
||||
return o.upper, false
|
||||
case o.removed[top]:
|
||||
return nil, true
|
||||
default:
|
||||
return o.lower, false
|
||||
}
|
||||
}
|
||||
|
||||
func (o *overlayFS) Open(name string) (fs.File, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name == "." {
|
||||
entries, err := o.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rootDir{entries: entries}, nil
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return target.Open(name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) Stat(name string) (fs.FileInfo, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name == "." {
|
||||
return rootInfo{}, nil
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.Stat(target, name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) ReadFile(name string) ([]byte, error) {
|
||||
if !fs.ValidPath(name) || name == "." {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.ReadFile(target, name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name != "." {
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.ReadDir(target, name)
|
||||
}
|
||||
return o.mergedRoot()
|
||||
}
|
||||
|
||||
// mergedRoot lists the top-level skills: every upper skill, plus every
|
||||
// lower skill that is neither removed nor shadowed by a same-named upper.
|
||||
// Sorted so listings are deterministic.
|
||||
func (o *overlayFS) mergedRoot() ([]fs.DirEntry, error) {
|
||||
seen := map[string]bool{}
|
||||
var out []fs.DirEntry
|
||||
if o.upper != nil {
|
||||
entries, err := fs.ReadDir(o.upper, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
seen[e.Name()] = true
|
||||
}
|
||||
}
|
||||
if o.lower != nil {
|
||||
entries, err := fs.ReadDir(o.lower, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if o.removed[e.Name()] || seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
seen[e.Name()] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rootInfo is the synthetic FileInfo for the overlay's "." directory,
|
||||
// which has no single backing entry in either tree.
|
||||
type rootInfo struct{}
|
||||
|
||||
func (rootInfo) Name() string { return "." }
|
||||
func (rootInfo) Size() int64 { return 0 }
|
||||
func (rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 }
|
||||
func (rootInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (rootInfo) IsDir() bool { return true }
|
||||
func (rootInfo) Sys() any { return nil }
|
||||
|
||||
// rootDir is the synthetic directory file returned by Open(".").
|
||||
type rootDir struct {
|
||||
entries []fs.DirEntry
|
||||
off int
|
||||
}
|
||||
|
||||
func (d *rootDir) Stat() (fs.FileInfo, error) { return rootInfo{}, nil }
|
||||
func (d *rootDir) Close() error { return nil }
|
||||
|
||||
func (d *rootDir) Read([]byte) (int, error) {
|
||||
return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid}
|
||||
}
|
||||
|
||||
func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if n <= 0 {
|
||||
rest := d.entries[d.off:]
|
||||
d.off = len(d.entries)
|
||||
return rest, nil
|
||||
}
|
||||
if d.off >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := d.off + n
|
||||
if end > len(d.entries) {
|
||||
end = len(d.entries)
|
||||
}
|
||||
part := d.entries[d.off:end]
|
||||
d.off = end
|
||||
return part, nil
|
||||
}
|
||||
156
internal/skillpolicy/resolver.go
Normal file
156
internal/skillpolicy/resolver.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillpolicy composes the CLI's effective embedded skill tree
|
||||
// from a base skill FS and at most one plugin-supplied SkillsOverlay. It is
|
||||
// the skill-side analogue of internal/cmdpolicy: plugins contribute a
|
||||
// delta over a base, and one resolver produces the single tree that both
|
||||
// skill readers consume -- `skills list`/`read` and the --help
|
||||
// domain-guide pointer.
|
||||
package skillpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// PluginSkill pairs a plugin name with the SkillsOverlay it contributed, so a
|
||||
// conflict can be attributed to specific owners. Mirrors
|
||||
// cmdpolicy.PluginRule.
|
||||
type PluginSkill struct {
|
||||
PluginName string
|
||||
SkillsOverlay *platform.SkillsOverlay
|
||||
}
|
||||
|
||||
// ErrMultipleSkillsOverlays reports that more than one plugin tried to
|
||||
// customize skill content. Mirrors cmdpolicy.ErrMultipleRestricts: only
|
||||
// one owner is allowed so independent plugins cannot silently overwrite
|
||||
// each other's skill tree.
|
||||
var ErrMultipleSkillsOverlays = errors.New("multiple plugins customized skills; only one plugin may own skill content")
|
||||
|
||||
// Resolve composes the effective skill tree from base and the supplied
|
||||
// specs. base is the CLI's embedded skill FS (nil when the build embeds
|
||||
// none). With no spec it returns base unchanged. With exactly one spec it
|
||||
// applies, in fixed order, Base override -> Allow -> Remove -> Overlay,
|
||||
// returning an overlay FS in which a same-named skill resolves to
|
||||
// Overlay. Two or more distinct owners is a configuration error.
|
||||
func Resolve(base fs.FS, specs []PluginSkill) (fs.FS, error) {
|
||||
owners := distinctOwners(specs)
|
||||
if len(owners) > 1 {
|
||||
return nil, fmt.Errorf("%w: %v", ErrMultipleSkillsOverlays, owners)
|
||||
}
|
||||
if len(specs) == 0 || specs[0].SkillsOverlay == nil {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
owner, spec := specs[0].PluginName, specs[0].SkillsOverlay
|
||||
lower := base
|
||||
if spec.Base != nil {
|
||||
lower = spec.Base
|
||||
}
|
||||
if err := validate(lower, spec); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q skill spec: %w", owner, err)
|
||||
}
|
||||
if lower == nil && spec.Overlay == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return newOverlayFS(lower, spec.Overlay, effectiveRemove(lower, spec)), nil
|
||||
}
|
||||
|
||||
// effectiveRemove folds a non-empty Allow into the remove set: every
|
||||
// base skill not allow-listed is treated as removed, and explicit
|
||||
// Remove entries win regardless (Remove-over-Allow, mirroring Rule's
|
||||
// Deny-over-Allow). Overlay entries are never folded in — the overlay
|
||||
// FS resolves them from the upper layer before the whiteout applies.
|
||||
func effectiveRemove(lower fs.FS, spec *platform.SkillsOverlay) []string {
|
||||
if len(spec.Allow) == 0 {
|
||||
return spec.Remove
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, name := range spec.Allow {
|
||||
allowed[name] = true
|
||||
}
|
||||
removed := append([]string(nil), spec.Remove...)
|
||||
entries, err := fs.ReadDir(lower, ".")
|
||||
if err != nil {
|
||||
return removed // validate already vetted lower; nothing more to fold
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !allowed[e.Name()] {
|
||||
removed = append(removed, e.Name())
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// distinctOwners returns the unique contributing plugin names in
|
||||
// first-seen order. Mirrors cmdpolicy.distinctOwners.
|
||||
func distinctOwners(specs []PluginSkill) []string {
|
||||
seen := map[string]bool{}
|
||||
owners := make([]string, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
if !seen[s.PluginName] {
|
||||
seen[s.PluginName] = true
|
||||
owners = append(owners, s.PluginName)
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
// validate rejects a spec that cannot compose cleanly, so the failure
|
||||
// surfaces at startup with a named cause rather than as a silently
|
||||
// missing skill at read time.
|
||||
func validate(lower fs.FS, spec *platform.SkillsOverlay) error {
|
||||
for _, name := range spec.Allow {
|
||||
if !isSkillName(name) {
|
||||
return fmt.Errorf("Allow: %q is not a valid skill name", name)
|
||||
}
|
||||
if !skillExists(lower, name) {
|
||||
return fmt.Errorf("Allow: skill %q is not in the base tree", name)
|
||||
}
|
||||
}
|
||||
for _, name := range spec.Remove {
|
||||
if !isSkillName(name) {
|
||||
return fmt.Errorf("Remove: %q is not a valid skill name", name)
|
||||
}
|
||||
if !skillExists(lower, name) {
|
||||
return fmt.Errorf("Remove: skill %q is not in the base tree", name)
|
||||
}
|
||||
}
|
||||
if spec.Overlay != nil {
|
||||
entries, err := fs.ReadDir(spec.Overlay, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Overlay: cannot read root: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
return fmt.Errorf("Overlay: %q is not a directory; every Overlay entry must be a <skill>/ dir", e.Name())
|
||||
}
|
||||
if !skillExists(spec.Overlay, e.Name()) {
|
||||
return fmt.Errorf("Overlay: skill %q is missing SKILL.md", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skillExists reports whether fsys holds a skill named name -- a
|
||||
// directory carrying SKILL.md, the shape internal/skillcontent treats as
|
||||
// a skill.
|
||||
func skillExists(fsys fs.FS, name string) bool {
|
||||
if fsys == nil {
|
||||
return false
|
||||
}
|
||||
info, err := fs.Stat(fsys, name+"/SKILL.md")
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// isSkillName rejects empty, dotted, or path-bearing names so Remove
|
||||
// cannot smuggle a traversal or match outside the top level.
|
||||
func isSkillName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, `/\`)
|
||||
}
|
||||
272
internal/skillpolicy/resolver_test.go
Normal file
272
internal/skillpolicy/resolver_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// skillFS builds an in-memory skill tree; each map entry is a path -> content.
|
||||
func skillFS(files map[string]string) fstest.MapFS {
|
||||
m := fstest.MapFS{}
|
||||
for p, content := range files {
|
||||
m[p] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// baseTree is a representative base: three skills, one with a reference file.
|
||||
func baseTree() fstest.MapFS {
|
||||
return skillFS(map[string]string{
|
||||
"lark-a/SKILL.md": "base a",
|
||||
"lark-a/references/x.md": "base a ref",
|
||||
"lark-b/SKILL.md": "base b",
|
||||
"lark-shared/SKILL.md": "base shared",
|
||||
})
|
||||
}
|
||||
|
||||
// topLevel returns the sorted top-level skill names of fsys.
|
||||
func topLevel(t *testing.T, fsys fs.FS) []string {
|
||||
t.Helper()
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(.): %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, fsys fs.FS, name string) string {
|
||||
t.Helper()
|
||||
data, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q): %v", name, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mustResolve(t *testing.T, base fs.FS, spec *platform.SkillsOverlay) fs.FS {
|
||||
t.Helper()
|
||||
got, err := Resolve(base, []PluginSkill{{PluginName: "acme", SkillsOverlay: spec}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: unexpected error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestResolve_NoSpecs_ReturnsBaseUnchanged(t *testing.T) {
|
||||
base := baseTree()
|
||||
got, err := Resolve(base, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if want := []string{"lark-a", "lark-b", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Remove_HidesSkill(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||
|
||||
if want := []string{"lark-a", "lark-b"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
// The removed skill is gone for the affordance reader too (Stat gates it).
|
||||
if _, err := fs.Stat(got, "lark-shared/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("Stat removed skill: err = %v, want ErrNotExist", err)
|
||||
}
|
||||
// Surviving skills still read from base.
|
||||
if c := readFile(t, got, "lark-a/SKILL.md"); c != "base a" {
|
||||
t.Errorf("lark-a content = %q, want %q", c, "base a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Overlay_AddsNewSkill(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-new/SKILL.md": "new skill"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||
|
||||
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if c := readFile(t, got, "lark-new/SKILL.md"); c != "new skill" {
|
||||
t.Errorf("lark-new content = %q, want %q", c, "new skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Overlay_ReplacesSameNameWholeSkill(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-a/SKILL.md": "overlaid a"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||
|
||||
// Same-named skill resolves to the overlay (upper wins).
|
||||
if c := readFile(t, got, "lark-a/SKILL.md"); c != "overlaid a" {
|
||||
t.Errorf("lark-a content = %q, want overlaid", c)
|
||||
}
|
||||
// Replacement is whole-skill: the base's reference file is shadowed, not merged.
|
||||
if _, err := fs.Stat(got, "lark-a/references/x.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("base reference should be shadowed by overlay skill; err = %v, want ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Base_ReplacesEntireTree(t *testing.T) {
|
||||
replacement := skillFS(map[string]string{"lark-only/SKILL.md": "only"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Base: replacement})
|
||||
|
||||
if want := []string{"lark-only"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if _, err := fs.Stat(got, "lark-a/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("base skill should be gone after Base replace; err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Base_WithRemoveAndOverlay(t *testing.T) {
|
||||
replacement := skillFS(map[string]string{
|
||||
"lark-p/SKILL.md": "p",
|
||||
"lark-q/SKILL.md": "q",
|
||||
})
|
||||
overlay := skillFS(map[string]string{"lark-r/SKILL.md": "r"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Base: replacement,
|
||||
Remove: []string{"lark-q"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if want := []string{"lark-p", "lark-r"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow keeps only the listed base skills — the allow-list counterpart
|
||||
// of Rule.Allow, so a CLI upgrade adding new embedded skills cannot
|
||||
// widen an allow-listed build.
|
||||
func TestResolve_Allow_KeepsOnlyListed(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Allow: []string{"lark-a"}})
|
||||
|
||||
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if _, err := fs.Stat(got, "lark-b/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("non-allow-listed skill must be absent; err = %v", err)
|
||||
}
|
||||
// Kept skills still read from base, references included.
|
||||
if c := readFile(t, got, "lark-a/references/x.md"); c != "base a ref" {
|
||||
t.Errorf("kept skill content = %q, want base content", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove wins over Allow, mirroring Rule's Deny-over-Allow.
|
||||
func TestResolve_RemoveWinsOverAllow(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Allow: []string{"lark-a", "lark-b"},
|
||||
Remove: []string{"lark-b"},
|
||||
})
|
||||
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay entries are exempt from Allow: content the integrator
|
||||
// explicitly ships needs no allow-listing.
|
||||
func TestResolve_OverlayExemptFromAllow(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"acme-guide/SKILL.md": "mine"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Allow: []string{"lark-a"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if want := []string{"acme-guide", "lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// An Allow name absent from the base aborts startup, same as Remove.
|
||||
func TestResolve_AllowUnknown_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-nope"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error allow-listing a skill absent from base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RemoveUnknown_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-nope"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error removing a skill absent from base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RemoveInvalidName_Errors(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "a/b", `a\b`} {
|
||||
if _, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{bad}}}}); err == nil {
|
||||
t.Errorf("Remove %q: expected error, got nil", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OverlayMissingSKILLmd_Errors(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-bad/other.md": "no skill.md here"})
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for overlay entry missing SKILL.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OverlayNonDirEntry_Errors(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"loose.md": "top-level file, not a skill dir"})
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-directory overlay entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_TwoOwners_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{
|
||||
{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-a"}}},
|
||||
{PluginName: "globex", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-b"}}},
|
||||
})
|
||||
if !errors.Is(err, ErrMultipleSkillsOverlays) {
|
||||
t.Fatalf("err = %v, want ErrMultipleSkillsOverlays", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_ComposedConformsToFS runs the composed tree through the
|
||||
// standard io/fs conformance checker to catch Open/ReadDir/Stat drift.
|
||||
func TestResolve_ComposedConformsToFS(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{
|
||||
"lark-new/SKILL.md": "new",
|
||||
"lark-new/references/y.md": "new ref",
|
||||
})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Remove: []string{"lark-shared"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if err := fstest.TestFS(got,
|
||||
"lark-a/SKILL.md",
|
||||
"lark-a/references/x.md",
|
||||
"lark-b/SKILL.md",
|
||||
"lark-new/SKILL.md",
|
||||
"lark-new/references/y.md",
|
||||
); err != nil {
|
||||
t.Fatalf("composed FS failed fs conformance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
LayoutSeparate = "separate"
|
||||
LayoutHybrid = "hybrid"
|
||||
|
||||
suiteSkillName = "lark-suite"
|
||||
sharedSkillName = "lark-shared"
|
||||
suiteRoutesPlaceholder = "<!-- LARK_SUITE_ROUTES -->"
|
||||
)
|
||||
|
||||
type GlobalSkillInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func NormalizeLayout(layout string) (string, bool) {
|
||||
switch strings.TrimSpace(layout) {
|
||||
case "", LayoutSeparate:
|
||||
return LayoutSeparate, true
|
||||
case LayoutHybrid:
|
||||
return LayoutHybrid, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFlatSkills(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name != "" {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseGlobalSkillInfosJSON(text string) []GlobalSkillInfo {
|
||||
infos, _ := parseGlobalSkillInfosJSON(text)
|
||||
return infos
|
||||
}
|
||||
|
||||
func parseGlobalSkillInfosJSON(text string) ([]GlobalSkillInfo, bool) {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
seen := map[string]GlobalSkillInfo{}
|
||||
for _, skill := range skills {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
path := strings.TrimSpace(skill.Path)
|
||||
if name == "" || path == "" || !skillNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
seen[name] = GlobalSkillInfo{Name: name, Path: path}
|
||||
}
|
||||
|
||||
out := make([]GlobalSkillInfo, 0, len(seen))
|
||||
for _, info := range seen {
|
||||
out = append(out, info)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, true
|
||||
}
|
||||
|
||||
func installedSkillNamesFromInfos(infos []GlobalSkillInfo) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, info := range infos {
|
||||
seen[info.Name] = true
|
||||
if info.Name == suiteSkillName {
|
||||
for _, subskill := range listSuiteSubskills(info.Path) {
|
||||
seen[subskill] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func listSuiteSubskills(suitePath string) []string {
|
||||
entries, err := vfs.ReadDir(filepath.Join(suitePath, "references", "subskills"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(entry.Name())
|
||||
if name != "" && skillNamePattern.MatchString(name) {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func normalOfficialSkills(skills []string) []string {
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(skills) {
|
||||
if skill != suiteSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deletedOfficialSkills(official, local []string, previous *SkillsState, stateReadable, force bool, layout string) []string {
|
||||
if force || !stateReadable || previous == nil {
|
||||
return []string{}
|
||||
}
|
||||
officialSet := toSet(official)
|
||||
localSet := toSet(local)
|
||||
deleted := map[string]bool{}
|
||||
for _, skill := range previous.OfficialSkills {
|
||||
if !officialSet[skill] || localSet[skill] {
|
||||
continue
|
||||
}
|
||||
if layout != LayoutSeparate && skill == sharedSkillName {
|
||||
continue
|
||||
}
|
||||
deleted[skill] = true
|
||||
}
|
||||
return sortedKeys(deleted)
|
||||
}
|
||||
|
||||
func suiteEffectiveSkills(official []string, deleted map[string]bool) []string {
|
||||
out := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if !deleted[skill] {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(out)
|
||||
}
|
||||
|
||||
func resolveHybridSkillSets(layout string, requestedFlat, official []string, skippedDeleted []string) ([]string, []string, error) {
|
||||
if layout == LayoutSeparate {
|
||||
return []string{}, []string{}, nil
|
||||
}
|
||||
|
||||
officialSet := toSet(official)
|
||||
deletedSet := toSet(skippedDeleted)
|
||||
configuredFlat := map[string]bool{}
|
||||
effectiveFlat := map[string]bool{}
|
||||
for _, skill := range uniqueSorted(requestedFlat) {
|
||||
if skill == sharedSkillName {
|
||||
continue
|
||||
}
|
||||
if !officialSet[skill] {
|
||||
continue
|
||||
}
|
||||
configuredFlat[skill] = true
|
||||
if !deletedSet[skill] {
|
||||
effectiveFlat[skill] = true
|
||||
}
|
||||
}
|
||||
|
||||
collected := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if skill == sharedSkillName {
|
||||
collected = append(collected, skill)
|
||||
continue
|
||||
}
|
||||
if deletedSet[skill] || effectiveFlat[skill] {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, skill)
|
||||
}
|
||||
return sortedKeys(configuredFlat), uniqueSortedWithFirst(collected, sharedSkillName), nil
|
||||
}
|
||||
|
||||
func uniqueSortedWithFirst(values []string, first string) []string {
|
||||
seen := toSet(values)
|
||||
if !seen[first] {
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
delete(seen, first)
|
||||
return append([]string{first}, sortedKeys(seen)...)
|
||||
}
|
||||
|
||||
func assembleSuiteLayout(layout string, collected []string, keepSharedTopLevel bool, infos []GlobalSkillInfo) error {
|
||||
if layout == LayoutSeparate {
|
||||
return nil
|
||||
}
|
||||
|
||||
infoByName := map[string]GlobalSkillInfo{}
|
||||
for _, info := range infos {
|
||||
infoByName[info.Name] = info
|
||||
}
|
||||
suiteInfo, ok := infoByName[suiteSkillName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s was not installed from isolated skills source", suiteSkillName)
|
||||
}
|
||||
|
||||
subskillsDir := filepath.Join(suiteInfo.Path, "references", "subskills")
|
||||
if err := vfs.RemoveAll(subskillsDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(subskillsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, skill := range collected {
|
||||
info, ok := infoByName[skill]
|
||||
if !ok {
|
||||
return fmt.Errorf("suite subskill %q was not installed", skill)
|
||||
}
|
||||
dst := filepath.Join(subskillsDir, skill)
|
||||
if keepSharedTopLevel && skill == sharedSkillName {
|
||||
if err := copyDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := moveDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return renderSuiteRoutes(suiteInfo.Path, collected)
|
||||
}
|
||||
|
||||
func renderSuiteRoutes(suitePath string, collected []string) error {
|
||||
skillPath := filepath.Join(suitePath, "SKILL.md")
|
||||
data, err := vfs.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := normalizeSuiteTemplateText(string(data))
|
||||
routes := []string{}
|
||||
for _, skill := range collected {
|
||||
desc := skillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md"))
|
||||
if desc == "" {
|
||||
desc = skill
|
||||
}
|
||||
routes = append(routes, fmt.Sprintf("- %s: %s", skill, desc))
|
||||
}
|
||||
if !strings.Contains(text, suiteRoutesPlaceholder) {
|
||||
return fmt.Errorf("%s route placeholder not found", suiteSkillName)
|
||||
}
|
||||
text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1)
|
||||
return vfs.WriteFile(skillPath, []byte(text), 0o644)
|
||||
}
|
||||
|
||||
func normalizeSuiteTemplateText(text string) string {
|
||||
text = strings.ReplaceAll(text, "--collected-skills", "--flat-skills")
|
||||
oldShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
newShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。"
|
||||
return strings.ReplaceAll(text, oldShared, newShared)
|
||||
}
|
||||
|
||||
func skillDescription(path string) string {
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return ""
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "---" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "description:") {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "description:"))
|
||||
if value == ">" || value == "|" {
|
||||
return foldedYAMLScalar(lines[i+2:])
|
||||
}
|
||||
return strings.Trim(value, `"'`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func foldedYAMLScalar(lines []string) string {
|
||||
parts := []string{}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "---" || !isIndentedYAMLLine(line) {
|
||||
break
|
||||
}
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func isIndentedYAMLLine(line string) bool {
|
||||
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
||||
}
|
||||
|
||||
func moveDir(src, dst string) error {
|
||||
if err := vfs.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return vfs.RemoveAll(src)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
info, err := vfs.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", src)
|
||||
}
|
||||
if err := vfs.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return copyDirEntries(src, dst)
|
||||
}
|
||||
|
||||
func copyDirEntries(src, dst string) error {
|
||||
if err := vfs.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := vfs.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
if entry.IsDir() {
|
||||
if err := copyDirEntries(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := vfs.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
if err := vfs.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := vfs.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
@@ -23,12 +23,10 @@ var ErrUnreadableState = errors.New("skills state is unreadable")
|
||||
|
||||
type SkillsState struct {
|
||||
Version string `json:"version"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedOfficialSkills []string `json:"added_official_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
FlatSkills []string `json:"flat_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -78,14 +76,6 @@ func ReadSyncedVersion() (string, bool) {
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func ReadSyncedVersionAndLayout() (version string, layout string, ok bool) {
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return state.Version, state.Layout, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
@@ -99,7 +89,4 @@ func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
if s.FlatSkills == nil {
|
||||
s.FlatSkills = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,10 @@ func TestReadState_Valid(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
want := SkillsState{
|
||||
Version: "1.2.3",
|
||||
Layout: LayoutHybrid,
|
||||
OfficialSkills: []string{"lark-doc", "lark-im"},
|
||||
UpdatedSkills: []string{"lark-doc"},
|
||||
AddedOfficialSkills: []string{"lark-task"},
|
||||
SkippedDeletedSkills: []string{"custom-skill"},
|
||||
FlatSkills: []string{"lark-doc"},
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
@@ -117,9 +115,6 @@ func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
|
||||
if got.SkippedDeletedSkills == nil {
|
||||
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.FlatSkills == nil {
|
||||
t.Fatal("flat_skills decoded as nil, want empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSyncedVersionFromState(t *testing.T) {
|
||||
@@ -142,24 +137,3 @@ func TestReadSyncedVersionFromState(t *testing.T) {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSyncedVersionAndLayoutFromState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
if version, layout, ok := ReadSyncedVersionAndLayout(); ok || version != "" || layout != "" {
|
||||
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want empty result for missing state", version, layout, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{Version: "1.2.3", Layout: LayoutHybrid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, layout, ok := ReadSyncedVersionAndLayout(); !ok || version != "1.2.3" || layout != LayoutHybrid {
|
||||
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want (\"1.2.3\", %q, true)", version, layout, ok, LayoutHybrid)
|
||||
}
|
||||
if err := WriteState(SkillsState{Layout: LayoutHybrid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, layout, ok := ReadSyncedVersionAndLayout(); ok || version != "" || layout != "" {
|
||||
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want empty result for empty version", version, layout, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ var (
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
Layout string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
@@ -196,18 +195,7 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := normalOfficialSkills(input.OfficialSkills)
|
||||
layout, _ := NormalizeLayout(input.Layout)
|
||||
skippedDeleted := deletedOfficialSkills(official, input.LocalSkills, input.PreviousState, input.StateReadable, input.Force, layout)
|
||||
if layout != LayoutSeparate {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: suiteEffectiveSkills(official, toSet(skippedDeleted)),
|
||||
Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable),
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
@@ -220,7 +208,19 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
|
||||
officialSet := toSet(official)
|
||||
installedOfficial := intersection(input.LocalSkills, officialSet)
|
||||
newAddedOfficial := newlyOfficialSkills(official, input.PreviousState, input.StateReadable)
|
||||
|
||||
previousOfficial := []string{}
|
||||
if input.StateReadable && input.PreviousState != nil {
|
||||
previousOfficial = input.PreviousState.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
|
||||
newAddedOfficial := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
newAddedOfficial = append(newAddedOfficial, skill)
|
||||
}
|
||||
}
|
||||
|
||||
updateSet := toSet(installedOfficial)
|
||||
for _, skill := range newAddedOfficial {
|
||||
@@ -229,12 +229,19 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: newAddedOfficial,
|
||||
SkippedDeleted: skippedDeleted,
|
||||
Added: uniqueSorted(newAddedOfficial),
|
||||
SkippedDeleted: skipped,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,16 +252,13 @@ type SkillsRunner interface {
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
InstallSuiteSkill() *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Layout string
|
||||
FlatSkills []string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
@@ -267,9 +271,6 @@ type SyncResult struct {
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
Layout string
|
||||
Flat []string
|
||||
Collected []string
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
@@ -279,26 +280,16 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
layout, ok := NormalizeLayout(opts.Layout)
|
||||
if !ok {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("unsupported skills layout %q", opts.Layout)}
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to discover official skills for %s layout: %s", layout, reason), reason)
|
||||
}
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to list local skills for %s layout", layout), "local skills list failed or parsed as empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
}
|
||||
|
||||
@@ -311,17 +302,12 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
flat, collected, err := resolveHybridSkillSets(layout, opts.FlatSkills, plan.OfficialSkills, plan.SkippedDeleted)
|
||||
if err != nil {
|
||||
return &SyncResult{Action: "failed", Err: err, Official: plan.OfficialSkills, Force: opts.Force, Layout: layout}
|
||||
}
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
@@ -330,59 +316,25 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
Layout: layout,
|
||||
Flat: flat,
|
||||
Collected: collected,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("no target skills to assemble %s layout", layout), "toUpdate skills empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to install skills for %s layout: %s", layout, resultDetail(installResult)), resultDetail(installResult))
|
||||
}
|
||||
return fallbackFullInstall(opts, resultDetail(installResult), official)
|
||||
}
|
||||
}
|
||||
if layout != LayoutSeparate {
|
||||
installSuiteResult := opts.Runner.InstallSuiteSkill()
|
||||
if installSuiteResult == nil || installSuiteResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to install %s from isolated skills source: %s", suiteSkillName, resultDetail(installSuiteResult))
|
||||
result.Detail = resultDetail(installSuiteResult)
|
||||
return result
|
||||
}
|
||||
infosResult := opts.Runner.ListGlobalSkillsJSON()
|
||||
if infosResult == nil || infosResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to list installed skills for %s assembly: %s", suiteSkillName, resultDetail(infosResult))
|
||||
result.Detail = resultDetail(infosResult)
|
||||
return result
|
||||
}
|
||||
infos := ParseGlobalSkillInfosJSON(infosResult.Stdout.String())
|
||||
keepSharedTopLevel := layout == LayoutHybrid && len(flat) > 0
|
||||
if err := assembleSuiteLayout(layout, collected, keepSharedTopLevel, infos); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to assemble %s layout: %w", layout, err)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedOfficialSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
FlatSkills: stateFlatSkills(layout, flat),
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
@@ -394,16 +346,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func failedSync(layout string, force bool, err error, detail string) *SyncResult {
|
||||
return &SyncResult{
|
||||
Action: "failed",
|
||||
Err: err,
|
||||
Detail: detail,
|
||||
Force: force,
|
||||
Layout: layout,
|
||||
}
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
@@ -441,9 +383,8 @@ func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
infos, valid := parseGlobalSkillInfosJSON(jsonResult.Stdout.String())
|
||||
if valid {
|
||||
return installedSkillNamesFromInfos(infos), true
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +411,6 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
@@ -479,13 +419,11 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
|
||||
Detail: reason + "\n" + resultDetail(installResult),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: LayoutSeparate,
|
||||
OfficialSkills: official,
|
||||
UpdatedSkills: official,
|
||||
AddedOfficialSkills: official,
|
||||
@@ -501,7 +439,6 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason + "\nstate write failed: " + writeErr.Error(),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,38 +450,9 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
func stateFlatSkills(layout string, requested []string) []string {
|
||||
if layout != LayoutHybrid {
|
||||
return []string{}
|
||||
}
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(requested) {
|
||||
if skill != sharedSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string {
|
||||
previousOfficial := []string{}
|
||||
if stateReadable && previous != nil {
|
||||
previousOfficial = previous.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
added := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
added = append(added, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(added)
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -44,14 +43,6 @@ func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlatSkillsTrimsDeduplicatesAndSorts(t *testing.T) {
|
||||
got := ParseFlatSkills(" lark-doc, lark-im,,lark-doc, lark-base ")
|
||||
want := []string{"lark-base", "lark-doc", "lark-im"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseFlatSkills() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
@@ -225,10 +216,8 @@ type fakeSkillsRunner struct {
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installSuiteErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
installedSuite int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
@@ -330,13 +319,6 @@ func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSuiteSkill() *selfupdate.NpmResult {
|
||||
f.installedSuite++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installSuiteErr
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -490,34 +472,6 @@ func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridOfficialDiscoveryFailureDoesNotFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list unavailable"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
if result.Action != "failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want failed", result.Action)
|
||||
}
|
||||
if result.Layout != LayoutHybrid {
|
||||
t.Fatalf("SyncSkills() layout = %q, want %q", result.Layout, LayoutHybrid)
|
||||
}
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to discover official skills for hybrid layout") {
|
||||
t.Fatalf("SyncSkills() err = %v, want hybrid discovery failure", result.Err)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -620,7 +574,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T) {
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
@@ -631,15 +585,14 @@ func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %#v, want one incremental install", runner.installed)
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail"})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,200 +697,6 @@ func TestSyncSkills_NilRunnerFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridAssemblesSuiteAndMovesCollectedSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if runner.installedSuite != 1 {
|
||||
t.Fatalf("installedSuite = %d, want 1", runner.installedSuite)
|
||||
}
|
||||
assertStrings(t, result.Flat, []string{"lark-calendar"})
|
||||
assertStrings(t, result.Collected, []string{"lark-shared", "lark-doc"})
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-doc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-doc missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-shared missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-calendar"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("flat lark-calendar missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-doc"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("collected lark-doc still exists at top level or unexpected err: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("lark-shared should stay top-level when flat set is non-empty: %v", err)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Layout != LayoutHybrid {
|
||||
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutHybrid)
|
||||
}
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridCollectsNewOfficialSkillOnNextUpdate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
first := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
if first.Err != nil {
|
||||
t.Fatalf("first SyncSkills() err = %v, want nil", first.Err)
|
||||
}
|
||||
|
||||
paths["lark-new"] = filepath.Join(dir, "lark-new")
|
||||
for _, name := range []string{"lark-doc", "lark-new", "lark-shared", "lark-suite"} {
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
runner.officialIndexOut = officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-new", "lark-shared")
|
||||
runner.globalJSONOut = globalSkillsJSONFromPaths(paths)
|
||||
|
||||
second := SyncSkills(SyncOptions{
|
||||
Version: "1.0.34",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
if second.Err != nil {
|
||||
t.Fatalf("second SyncSkills() err = %v, want nil", second.Err)
|
||||
}
|
||||
assertStrings(t, second.Added, []string{"lark-new"})
|
||||
assertStrings(t, second.Flat, []string{"lark-calendar"})
|
||||
assertStrings(t, second.Collected, []string{"lark-shared", "lark-doc", "lark-new"})
|
||||
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-new", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-new missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-new"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("new official skill should be collected, top-level path err: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(paths["lark-suite"], "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read suite SKILL.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "- lark-new: lark-new description") {
|
||||
t.Fatalf("suite SKILL.md missing lark-new route:\n%s", data)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-doc", "lark-new", "lark-shared"})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridWithNoFlatSkillsDoesNotKeepSharedTopLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lark-shared should not stay top-level when flat set is empty; err: %v", err)
|
||||
}
|
||||
if result.Flat == nil || len(result.Flat) != 0 {
|
||||
t.Fatalf("result.Flat = %#v, want empty slice", result.Flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridFiltersNonFlatOfficialSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar", "lark-missing", "lark-shared"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Flat, []string{"lark-calendar"})
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -974,81 +733,6 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSuiteTemplateTextRewritesLegacyFlatSkillWording(t *testing.T) {
|
||||
input := "`lark-shared` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
got := normalizeSuiteTemplateText(input)
|
||||
if strings.Contains(got, "--collected-skills") {
|
||||
t.Fatalf("normalized text still contains legacy flag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--flat-skills") {
|
||||
t.Fatalf("normalized text missing --flat-skills: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份") {
|
||||
t.Fatalf("normalized text missing current lark-shared rule: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDescriptionSupportsFoldedYAMLScalar(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "SKILL.md")
|
||||
content := `---
|
||||
name: lark-whiteboard
|
||||
description: >
|
||||
飞书画板:查询和编辑飞书云文档中的画板。
|
||||
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := skillDescription(path)
|
||||
want := "飞书画板:查询和编辑飞书云文档中的画板。 当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。"
|
||||
if got != want {
|
||||
t.Fatalf("skillDescription() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSuiteLayoutSeparateIsNoop(t *testing.T) {
|
||||
if err := assembleSuiteLayout(LayoutSeparate, []string{"lark-doc"}, false, nil); err != nil {
|
||||
t.Fatalf("assembleSuiteLayout(separate) err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleSuiteLayoutMissingSuiteReturnsError(t *testing.T) {
|
||||
err := assembleSuiteLayout(LayoutHybrid, []string{"lark-doc"}, false, []GlobalSkillInfo{
|
||||
{Name: "lark-doc", Path: filepath.Join(t.TempDir(), "lark-doc")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "lark-suite") {
|
||||
t.Fatalf("assembleSuiteLayout() err = %v, want missing lark-suite error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDirCopiesNestedFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
dst := filepath.Join(root, "dst")
|
||||
if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
t.Fatalf("copyDir() err = %v, want nil", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dst, "nested", "file.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Fatalf("copied file = %q, want hello", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -1056,38 +740,6 @@ func assertStrings(t *testing.T, got, want []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSkill(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := fmt.Sprintf("---\nname: %s\ndescription: %s description\n---\n", name, name)
|
||||
if name == suiteSkillName {
|
||||
content = "---\nname: lark-suite\ndescription: Lark suite\n---\n<!-- LARK_SUITE_ROUTES -->\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func globalSkillsJSONFromPaths(paths map[string]string) string {
|
||||
names := make([]string, 0, len(paths))
|
||||
for name := range paths {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, paths[name])
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: lark-suite
|
||||
version: 0.1.0
|
||||
description: 飞书/Lark 聚合能力入口:当用户需求涉及本文件列出的任一飞书能力时使用;仅负责选择并加载已安装到本 suite 的 lark-* 子能力,不替代具体子能力的操作细节。
|
||||
metadata:
|
||||
requires:
|
||||
bins:
|
||||
- lark-cli
|
||||
---
|
||||
|
||||
# Lark Suite
|
||||
|
||||
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
|
||||
|
||||
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 根据用户意图从下方路由表选择一个或多个子能力;即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
|
||||
2. 仅使用本文件列出的路由与对应子能力入口,不要遍历或探测其他技能目录。
|
||||
3. 如果目标能力未列出,返回无法路由的明确提示。
|
||||
4. 仅读取当前已选子能力明确要求的前置文件。
|
||||
5. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`。
|
||||
|
||||
`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。
|
||||
|
||||
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
|
||||
|
||||
## 能力路由
|
||||
|
||||
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
|
||||
|
||||
<!-- LARK_SUITE_ROUTES -->
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.67",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||
|
||||
function normalizeRepoPath(input) {
|
||||
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||
}
|
||||
|
||||
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
function findPathMapping(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||
}
|
||||
|
||||
function labelDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||
}
|
||||
|
||||
function e2eDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||
}
|
||||
|
||||
function matchesFullFallback(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isSkippablePath(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||
|| (domainMap.skipFilenames || []).includes(basename);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
domainMap,
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
labelDomainsForPath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
};
|
||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"pathMappings": [
|
||||
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||
|
||||
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||
],
|
||||
"fullFallbackPrefixes": [
|
||||
"shortcuts/common/",
|
||||
"cmd/",
|
||||
"internal/",
|
||||
"pkg/",
|
||||
"extension/",
|
||||
"registry/",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"Makefile",
|
||||
".github/workflows/",
|
||||
"scripts/"
|
||||
],
|
||||
"skipPrefixes": [
|
||||
"docs/",
|
||||
".changeset/"
|
||||
],
|
||||
"skipSuffixes": [
|
||||
".md",
|
||||
".mdx",
|
||||
".txt",
|
||||
".rst"
|
||||
],
|
||||
"skipFilenames": [
|
||||
"readme.md",
|
||||
"readme.zh.md",
|
||||
"changelog.md",
|
||||
"license",
|
||||
"cla.md"
|
||||
]
|
||||
}
|
||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
} = require("./domain-map");
|
||||
|
||||
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||
process.chdir(ROOT);
|
||||
|
||||
function execLines(command, args) {
|
||||
return execFileSync(command, args, { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function modulePath() {
|
||||
return execLines("go", ["list", "-m"])[0];
|
||||
}
|
||||
|
||||
function rootPackage(moduleName) {
|
||||
return `${moduleName}/tests/cli_e2e`;
|
||||
}
|
||||
|
||||
function allLivePackages(moduleName) {
|
||||
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||
}
|
||||
|
||||
function allDryPackages(moduleName) {
|
||||
return allLivePackages(moduleName);
|
||||
}
|
||||
|
||||
const domainExistsCache = new Map();
|
||||
|
||||
function domainExists(domain) {
|
||||
if (domainExistsCache.has(domain)) {
|
||||
return domainExistsCache.get(domain);
|
||||
}
|
||||
let exists = false;
|
||||
try {
|
||||
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||
exists = true;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
domainExistsCache.set(domain, exists);
|
||||
return exists;
|
||||
}
|
||||
|
||||
function readChangedFiles() {
|
||||
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||
if (changedFilesPath) {
|
||||
return fs.readFileSync(changedFilesPath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeRepoPath)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addDomain(domains, domain) {
|
||||
if (domain && domainExists(domain)) {
|
||||
domains.add(domain);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function classifyPath(filePath, domains) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
if (!normalized) return { matched: false };
|
||||
|
||||
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||
if (e2eMatch) {
|
||||
const domain = e2eMatch[1];
|
||||
if (domain === "demo") return { matched: false };
|
||||
if (domainExists(domain)) {
|
||||
addDomain(domains, domain);
|
||||
return { matched: true };
|
||||
}
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||
}
|
||||
|
||||
if (matchesFullFallback(normalized)) {
|
||||
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
const mappedDomains = e2eDomainsForPath(normalized);
|
||||
if (mappedDomains.length > 0) {
|
||||
const missingDomains = [];
|
||||
for (const domain of mappedDomains) {
|
||||
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||
}
|
||||
if (missingDomains.length > 0) {
|
||||
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||
}
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
if (findPathMapping(normalized)) {
|
||||
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
|
||||
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
function resolveDomains(changedFiles) {
|
||||
const moduleName = modulePath();
|
||||
const rootDryPackage = rootPackage(moduleName);
|
||||
if (changedFiles === null) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: "non-pull_request run or unavailable diff",
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
const domains = new Set();
|
||||
let matchedRelevant = false;
|
||||
let fullReason = "";
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const result = classifyPath(file, domains);
|
||||
if (result.matched) matchedRelevant = true;
|
||||
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||
}
|
||||
|
||||
if (fullReason) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: fullReason,
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRelevant && domains.size > 0) {
|
||||
const sortedDomains = [...domains].sort();
|
||||
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||
return {
|
||||
mode: "subset",
|
||||
reason: "business domain changes",
|
||||
domains: sortedDomains,
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: packages,
|
||||
livePackages: packages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "skip",
|
||||
reason: "docs-only or no live CLI E2E impact",
|
||||
domains: [],
|
||||
dryRootPackage: "",
|
||||
dryPackages: [],
|
||||
livePackages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emit(resolved) {
|
||||
const values = {
|
||||
mode: resolved.mode,
|
||||
reason: resolved.reason,
|
||||
domains: resolved.domains.join(","),
|
||||
dry_root_package: resolved.dryRootPackage,
|
||||
dry_packages: resolved.dryPackages.join(" "),
|
||||
live_packages: resolved.livePackages.join(" "),
|
||||
};
|
||||
|
||||
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
emit(resolveDomains(readChangedFiles()));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyPath,
|
||||
readChangedFiles,
|
||||
resolveDomains,
|
||||
};
|
||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||
|
||||
function parseOutput(raw) {
|
||||
const result = {};
|
||||
for (const line of raw.trim().split(/\r?\n/)) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runDomains(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||
const file = path.join(dir, "changed.txt");
|
||||
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||
try {
|
||||
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("maps shortcut changes to one business domain package", () => {
|
||||
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "im");
|
||||
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("maps doc shortcuts to docs package", () => {
|
||||
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "docs");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||
});
|
||||
|
||||
test("maps direct e2e domain package changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "drive");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("falls back to full for shared e2e harness changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||
});
|
||||
|
||||
test("falls back to full for runtime changes", () => {
|
||||
const output = runDomains(["cmd/root.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared\/runtime path changed/);
|
||||
});
|
||||
|
||||
test("skips docs-only changes", () => {
|
||||
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||
assert.equal(output.mode, "skip");
|
||||
assert.equal(output.domains, "");
|
||||
assert.equal(output.dry_root_package, "");
|
||||
assert.equal(output.live_packages, "");
|
||||
});
|
||||
|
||||
test("uses shared map for skill domain changes", () => {
|
||||
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "sheets");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||
});
|
||||
|
||||
test("falls back to full when a mapped path has no e2e package", () => {
|
||||
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||
|
||||
// CODEOWNERS-based path to domain label mapping
|
||||
// Maps shortcuts and skills paths to business domain labels
|
||||
const PATH_TO_DOMAIN_MAP = {
|
||||
// shortcuts
|
||||
"shortcuts/im/": "im",
|
||||
"shortcuts/vc/": "vc",
|
||||
"shortcuts/calendar/": "calendar",
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/wiki/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
"shortcuts/contact/": "contact",
|
||||
// skills
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-wiki/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
"skills/lark-task/": "task",
|
||||
"skills/lark-contact/": "contact",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||
|
||||
const CLASS_STANDARDS = {
|
||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
cleanup_tmp() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$tmp" && return 0
|
||||
sleep 1
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
trap cleanup_tmp EXIT
|
||||
mkdir -p "$tmp"
|
||||
|
||||
git_init() {
|
||||
|
||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
params := map[string]interface{}{"page_size": 100}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
statusParams := map[string]interface{}{}
|
||||
if env != "" {
|
||||
statusParams["env"] = env
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Enable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||
stop()
|
||||
if err != nil {
|
||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Disable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
|
||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||
"table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||
|
||||
exportQuery := larkcore.QueryParams{
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
exportQuery["env"] = []string{env}
|
||||
}
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"env": []string{dbEnv(rctx)},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: exportQuery,
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
params := map[string]interface{}{"page_size": 1}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDataImportPath(appID)).
|
||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
importQuery["env"] = []string{env}
|
||||
}
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
||||
QueryParams: importQuery,
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
|
||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
}
|
||||
if p["table"] != "orders" {
|
||||
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经
|
||||
// dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。
|
||||
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
|
||||
// 交由下面真实 apply 统一报同样的业务错。
|
||||
pending := 0
|
||||
var previewFrom, previewTo string
|
||||
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
|
||||
pending = len(projectMigrationChanges(preview["changes"]))
|
||||
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
if from == "" {
|
||||
from = previewFrom
|
||||
}
|
||||
if to == "" {
|
||||
to = previewTo
|
||||
}
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||
if applied == 0 && pending > 0 {
|
||||
applied = pending
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
|
||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
|
||||
// query 不带 env 键(交服务端按应用形态自动选分支)。
|
||||
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
return dbEnvParams(rctx, map[string]interface{}{
|
||||
"transactional": false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
|
||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
|
||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
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), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
raw := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
raw = append(raw, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||
// 两行 + tables_affected 翻倍。
|
||||
hasSchema := map[string]bool{}
|
||||
for _, c := range raw {
|
||||
if c.Action != "" {
|
||||
hasSchema[c.Table] = true
|
||||
}
|
||||
}
|
||||
changes := make([]recoveryChange, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
if c.Action == "" && hasSchema[c.Table] {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, c)
|
||||
}
|
||||
// tables_affected 按去重后的不同表数计(而非变更条数)。
|
||||
seen := map[string]bool{}
|
||||
for _, c := range changes {
|
||||
seen[c.Table] = true
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"target": target, "tables_affected": len(seen),
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"unsupported string", "x", 0, false},
|
||||
{"non-numeric string", "x", 0, false},
|
||||
{"numeric string", "13.5", 13.5, true},
|
||||
{"numeric string int", "2", 2, true},
|
||||
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||
{"empty string", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user